Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83c1c50d8d | ||
|
|
650896131d | ||
|
|
dbc2046e24 |
No files matched your search
@@ -42,9 +42,15 @@ the package depends on, and no web framework. It listens on `web.port`, 7081 by
|
||||
address of the internal interface, of the external interface or of both, as `web.listen` sets
|
||||
(`internal`, `external` or `both`, the default), with a server for each address, and is on
|
||||
unless `web.enabled` is `false` (ADR-7). `/` is for people, in plain text for `curl` and `wget`
|
||||
and as a Tailwind CSS page for a browser, and `/status` is the same status as JSON. It shows the
|
||||
state of the services, the package version and the state of the site-to-site, road-warrior,
|
||||
OpenVPN and WireGuard features, and nothing from the configuration. `vpn-router-setup` opens the
|
||||
and as a Tailwind CSS page for a browser, and `/status` is the same status as JSON. Both show as
|
||||
much as `web.status_content` says, since the router is reachable from the internet and the
|
||||
operator decides how much it tells strangers: `basic`, the default because it tells the least,
|
||||
the overall status and the Client IP, `standard` also the package versions and the number of
|
||||
active and other services, and `full` also each service and the state of the site-to-site, road-warrior, OpenVPN and
|
||||
WireGuard features. The Client IP is the address the request came from, through the trusted
|
||||
proxies, so a person can see the address the router sees. Nothing comes from the configuration.
|
||||
`/status` names its content, so clients such as manage-ui and the Router Manager show what it
|
||||
has. `vpn-router-setup` opens the
|
||||
port on both interfaces in UFW, so the choice of `web.listen` is made by the service alone, and
|
||||
writes the features and the facts it resolves to `/run/vpn-router/web.json`.
|
||||
|
||||
|
||||
@@ -127,7 +127,8 @@ must have the type shown below; an unknown section or setting is an error.
|
||||
| `web` | `listen` | string | `both` | The interfaces the status web service listens on: `internal`, `external` or `both` |
|
||||
| `web` | `port` | integer | `7081` | TCP port of the status web service |
|
||||
| `web` | `database` | string | | A PostgreSQL connection string for the refresh tokens; without it, the `vpn-router` database on this host that the web service creates |
|
||||
| `web` | `trusted_proxies` | list | the address the call came in on | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit |
|
||||
| `web` | `trusted_proxies` | list | the address the call came in on | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit and the Client IP of the status |
|
||||
| `web` | `status_content` | string | `basic` | What `/` and `/status` show: `basic`, `standard` or `full` (see Status web service) |
|
||||
| `https` | `enabled` | boolean | `true` | Caddy serves the status web service and the Router Manager over HTTPS for `wan.local_fqdn` |
|
||||
| `https` | `email` | string | | The e-mail address of the Let's Encrypt account |
|
||||
| `https` | `web_port` | integer | `7444` | HTTPS port of the status web service |
|
||||
@@ -239,6 +240,24 @@ vpn-router-setup` applies a changed configuration while Docker runs. With `docke
|
||||
`on`, the service fails when Docker is not installed or its daemon is not running; with
|
||||
`off` it removes the rules.
|
||||
|
||||
## Editing the configuration
|
||||
|
||||
`vpn-config-edit`, run as root, shows and changes `/etc/vpn-router/vpn-router.json`:
|
||||
|
||||
```sh
|
||||
vpn-config-edit show
|
||||
vpn-config-edit set web.status_content standard
|
||||
vpn-config-edit set web.port 7082
|
||||
vpn-config-edit set web.trusted_proxies 127.0.0.1,10.0.0.4
|
||||
```
|
||||
|
||||
`show` prints the file as indented JSON and does not change it. `set` takes a setting as
|
||||
`section.option` and converts the value to the setting's type: a whole number, `true` or
|
||||
`false`, a list separated by commas (an empty value is an empty list), or a string; an unknown
|
||||
setting is refused. It checks the whole configuration as `vpn-router-setup` does, and only then
|
||||
writes the file, indented, with mode 0600, and reminds that `vpn-router-setup` applies it. A
|
||||
refused request exits with status 2, any other failure with 1.
|
||||
|
||||
## Applying changes
|
||||
|
||||
```sh
|
||||
@@ -500,7 +519,19 @@ modules shipped with the package; the configuration file accepts any module inst
|
||||
The `vpn-router-web` service shows the state of the router on the addresses of the interfaces
|
||||
`web.listen` names, the internal, the external or both (the default), and on `127.0.0.1`, where
|
||||
Caddy calls it (see HTTPS), with `web.port` (7081 by default). Its pages `/` and `/status` need no authentication and show no
|
||||
configuration: no key, identity, address or subnet.
|
||||
configuration: no key, identity, subnet, or address but the client's. They show as much as
|
||||
`web.status_content` says, read at each request:
|
||||
|
||||
| `status_content` | `/` and `/status` show |
|
||||
|---|---|
|
||||
| `basic` (default) | The overall status (`ok` or `degraded`), the time and the Client IP |
|
||||
| `standard` | Also the `vpn-router` version, the `vpn-router-manager` version or that it is not installed, and the number of active services and of services in any other state, without their names |
|
||||
| `full` | Also each service and its state, Docker, and the state of site-to-site, road-warrior, OpenVPN and WireGuard |
|
||||
|
||||
The Client IP is the address the request came from: the last entry of `X-Forwarded-For` for a
|
||||
request from one of `web.trusted_proxies`, by default the address the request came in on, so
|
||||
behind Caddy or a Router Manager on the router it is the browser's. `/status` names the content in
|
||||
`content` and the Client IP in `client_ip`; `/` keeps the time and the link to `/status`.
|
||||
|
||||
The service first makes its signing key and the refresh tokens' database, retrying every 15
|
||||
seconds, for example while PostgreSQL is not running yet, and logs each failure; then it listens.
|
||||
|
||||
@@ -1,3 +1,41 @@
|
||||
vpn-router (0.15.0-1) unstable; urgency=medium
|
||||
|
||||
feature: vpn-config-edit shows and changes the router's configuration.
|
||||
|
||||
* vpn-config-edit show prints /etc/vpn-router/vpn-router.json as
|
||||
indented JSON.
|
||||
* vpn-config-edit set SECTION.OPTION VALUE converts the value to the
|
||||
setting's type, checks the whole configuration as vpn-router-setup
|
||||
does, writes the file indented with mode 0600, and reminds that
|
||||
vpn-router-setup applies it.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 18:00:00 +0200
|
||||
|
||||
vpn-router (0.14.1-1) unstable; urgency=medium
|
||||
|
||||
fix: web.status_content is basic by default.
|
||||
|
||||
* Without web.status_content, or with a value that is not allowed, /
|
||||
and /status show only the overall status, the time and the Client
|
||||
IP; set it to standard or full to show more.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 17:00:00 +0200
|
||||
|
||||
vpn-router (0.14.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the status page shows the Client IP, and web.status_content sets how much it shows.
|
||||
|
||||
* / and /status show the Client IP, the address the request came from:
|
||||
the last X-Forwarded-For entry for a request from a trusted proxy, so
|
||||
behind Caddy or the Router Manager the browser's; /status has it as
|
||||
client_ip.
|
||||
* web.status_content sets what / and /status show: basic the overall
|
||||
status, the time and the Client IP; standard also the package
|
||||
versions and the number of active and failed services; full, the
|
||||
default, everything as before. /status names it in content.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 16:00:00 +0200
|
||||
|
||||
vpn-router (0.13.0-1) unstable; urgency=medium
|
||||
|
||||
feature: Caddy serves the status page and the Router Manager over HTTPS with a certificate from Let's Encrypt.
|
||||
|
||||
@@ -9,6 +9,7 @@ src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/
|
||||
src/usr/sbin/vpn-router-user usr/sbin/
|
||||
src/usr/sbin/vpn-router-group usr/sbin/
|
||||
src/usr/sbin/vpn-router-token usr/sbin/
|
||||
src/usr/sbin/vpn-config-edit usr/sbin/
|
||||
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/configure usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/
|
||||
|
||||
@@ -54,13 +54,17 @@ SCHEMA = {
|
||||
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
|
||||
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
||||
'docker': {'mode': 'auto', 'exposed_ports': []},
|
||||
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': []},
|
||||
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': [],
|
||||
'status_content': 'basic'},
|
||||
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443},
|
||||
}
|
||||
|
||||
#: The port of Let's Encrypt's HTTP challenge, which Caddy answers while HTTPS is on.
|
||||
ACME_HTTP_PORT = 80
|
||||
|
||||
#: What / and /status of the web service show, web.status_content, from the least to the most.
|
||||
STATUS_CONTENTS = ('basic', 'standard', 'full')
|
||||
|
||||
DOCKER_MODES = ('auto', 'on', 'off')
|
||||
|
||||
OPENVPN_PROTOCOLS = ('udp', 'tcp')
|
||||
@@ -268,6 +272,11 @@ def validate(cfg):
|
||||
raise ConfigError(
|
||||
f'web.listen: {web_listen!r} is not one of {", ".join(LISTEN_CHOICES)}')
|
||||
|
||||
status_content = _get(cfg, 'web', 'status_content')
|
||||
if status_content not in STATUS_CONTENTS:
|
||||
raise ConfigError(
|
||||
f'web.status_content: {status_content!r} is not one of {", ".join(STATUS_CONTENTS)}')
|
||||
|
||||
https_ports = []
|
||||
for option in ('web_port', 'manager_port'):
|
||||
port = _get(cfg, 'https', option)
|
||||
|
||||
@@ -29,10 +29,13 @@ served (404) while the key does not exist. /auth is limited to AUTH_LIMIT calls
|
||||
address in AUTH_WINDOW seconds; the address of a call from one of web.trusted_proxies, by
|
||||
default the address the call came in on, is the last entry of its X-Forwarded-For header.
|
||||
|
||||
/ and /status show only the status: whether the services run, the package version and the
|
||||
installed version of vpn-router-manager (manager_version, null when it is not installed), whether
|
||||
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.
|
||||
/ and /status show only the status, as much as web.status_content says (see shown): basic, the
|
||||
default, the overall status, the time and the client's address (its address as for the rate limit
|
||||
below); standard also the package version, the installed version of vpn-router-manager
|
||||
(manager_version, null when it is not installed) and the number of active and other services; full
|
||||
whether each service runs, whether Docker is installed, and the state of site-to-site, road-warrior
|
||||
(IPsec P2S), OpenVPN and WireGuard. No configuration data or key is shown, and no address but the
|
||||
client's.
|
||||
|
||||
The service first initializes, without listening: it makes the signing key and the refresh
|
||||
tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until both are ready and
|
||||
@@ -289,6 +292,26 @@ def collect(state):
|
||||
return status
|
||||
|
||||
|
||||
def shown(status, content, client):
|
||||
"""The status document as web.status_content shows it to the client at its address.
|
||||
|
||||
Every content has status, time and client_ip, the client's address, and content, which one it is: basic nothing
|
||||
more; standard adds version, manager_version and services, the number of active units and of units in every
|
||||
other state; full adds the whole status document of collect. Content that is not one of
|
||||
vpnrouter.STATUS_CONTENTS, or not set, is basic.
|
||||
"""
|
||||
if content not in vpnrouter.STATUS_CONTENTS:
|
||||
content = 'basic'
|
||||
if content == 'full':
|
||||
return {**status, 'content': content, 'client_ip': client}
|
||||
document = {'status': status['status'], 'content': content, 'time': status['time'], 'client_ip': client}
|
||||
if content == 'standard':
|
||||
active = sum(1 for value in status['units'].values() if value == 'active')
|
||||
document.update(version=status['version'], manager_version=status['manager_version'],
|
||||
services={'active': active, 'failed': len(status['units']) - active})
|
||||
return document
|
||||
|
||||
|
||||
def lines_of(*args):
|
||||
"""The output lines of a command, or None when it fails."""
|
||||
result = run(*args)
|
||||
@@ -346,13 +369,20 @@ def details(state):
|
||||
|
||||
|
||||
def text_of(status):
|
||||
"""The status for people, as plain text."""
|
||||
lines = ['VPN Router', f'Status: {status["status"]}', f'Version: {status["version"] or "unknown"}', '']
|
||||
lines.append(f'Router Manager: {status["manager_version"] or "not installed"}')
|
||||
lines += [f'{name}: {value}' for name, value in status['units'].items()]
|
||||
docker = status['docker']
|
||||
lines.append('Docker: not installed' if not docker['installed']
|
||||
else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}')
|
||||
"""The status document of shown for people, as plain text: a header, a blank line, then name: value lines."""
|
||||
lines = ['VPN Router', f'Status: {status["status"]}']
|
||||
if 'version' in status:
|
||||
lines.append(f'Version: {status["version"] or "unknown"}')
|
||||
lines += ['', f'Client IP: {status["client_ip"]}']
|
||||
if 'manager_version' in status:
|
||||
lines.append(f'Router Manager: {status["manager_version"] or "not installed"}')
|
||||
if 'services' in status:
|
||||
lines.append(f'Services: {status["services"]["active"]} active, {status["services"]["failed"]} failed')
|
||||
lines += [f'{name}: {value}' for name, value in status.get('units', {}).items()]
|
||||
if 'docker' in status:
|
||||
docker = status['docker']
|
||||
lines.append('Docker: not installed' if not docker['installed']
|
||||
else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}')
|
||||
if 'site_to_site' in status:
|
||||
value = status['site_to_site']['established']
|
||||
lines.append(f'Site to site: {"unknown" if value is None else "established" if value else "not established"}')
|
||||
@@ -376,11 +406,12 @@ def text_of(status):
|
||||
|
||||
|
||||
def html_of(status):
|
||||
"""The status for a browser, as a page styled with Tailwind CSS."""
|
||||
"""The status document of shown for a browser, as a page styled with Tailwind CSS."""
|
||||
colour = 'bg-emerald-600 dark:bg-emerald-700' if status['status'] == 'ok' else 'bg-amber-600 dark:bg-amber-700'
|
||||
# Each "name: value" line of the text is a row; indented lines, such as WireGuard peers, are indented here too.
|
||||
version = f', version {status["version"] or "unknown"}' if 'version' in status else ''
|
||||
# Each "name: value" line after the header of the text is a row; indented lines, such as WireGuard peers, are indented here too.
|
||||
rows = []
|
||||
for line in text_of(status).splitlines()[4:]:
|
||||
for line in text_of(status).split('\n\n', 1)[1].splitlines():
|
||||
if not line:
|
||||
continue
|
||||
name, _, value = line.partition(': ')
|
||||
@@ -404,7 +435,7 @@ def html_of(status):
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800">
|
||||
<div class="{colour} px-6 py-4 text-white">
|
||||
<h1 class="text-xl font-semibold">VPN Router</h1>
|
||||
<p class="mt-1 text-sm">Status: {escape(status['status'])}, version {escape(status['version'] or 'unknown')}</p>
|
||||
<p class="mt-1 text-sm">Status: {escape(status['status'] + version)}</p>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<table class="w-full text-sm"><tbody>{body}</tbody></table>
|
||||
@@ -718,14 +749,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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
|
||||
elif path == '/status':
|
||||
body, kind, code = json.dumps(self.status(), indent=2).encode() + b'\n', 'application/json', 200
|
||||
elif path == '/':
|
||||
elif path in ('/status', '/'):
|
||||
document = shown(self.status(), section(read_json(CONFIG_FILE), 'web').get('status_content'), self.client())
|
||||
agent = self.headers.get('User-Agent', '').lower()
|
||||
if 'curl' in agent or 'wget' in agent:
|
||||
body, kind = text_of(self.status()).encode(), 'text/plain; charset=utf-8'
|
||||
if path == '/status':
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif 'curl' in agent or 'wget' in agent:
|
||||
body, kind = text_of(document).encode(), 'text/plain; charset=utf-8'
|
||||
else:
|
||||
body, kind = html_of(self.status()).encode(), 'text/html; charset=utf-8'
|
||||
body, kind = html_of(document).encode(), 'text/html; charset=utf-8'
|
||||
code = 200
|
||||
else:
|
||||
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
|
||||
|
||||
Executable
+129
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/python3
|
||||
"""Show and change the router's configuration, /etc/vpn-router/vpn-router.json.
|
||||
|
||||
show prints the file as indented JSON; the file is not changed. set takes a setting as
|
||||
section.option, such as web.port, and a value, which it converts to the type the package's
|
||||
schema (vpnrouter.SCHEMA) gives the setting: a whole number, true or false, a list of strings
|
||||
separated by commas (an empty value is an empty list), or a string. The configuration with the
|
||||
new value is checked as vpn-router-setup checks it, and only then written, indented, with mode
|
||||
0600; vpn-router-setup applies it at its next run, which set reminds of. A failure is a message
|
||||
on standard error; the exit status is 2 for an invalid request and 1 for any other failure.
|
||||
-h or --help shows the options.
|
||||
|
||||
vpn-config-edit show
|
||||
vpn-config-edit set SECTION.OPTION VALUE
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
|
||||
import vpnrouter
|
||||
|
||||
|
||||
class Failure(Exception):
|
||||
"""A request that cannot be done; status is 2 invalid, 1 other."""
|
||||
|
||||
def __init__(self, message, status=1):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
class Parser(argparse.ArgumentParser):
|
||||
"""An argument parser whose errors are Failures with status 2."""
|
||||
|
||||
def error(self, message):
|
||||
raise Failure(f'{message}; see {self.prog} --help', 2)
|
||||
|
||||
|
||||
def read():
|
||||
"""The settings in the configuration file, without the defaults; {} when the file does not exist."""
|
||||
try:
|
||||
data = json.loads(vpnrouter.CONFIG_FILE.read_text(encoding='utf-8'))
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except ValueError as exc:
|
||||
raise Failure(f'cannot parse {vpnrouter.CONFIG_FILE}: {exc}')
|
||||
if not isinstance(data, dict) or not all(isinstance(options, dict) for options in data.values()):
|
||||
raise Failure(f'cannot parse {vpnrouter.CONFIG_FILE}: it is not an object of sections')
|
||||
return data
|
||||
|
||||
|
||||
def converted(key, text):
|
||||
"""The value of text for the setting key, section.option, of the type the schema gives it."""
|
||||
section, _, option = key.partition('.')
|
||||
if section not in vpnrouter.SCHEMA or option not in vpnrouter.SCHEMA[section]:
|
||||
raise Failure(f'{key}: unknown setting; settings are section.option, such as web.port', 2)
|
||||
kind = type(vpnrouter.SCHEMA[section][option])
|
||||
if kind is bool:
|
||||
if text not in ('true', 'false'):
|
||||
raise Failure(f'{key} takes true or false', 2)
|
||||
return text == 'true'
|
||||
if kind is int:
|
||||
try:
|
||||
return int(text)
|
||||
except ValueError:
|
||||
raise Failure(f'{key} takes a whole number', 2)
|
||||
if kind is list:
|
||||
return [item.strip() for item in text.split(',') if item.strip()]
|
||||
return text
|
||||
|
||||
|
||||
def write(data):
|
||||
"""Write the settings, indented, with mode 0600, replacing the file in one step."""
|
||||
directory = vpnrouter.CONFIG_FILE.parent
|
||||
descriptor, name = tempfile.mkstemp(dir=directory, prefix='.vpn-router.json.')
|
||||
try:
|
||||
with os.fdopen(descriptor, 'w', encoding='utf-8') as handle:
|
||||
handle.write(json.dumps(data, indent=2) + '\n')
|
||||
os.chmod(name, 0o600)
|
||||
os.replace(name, vpnrouter.CONFIG_FILE)
|
||||
except OSError:
|
||||
os.unlink(name)
|
||||
raise
|
||||
|
||||
|
||||
def main(argv):
|
||||
try:
|
||||
parser = Parser(prog='vpn-config-edit', description='Show and change the router configuration.')
|
||||
commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser)
|
||||
commands.add_parser('show', help='print the configuration as indented JSON')
|
||||
setter = commands.add_parser('set', help='set a setting and write the configuration')
|
||||
setter.add_argument('key', metavar='SECTION.OPTION', help='the setting, such as web.port')
|
||||
setter.add_argument('value', metavar='VALUE', help='its value; a list separated by commas')
|
||||
options = parser.parse_args(argv)
|
||||
|
||||
data = read()
|
||||
if options.command == 'show':
|
||||
print(json.dumps(data, indent=2))
|
||||
return 0
|
||||
|
||||
section, _, option = options.key.partition('.')
|
||||
value = converted(options.key, options.value)
|
||||
data.setdefault(section, {})[option] = value
|
||||
# The configuration as vpn-router-setup reads it: the defaults with the file's settings.
|
||||
candidate = {name: dict(defaults) for name, defaults in vpnrouter.SCHEMA.items()}
|
||||
for name, settings in data.items():
|
||||
candidate.setdefault(name, {}).update(settings)
|
||||
try:
|
||||
vpnrouter.validate(candidate)
|
||||
except vpnrouter.ConfigError as exc:
|
||||
raise Failure(str(exc), 2)
|
||||
write(data)
|
||||
except Failure as failure:
|
||||
print(f'vpn-config-edit: {failure}', file=sys.stderr)
|
||||
return failure.status
|
||||
except OSError as exc:
|
||||
print(f'vpn-config-edit: {exc}', file=sys.stderr)
|
||||
return 1
|
||||
print(f'{options.key} set to {json.dumps(value)} in {vpnrouter.CONFIG_FILE}.')
|
||||
print('Apply it with: systemctl restart vpn-router-setup')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -48,7 +48,8 @@
|
||||
"listen": "both",
|
||||
"port": 7081,
|
||||
"database": "",
|
||||
"trusted_proxies": []
|
||||
"trusted_proxies": [],
|
||||
"status_content": "basic"
|
||||
},
|
||||
"https": {
|
||||
"enabled": true,
|
||||
|
||||
Reference in new issue
Block a user