This commit is contained in:
1 parent
cc6748a622
commit
a4cffb67e4
10 files changed
+370
-139
No files matched your search
@@ -21,12 +21,14 @@ web.jwt_secret is not set.
|
||||
Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S), OpenVPN and
|
||||
WireGuard. No configuration data, key or address is shown.
|
||||
|
||||
The address to bind to, the port, the secret and the configuration shown by /config
|
||||
are not read from the configuration here: vpn-router-setup resolves them, with the
|
||||
interfaces, and writes them to /run/vpn-router/web.json, readable by root only, before
|
||||
it restarts this service. The service ends with
|
||||
success when the file says the service is disabled, and with failure, to be
|
||||
started again, when the file is missing or the router has no internal address yet.
|
||||
The service needs nothing else to start and does not end on its own. A discovery thread works
|
||||
out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs, with
|
||||
web.port and web.enabled, every DISCOVERY_SECONDS, and runs the server thread there: it starts
|
||||
it, moves it when the address or the port changes, and stops it while the service is disabled
|
||||
or the address is not known. Every request reads what it needs when it is made: the token secret
|
||||
from the configuration, and the features of the router and the facts shown by /config from
|
||||
/run/vpn-router/web.json, which vpn-router-setup writes. Before setup has written that file
|
||||
the router has no features, and /config shows no configuration or interfaces.
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -42,7 +44,18 @@ import time
|
||||
from html import escape
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||
|
||||
import vpnrouter_net
|
||||
|
||||
STATE_FILE = '/run/vpn-router/web.json'
|
||||
CONFIG_FILE = '/etc/vpn-router/vpn-router.json'
|
||||
|
||||
#: The port when web.port is not set.
|
||||
DEFAULT_PORT = 8080
|
||||
|
||||
#: Seconds between two discoveries of where the service should listen.
|
||||
DISCOVERY_SECONDS = 30
|
||||
COMMAND_TIMEOUT = 5
|
||||
CACHE_SECONDS = 2
|
||||
|
||||
@@ -73,6 +86,33 @@ _cache = {'at': 0.0, 'data': None}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def read_json(path):
|
||||
"""The object in a JSON file, or {} when the file is missing or holds no object."""
|
||||
try:
|
||||
with open(path, encoding='utf-8') as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def section(configuration, name):
|
||||
"""A section of the configuration, or {} when it is missing or not an object."""
|
||||
value = configuration.get(name)
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def current():
|
||||
"""What a request needs, read now: the features and facts setup resolved, and the token secret.
|
||||
|
||||
Before setup has run the router has no features, and without a secret /config and /users are
|
||||
not served.
|
||||
"""
|
||||
state = read_json(STATE_FILE)
|
||||
secret = section(read_json(CONFIG_FILE), 'web').get('jwt_secret')
|
||||
return dict(state, jwt_secret=secret if isinstance(secret, str) else '')
|
||||
|
||||
|
||||
def run(*args):
|
||||
"""The finished process of a command, or None when it cannot be run."""
|
||||
try:
|
||||
@@ -433,13 +473,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def status(self):
|
||||
with _lock:
|
||||
if _cache['data'] is None or time.monotonic() - _cache['at'] > CACHE_SECONDS:
|
||||
_cache['data'] = collect(self.server.state)
|
||||
_cache['data'] = collect(current())
|
||||
_cache['at'] = time.monotonic()
|
||||
return _cache['data']
|
||||
|
||||
def authorized(self):
|
||||
def authorized(self, secret):
|
||||
"""Whether the request carries a token signed with the secret; False also while no secret is set."""
|
||||
secret = self.server.state.get('jwt_secret')
|
||||
header = self.headers.get('Authorization', '')
|
||||
return bool(secret) and header.startswith('Bearer ') and verify(header[7:].strip(), secret) is not None
|
||||
|
||||
@@ -449,16 +488,17 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# A body that is not read must not be taken for the next request.
|
||||
if self.command not in ('GET', 'HEAD'):
|
||||
self.close_connection = True
|
||||
protected =path == '/config' or path == '/users' or path.startswith('/users/')
|
||||
if protected and not self.server.state.get('jwt_secret'):
|
||||
protected = path == '/config' or path == '/users' or path.startswith('/users/')
|
||||
state = current() if protected else {}
|
||||
if protected and not state['jwt_secret']:
|
||||
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
|
||||
elif protected and not self.authorized():
|
||||
elif protected and not self.authorized(state['jwt_secret']):
|
||||
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
|
||||
extra.append(('WWW-Authenticate', 'Bearer'))
|
||||
elif path == '/config' and self.command in ('GET', 'HEAD'):
|
||||
body, kind, code = json.dumps(details(self.server.state), indent=2).encode() + b'\n', 'application/json', 200
|
||||
body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200
|
||||
elif protected and path != '/config':
|
||||
code, document = users(self.server.state, self.command, path, self.request_body())
|
||||
code, document = users(state, self.command, path, self.request_body())
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif self.command not in ('GET', 'HEAD'):
|
||||
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
|
||||
@@ -515,31 +555,65 @@ class Handler(BaseHTTPRequestHandler):
|
||||
sys.stderr.write('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args))
|
||||
|
||||
|
||||
def wanted():
|
||||
"""Where the service should listen now, as (address, port), or the reason it should not listen."""
|
||||
configuration = read_json(CONFIG_FILE)
|
||||
web = section(configuration, 'web')
|
||||
if web.get('enabled', True) is False:
|
||||
return None, 'the web service is disabled in the configuration'
|
||||
port = web.get('port', DEFAULT_PORT)
|
||||
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
|
||||
port = DEFAULT_PORT
|
||||
general, interfaces, local = (section(configuration, name) for name in ('general', 'interfaces', 'local'))
|
||||
address = vpnrouter_net.internal_address(str(general.get('mode') or 'manual'), str(interfaces.get('internal') or ''),
|
||||
str(local.get('int_addr') or ''))
|
||||
if not address:
|
||||
return None, 'the internal address is not known yet'
|
||||
return (address, port), ''
|
||||
|
||||
|
||||
def log(message):
|
||||
print(f'vpn-router-web: {message}', file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def discover():
|
||||
"""Work out where to listen every DISCOVERY_SECONDS, and run the server thread there.
|
||||
|
||||
When the address or the port changes, the server thread is stopped and a new one listens at
|
||||
the new place; while the service is disabled, the address is not known or it cannot be bound,
|
||||
no server runs and the next round tries again.
|
||||
"""
|
||||
server, thread, listening, reported = None, None, None, None
|
||||
while True:
|
||||
target, reason = wanted()
|
||||
if target != listening and server is not None:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
server, listening = None, None
|
||||
if target is not None and server is None:
|
||||
try:
|
||||
server = ThreadingHTTPServer(target, Handler)
|
||||
except OSError as exc:
|
||||
reason = f'cannot listen on {target[0]}:{target[1]}: {exc}'
|
||||
else:
|
||||
thread = threading.Thread(target=server.serve_forever, name='server', daemon=True)
|
||||
thread.start()
|
||||
listening, reason = target, f'serving on http://{target[0]}:{target[1]}'
|
||||
if reason and reason != reported:
|
||||
log(reason)
|
||||
reported = reason
|
||||
time.sleep(DISCOVERY_SECONDS)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
with open(STATE_FILE, encoding='utf-8') as handle:
|
||||
state = json.load(handle)
|
||||
except (OSError, ValueError) as exc:
|
||||
print(f'vpn-router-web: cannot read {STATE_FILE}: {exc}', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not state.get('enabled'):
|
||||
print('vpn-router-web: the web service is disabled in the configuration')
|
||||
return 0
|
||||
if not state.get('bind'):
|
||||
print('vpn-router-web: the router has no internal address yet', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
server = ThreadingHTTPServer((state['bind'], state['port']), Handler)
|
||||
server.state = state
|
||||
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
||||
print(f'vpn-router-web: serving on http://{state["bind"]}:{state["port"]}')
|
||||
discovery = threading.Thread(target=discover, name='discovery', daemon=True)
|
||||
discovery.start()
|
||||
try:
|
||||
server.serve_forever()
|
||||
discovery.join()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user