Cleanup, begin challenge class
This commit is contained in:
parent
063da32ae9
commit
f51b5f4c73
11 changed files with 340 additions and 292 deletions
112
README.md
112
README.md
|
@ -1,60 +1,86 @@
|
|||
## Notes to myself
|
||||
|
||||
* Notes in ungleich-marketing.org / Quiz
|
||||
|
||||
## Objective
|
||||
|
||||
This codebase is for preparing the ungleich game, which heavily relies
|
||||
on checking other people's VMs.
|
||||
|
||||
The (not so) hidden objective is to create the base for "cmon",
|
||||
our new monitoring system that does not suck.
|
||||
|
||||
### Planned features (monitoring)
|
||||
|
||||
- full parallel execution
|
||||
- history support -> possible grafana interface
|
||||
- easy to create and extend checks
|
||||
- Requirements: python3 + binaries for certain checks
|
||||
- Minimal core logic - checks can check "anything"
|
||||
|
||||
### Planned features (game)
|
||||
|
||||
- Allow registration
|
||||
- Allow deep (i.e. functionality based) checks of services
|
||||
- Define points (or monitoring severity)
|
||||
|
||||
|
||||
## Welcome to the ungleich-game, a geek game engine!
|
||||
|
||||
ungleich-game is supposed to be an easy-to-use, easy-to-play and
|
||||
easy-to-extend game framework for geeks. The project name is
|
||||
ungleich-game, as it has its roots at ungleich - the project name
|
||||
might change later.
|
||||
|
||||
## How to play
|
||||
|
||||
### Monitoring
|
||||
* Select a game server
|
||||
* Register
|
||||
* List challenges - have fun!
|
||||
|
||||
Test base:
|
||||
### How to play on Nico's notebook
|
||||
|
||||
1. Register
|
||||
|
||||
```
|
||||
python check-cli.py
|
||||
curl -d user=nico http://nico.ungleich.cloud:5002/register
|
||||
```
|
||||
|
||||
### Game (not implemented)
|
||||
2. Get challenges
|
||||
|
||||
ungleich register --name your-user-name --email your@email
|
||||
--first-name Yourfirstname --last-name YourLastName
|
||||
```
|
||||
curl http://nico.ungleich.cloud:5002/challenge
|
||||
```
|
||||
|
||||
ungleich play-game --game register --ip
|
||||
ungleich play-game --game dns-forward --ip
|
||||
3. Get a challenge description
|
||||
|
||||
## Documentation
|
||||
```
|
||||
curl http://nico.ungleich.cloud:5002/challenge/registernet
|
||||
```
|
||||
|
||||
- Raise CheckException on parameter wrong
|
||||
4. Solve a challenge
|
||||
|
||||
## How to write a check
|
||||
```
|
||||
curl -d user=nico -d 2a0a:e5c0:101::/64 http://nico.ungleich.cloud:5002/challenge/registernet
|
||||
```
|
||||
|
||||
- Your command must return with exit code = 0
|
||||
- Output will be saved by cmon, but not interpreted
|
||||
5. Get high score
|
||||
|
||||
## TODOs
|
||||
```
|
||||
curl http://nico.ungleich.cloud:5002/highscore
|
||||
```
|
||||
|
||||
- last result: select checkname where result = true
|
||||
- last success: select checkname where result = true
|
||||
|
||||
## Overview - Game flow
|
||||
|
||||
* Users register at a game server
|
||||
* Users play by getting challenges from the game server
|
||||
* Users can see their or all high scores on the main page
|
||||
|
||||
## Overview - Development Flow
|
||||
|
||||
[not yet fully implemented]
|
||||
|
||||
The idea is that there are challenges and each challenge offers:
|
||||
|
||||
* A description
|
||||
* Some dependencies (on something another challenge can provide)
|
||||
* A score ("how difficult it is")
|
||||
|
||||
### How to add challenges
|
||||
|
||||
* Create challenges-<YOURNAME>.py and add challenges in there
|
||||
* Do some magic so all challenges are imported by server
|
||||
|
||||
## Overview - Security
|
||||
|
||||
None at the moment.
|
||||
|
||||
|
||||
## Tech stack
|
||||
|
||||
The base for building games is:
|
||||
|
||||
* Python3 - The programmming language
|
||||
* Flask - web frontend
|
||||
* etcd - storing data, games, etcd.
|
||||
|
||||
## Things to solve
|
||||
|
||||
* Enhance the Challenge class - maybe make it easier for challenges to abort
|
||||
* Enhance the Challenge class - abstract away writing information?
|
||||
* Implement dependencies / providers for challenges
|
||||
* Add an easy to use CLI (Steven might like click)
|
||||
|
|
62
archive/server.py
Normal file
62
archive/server.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
from flask import Flask, request
|
||||
from flask_restful import Resource, Api
|
||||
from sqlalchemy import create_engine
|
||||
from json import dumps
|
||||
from flask.json import jsonify
|
||||
|
||||
db_connect = create_engine('sqlite:///chinook.db')
|
||||
app = Flask(__name__)
|
||||
api = Api(app)
|
||||
|
||||
class Employees(Resource):
|
||||
def get(self):
|
||||
conn = db_connect.connect() # connect to database
|
||||
query = conn.execute("select * from employees") # This line performs query and returns json result
|
||||
return {'employees': [i[0] for i in query.cursor.fetchall()]} # Fetches first column that is Employee ID
|
||||
|
||||
class Tracks(Resource):
|
||||
def get(self):
|
||||
conn = db_connect.connect()
|
||||
query = conn.execute("select trackid, name, composer, unitprice from tracks;")
|
||||
result = {'data': [dict(zip(tuple (query.keys()) ,i)) for i in query.cursor]}
|
||||
return jsonify(result)
|
||||
|
||||
class Employees_Name(Resource):
|
||||
def get(self, employee_id):
|
||||
conn = db_connect.connect()
|
||||
query = conn.execute("select * from employees where EmployeeId =%d " %int(employee_id))
|
||||
result = {'data': [dict(zip(tuple (query.keys()) ,i)) for i in query.cursor]}
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def hello():
|
||||
return """
|
||||
<pre>
|
||||
|
||||
|
||||
Join the game
|
||||
|
||||
- Create an on account.ungleich.ch
|
||||
-> creates ldap account
|
||||
|
||||
Creating the game:
|
||||
|
||||
- Create IPv6 only VM + http proxy
|
||||
- Intro on main page
|
||||
- Score list below
|
||||
- Users can be clicked
|
||||
|
||||
</pre>
|
||||
|
||||
"""
|
||||
|
||||
|
||||
api.add_resource(Employees, '/employees') # Route_1
|
||||
api.add_resource(Employees_Name, '/employees/<employee_id>') # Route_3
|
||||
#api.add_resource(Tracks, '/tracks') # Route_2
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(port='5002')
|
207
game-etcd.py
207
game-etcd.py
|
@ -1,207 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
USERLENGTH = 50
|
||||
|
||||
import ipaddress
|
||||
import random
|
||||
import sys
|
||||
import etcd
|
||||
import ungleichapi
|
||||
import json
|
||||
import datetime
|
||||
|
||||
from flask import Flask, abort, request, Response
|
||||
from flask_restful import Resource, Api
|
||||
from flask_restful import reqparse
|
||||
|
||||
app = Flask(__name__)
|
||||
api = Api(app)
|
||||
|
||||
|
||||
def get_random_ip(network):
|
||||
net = ipaddress.IPv6Network(network)
|
||||
addr_offset = random.randrange(2**64)
|
||||
addr = net[0] + addr_offset
|
||||
|
||||
return addr
|
||||
|
||||
class Level(Resource):
|
||||
points = 0
|
||||
|
||||
def test(self):
|
||||
pass
|
||||
|
||||
class Ping6(Level):
|
||||
points = 10
|
||||
|
||||
def test(self):
|
||||
|
||||
"""
|
||||
ping6 -c3
|
||||
"""
|
||||
|
||||
class Game(object):
|
||||
def __init__(self, name, etcdclient, etcbase="/ungleichgame/v1"):
|
||||
self.client = etcdclient
|
||||
self.app = Flask(name)
|
||||
self.app.add_url_rule('/', 'highscore', self.highscore)
|
||||
self.app.add_url_rule('/highscore', 'highscore', self.highscore)
|
||||
self.app.add_url_rule("/register", 'register', self.register, methods=['POST'])
|
||||
|
||||
# etcd paths are below here
|
||||
self.etcbase = etcbase
|
||||
self.userbase = "{}/user".format(self.etcbase)
|
||||
|
||||
|
||||
def read_etcd(self, path, recursive=False):
|
||||
try:
|
||||
data = self.client.read(path, recursive=recursive)
|
||||
except etcd.EtcdKeyNotFound:
|
||||
return None
|
||||
except Exception:
|
||||
abort(Response(status=400, response="Error connecting to etcd"))
|
||||
|
||||
return data
|
||||
|
||||
def get_highscore(self, username=None):
|
||||
""" Returns a dict['username'] = points """
|
||||
|
||||
all_users = {}
|
||||
highscore = {}
|
||||
|
||||
print("getting high")
|
||||
|
||||
if username:
|
||||
path = "{}/{}".format(self.userbase, username)
|
||||
user = self.read_etcd(path)
|
||||
if user:
|
||||
all_users[username] = user
|
||||
else:
|
||||
path = "{}/".format(self.userbase)
|
||||
users = self.read_etcd(path, recursive=True)
|
||||
print("reading from {}".format(path))
|
||||
if users:
|
||||
for child in users.children:
|
||||
print("adding user {} {} = {}".format(child, child.key, child.value))
|
||||
all_users[child.key] = child.value
|
||||
|
||||
for k, v in all_users.items():
|
||||
# Ignore all kind of errors - just add the ones that work
|
||||
try:
|
||||
highscore[k] = json.loads(v)['points']
|
||||
print("f?")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
return highscore
|
||||
|
||||
def highscore(self):
|
||||
point_list = self.get_highscore()
|
||||
res = []
|
||||
if not point_list:
|
||||
return Response("No winners yet!")
|
||||
|
||||
for k, v in point_list.items():
|
||||
res.append("<p>{} has {} points</p>".format(k, v))
|
||||
|
||||
return Response("\n".join(res))
|
||||
|
||||
def require_args(self, *args):
|
||||
parser = reqparse.RequestParser()
|
||||
for arg in args:
|
||||
parser.add_argument(arg, required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
def register(self):
|
||||
args = self.require_args("network", "user")
|
||||
|
||||
# Needs to be fixed with ungleich-otp
|
||||
username=args['user']
|
||||
|
||||
try:
|
||||
net = ipaddress.IPv6Network(args['network'])
|
||||
network = args['network']
|
||||
except Exception as e:
|
||||
return Response(status=400, response="Cannot register network {}: {}".format(network, e))
|
||||
|
||||
if not net.prefixlen == 64:
|
||||
return Response(status=400, response="{} mask is not /64 - please use a /64 network".format(net))
|
||||
|
||||
self.client.write("/ungleichgame/v1/{}/network".format(username), network)
|
||||
data = self.client.read("/ungleichgame/v1/{}/network".format(username))
|
||||
|
||||
return json.dumps("All good, go to /level/1 to start with level 1! - {}".format(data.value))
|
||||
|
||||
|
||||
|
||||
@app.route("/level/1", methods=['GET', 'POST']) # post for username
|
||||
def get_ip_address():
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument('user', required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Needs to be fixed with ungleich-otp
|
||||
username=args['user']
|
||||
|
||||
if request.method == 'GET':
|
||||
return Response("""
|
||||
This is an easy level - just register any /64 network
|
||||
that you fully control. After submission the game server will generate
|
||||
a random IPv6 address in this network.
|
||||
""")
|
||||
|
||||
client = etcd.Client(port=2379)
|
||||
try:
|
||||
data = client.read("/ungleichgame/v1/{}/network".format(username))
|
||||
# FIXME: differentiate keynotfound and other errors
|
||||
except Exception as e:
|
||||
return Response(status=400, response="Cannot read your network, try registering first (error: {})".format(e))
|
||||
|
||||
return Response("data={}".format(data.value))
|
||||
address = get_random_ip(data.value)
|
||||
# FIXME: catch errors
|
||||
client.write("/ungleichgame/v1/{}/address".format(username), address)
|
||||
|
||||
return Response("Your IPv6 address for this game is {}. Make it pingable and post to /level/1/result".format(address))
|
||||
|
||||
@app.route("/level/2", methods=['GET', 'POST']) # post for username
|
||||
def pingme():
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument('user', required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Needs to be fixed with ungleich-otp
|
||||
username=args['user']
|
||||
|
||||
if request.method == 'GET':
|
||||
return Response("""
|
||||
Proof that you can really control the network that you submitted:
|
||||
|
||||
- Setup the IPv6 address to be ping6 able globally
|
||||
- POST to this address when it is configured
|
||||
""")
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
data = client.read("/ungleichgame/v1/{}/address".format(username), address)
|
||||
except Exception as e:
|
||||
return Response(status=400,
|
||||
response="""
|
||||
You need to register a network before trying to be reachable.
|
||||
Please go back to Level 1 for registering your network.
|
||||
""")
|
||||
return Response("something good")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
net_base = "2a0a:e5c1:{:x}::/64"
|
||||
net_offset = random.randrange(0xffff)
|
||||
net = ipaddress.IPv6Network(net_base.format(net_offset))
|
||||
username = 'nico{}'.format(net_offset)
|
||||
|
||||
print("{} has {}".format(username, net))
|
||||
|
||||
g = Game(__name__, etcd.Client(port=2379))
|
||||
g.app.run(port='5002')
|
||||
|
||||
# app.run(port='5002')
|
251
server.py
251
server.py
|
@ -1,62 +1,229 @@
|
|||
from flask import Flask, request
|
||||
from flask_restful import Resource, Api
|
||||
from sqlalchemy import create_engine
|
||||
from json import dumps
|
||||
from flask.json import jsonify
|
||||
#!/usr/bin/env python3
|
||||
|
||||
db_connect = create_engine('sqlite:///chinook.db')
|
||||
app = Flask(__name__)
|
||||
api = Api(app)
|
||||
USERLENGTH = 50
|
||||
|
||||
class Employees(Resource):
|
||||
def get(self):
|
||||
conn = db_connect.connect() # connect to database
|
||||
query = conn.execute("select * from employees") # This line performs query and returns json result
|
||||
return {'employees': [i[0] for i in query.cursor.fetchall()]} # Fetches first column that is Employee ID
|
||||
import ipaddress
|
||||
import random
|
||||
import sys
|
||||
import etcd
|
||||
import ungleichapi
|
||||
import json
|
||||
import datetime
|
||||
|
||||
class Tracks(Resource):
|
||||
def get(self):
|
||||
conn = db_connect.connect()
|
||||
query = conn.execute("select trackid, name, composer, unitprice from tracks;")
|
||||
result = {'data': [dict(zip(tuple (query.keys()) ,i)) for i in query.cursor]}
|
||||
return jsonify(result)
|
||||
from flask import Flask, abort, request, Response
|
||||
from flask_restful import reqparse
|
||||
|
||||
class Employees_Name(Resource):
|
||||
def get(self, employee_id):
|
||||
conn = db_connect.connect()
|
||||
query = conn.execute("select * from employees where EmployeeId =%d " %int(employee_id))
|
||||
result = {'data': [dict(zip(tuple (query.keys()) ,i)) for i in query.cursor]}
|
||||
return jsonify(result)
|
||||
# app = Flask(__name__)
|
||||
|
||||
def get_random_ip(network):
|
||||
net = ipaddress.IPv6Network(network)
|
||||
addr_offset = random.randrange(2**64)
|
||||
addr = net[0] + addr_offset
|
||||
|
||||
@app.route("/")
|
||||
def hello():
|
||||
return """
|
||||
<pre>
|
||||
return addr
|
||||
|
||||
class Challenge(object):
|
||||
""" A sample challenge -- inherit this and overwrite accordingly """
|
||||
|
||||
Join the game
|
||||
points = 0
|
||||
provides = []
|
||||
requires = []
|
||||
|
||||
- Create an on account.ungleich.ch
|
||||
-> creates ldap account
|
||||
def __init__(self, etcdclient):
|
||||
self.client = etcdclient
|
||||
|
||||
Creating the game:
|
||||
def require_args(self, *args):
|
||||
parser = reqparse.RequestParser()
|
||||
for arg in args:
|
||||
parser.add_argument(arg, required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
- Create IPv6 only VM + http proxy
|
||||
- Intro on main page
|
||||
- Score list below
|
||||
- Users can be clicked
|
||||
def describe(self):
|
||||
return self.description
|
||||
|
||||
</pre>
|
||||
def save_points(self, user):
|
||||
""" should be called when the challenge was solved successfully"""
|
||||
|
||||
path = "/ungleichgame/v1/{}/challenges/{}/points".format(user, self.__name__)
|
||||
self.client.write(path, self.points)
|
||||
|
||||
def solve(self):
|
||||
""" Needs to be implemented per challenge """
|
||||
pass
|
||||
|
||||
class RegisterNet(Challenge):
|
||||
points = 10
|
||||
provides = [ "network" ]
|
||||
|
||||
description = """
|
||||
Register a /64 IPv6 network that you fully control.
|
||||
Many other challenges depend on this. You will need to
|
||||
be able to configure IPv6 addresses in this networks
|
||||
and to setup services listening on these IPv6 addresses.
|
||||
|
||||
Submit your network with the "network" parameter.
|
||||
"""
|
||||
def solve(self):
|
||||
self.require_args("user", "network")
|
||||
|
||||
try:
|
||||
net = ipaddress.IPv6Network(args['network'])
|
||||
network = args['network']
|
||||
except Exception as e:
|
||||
return Response(status=400, response="Cannot register network {}: {}".format(network, e))
|
||||
|
||||
if not net.prefixlen == 64:
|
||||
return Response(status=400, response="{} mask is not /64 - please use a /64 network".format(net))
|
||||
|
||||
# Save network
|
||||
self.client.write("/ungleichgame/v1/{}/network".format(user), network)
|
||||
self.save_points(args['user'])
|
||||
|
||||
return json.dumps("All good, go to /level/1 to start with level 1! - {}".format(data.value))
|
||||
|
||||
|
||||
api.add_resource(Employees, '/employees') # Route_1
|
||||
api.add_resource(Employees_Name, '/employees/<employee_id>') # Route_3
|
||||
#api.add_resource(Tracks, '/tracks') # Route_2
|
||||
|
||||
class Game(object):
|
||||
def __init__(self, name, etcdclient, etcbase="/ungleichgame/v1"):
|
||||
self.client = etcdclient
|
||||
self.app = Flask(name)
|
||||
|
||||
self.app.add_url_rule('/', 'highscore', self.highscore)
|
||||
self.app.add_url_rule('/highscore', 'highscore', self.highscore)
|
||||
|
||||
# etcd paths are below here
|
||||
self.etcbase = etcbase
|
||||
self.userbase = "{}/user".format(self.etcbase)
|
||||
|
||||
# Automate this
|
||||
challenges = [ RegisterNet ]
|
||||
|
||||
for challenge in challenges:
|
||||
c = challenge(self.client)
|
||||
name = c.__name__
|
||||
path = "/challenge/{}".format(name)
|
||||
|
||||
self.app.add_url_rule(path, name, c.describe, methods=['GET'])
|
||||
self.app.add_url_rule(path, name, c.solve, methods=['POST'])
|
||||
|
||||
|
||||
def read_etcd(self, path, recursive=False):
|
||||
try:
|
||||
data = self.client.read(path, recursive=recursive)
|
||||
except etcd.EtcdKeyNotFound:
|
||||
return None
|
||||
except Exception:
|
||||
abort(Response(status=400, response="Error connecting to etcd"))
|
||||
|
||||
return data
|
||||
|
||||
def get_highscore(self, username=None):
|
||||
""" Returns a dict['username'] = points """
|
||||
|
||||
all_users = {}
|
||||
highscore = {}
|
||||
|
||||
print("getting high")
|
||||
|
||||
if username:
|
||||
path = "{}/{}".format(self.userbase, username)
|
||||
user = self.read_etcd(path)
|
||||
if user:
|
||||
all_users[username] = user
|
||||
else:
|
||||
path = "{}/".format(self.userbase)
|
||||
users = self.read_etcd(path, recursive=True)
|
||||
print("reading from {}".format(path))
|
||||
if users:
|
||||
for child in users.children:
|
||||
print("adding user {} {} = {}".format(child, child.key, child.value))
|
||||
all_users[child.key] = child.value
|
||||
|
||||
for k, v in all_users.items():
|
||||
# Ignore all kind of errors - just add the ones that work
|
||||
try:
|
||||
highscore[k] = json.loads(v)['points']
|
||||
print("f?")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
return highscore
|
||||
|
||||
def highscore(self):
|
||||
point_list = self.get_highscore()
|
||||
res = []
|
||||
if not point_list:
|
||||
return Response("No winners yet!")
|
||||
|
||||
for k, v in point_list.items():
|
||||
res.append("<p>{} has {} points</p>".format(k, v))
|
||||
|
||||
return Response("\n".join(res))
|
||||
|
||||
|
||||
# def get_ip_address():
|
||||
# args = self.require_args("network", "user")
|
||||
|
||||
# # Needs to be fixed with ungleich-otp
|
||||
# username=args['user']
|
||||
|
||||
# if request.method == 'GET':
|
||||
# return Response("""
|
||||
# This is an easy level - just register any /64 network
|
||||
# that you fully control. After submission the game server will generate
|
||||
# a random IPv6 address in this network.
|
||||
# """)
|
||||
|
||||
# client = etcd.Client(port=2379)
|
||||
# try:
|
||||
# data = client.read("/ungleichgame/v1/{}/network".format(username))
|
||||
# # FIXME: differentiate keynotfound and other errors
|
||||
# except Exception as e:
|
||||
# return Response(status=400, response="Cannot read your network, try registering first (error: {})".format(e))
|
||||
|
||||
# return Response("data={}".format(data.value))
|
||||
# address = get_random_ip(data.value)
|
||||
# # FIXME: catch errors
|
||||
# client.write("/ungleichgame/v1/{}/address".format(username), address)
|
||||
|
||||
# return Response("Your IPv6 address for this game is {}. Make it pingable and post to /level/1/result".format(address))
|
||||
|
||||
# @app.route("/level/2", methods=['GET', 'POST']) # post for username
|
||||
# def pingme():
|
||||
# parser = reqparse.RequestParser()
|
||||
# parser.add_argument('user', required=True)
|
||||
# args = parser.parse_args()
|
||||
|
||||
# # Needs to be fixed with ungleich-otp
|
||||
# username=args['user']
|
||||
|
||||
# if request.method == 'GET':
|
||||
# return Response("""
|
||||
# Proof that you can really control the network that you submitted:
|
||||
|
||||
# - Setup the IPv6 address to be ping6 able globally
|
||||
# - POST to this address when it is configured
|
||||
# """)
|
||||
|
||||
# if request.method == 'POST':
|
||||
# try:
|
||||
# data = client.read("/ungleichgame/v1/{}/address".format(username), address)
|
||||
# except Exception as e:
|
||||
# return Response(status=400,
|
||||
# response="""
|
||||
# You need to register a network before trying to be reachable.
|
||||
# Please go back to Level 1 for registering your network.
|
||||
# """)
|
||||
# return Response("something good")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(port='5002')
|
||||
# net_base = "2a0a:e5c1:{:x}::/64"
|
||||
# net_offset = random.randrange(0xffff)
|
||||
# net = ipaddress.IPv6Network(net_base.format(net_offset))
|
||||
# username = 'nico{}'.format(net_offset)
|
||||
|
||||
# print("{} has {}".format(username, net))
|
||||
|
||||
g = Game(__name__, etcd.Client(port=2379))
|
||||
g.app.run(host="::", port='5002')
|
||||
|
|
Loading…
Reference in a new issue