Compare commits

..
3 Commits
6 changed files with 79 additions and 18 deletions

No files matched your search

+4 -1
View File
@@ -909,7 +909,10 @@ Manager cannot set it. The shell runs as the console account of the signed-in us
it is never root and each administrator's shell is the administrator's own; a user without it is never root and each administrator's shell is the administrator's own; a user without
console login gets none. `POST /shell` signs the user in again with the password console login gets none. `POST /shell` signs the user in again with the password
and TOTP code and gives a one-time ticket valid 30 seconds; a WebSocket on `GET /shell` with that and TOTP code and gives a one-time ticket valid 30 seconds; a WebSocket on `GET /shell` with that
ticket gets the shell. The WebSocket of RFC 6455 is implemented on the standard library inside the ticket gets the shell. Asking for the password and code at every opening of the page is tiresome,
so within `web.shell_auth_timeout` minutes of the user's last sign-in or keystroke a ticket is given
without them; the times are kept in the web service's memory only, so a restart asks again, and 0
keeps the sign-in at every opening. The WebSocket of RFC 6455 is implemented on the standard library inside the
web service's `http.server`, server side only, without extensions. `systemd-run --pty` starts the web service's `http.server`, server side only, without extensions. `systemd-run --pty` starts the
shell in a transient unit with a PAM login session, outside the sandbox, and `setsid --ctty` shell in a transient unit with a PAM login session, outside the sandbox, and `setsid --ctty`
makes the terminal its controlling terminal so window sizes reach the shell. A shell idle for makes the terminal its controlling terminal so window sizes reach the shell. A shell idle for
+7 -4
View File
@@ -114,7 +114,7 @@ must have the type shown below; an unknown section or setting is an error.
| `p2s` | `enabled` | boolean | `false` | Road-warrior access | | `p2s` | `enabled` | boolean | `false` | Road-warrior access |
| `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients | | `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients |
| `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint | | `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint |
| `wireguard` | `address` | string | | Address and prefix for `wg0`, for example `192.168.200.1/24` | | `wireguard` | `address` | string | | The router's own address on `wg0` with the prefix of the peers' network, for example `192.168.200.1/24`; the network and the broadcast address of a network larger than /31 are refused |
| `wireguard` | `listen_port` | integer | `51820` | UDP port | | `wireguard` | `listen_port` | integer | `51820` | UDP port |
| `openvpn` | `enabled` | boolean | `false` | OpenVPN access for users | | `openvpn` | `enabled` | boolean | `false` | OpenVPN access for users |
| `openvpn` | `address_pool` | string | | IPv4 CIDR assigned to OpenVPN clients | | `openvpn` | `address_pool` | string | | IPv4 CIDR assigned to OpenVPN clients |
@@ -130,6 +130,7 @@ must have the type shown below; an unknown section or setting is an error.
| `web` | `status_content` | string | `basic` | What `/` and `/status` show: `basic`, `standard` or `full` (see Status web service) | | `web` | `status_content` | string | `basic` | What `/` and `/status` show: `basic`, `standard` or `full` (see Status web service) |
| `web` | `shell` | boolean | `false` | `/shell` opens a login shell for a member of `admins` with console login (see The shell) | | `web` | `shell` | boolean | `false` | `/shell` opens a login shell for a member of `admins` with console login (see The shell) |
| `web` | `shell_idle_minutes` | integer | `15` | Minutes without a keystroke after which a shell is closed, 1 to 1440 | | `web` | `shell_idle_minutes` | integer | `15` | Minutes without a keystroke after which a shell is closed, 1 to 1440 |
| `web` | `shell_auth_timeout` | integer | `15` | Minutes after the last use of a shell in which a new shell opens without signing in again, 0 (always sign in) to 1440 |
| `ssh` | `ldap_login` | boolean | `false` | sshd accepts the directory password of the console accounts in the directory (see Console login and SSH) | | `ssh` | `ldap_login` | boolean | `false` | sshd accepts the directory password of the console accounts in the directory (see Console login and SSH) |
| `https` | `enabled` | boolean | `true` | Caddy serves the status web service and the Router Manager over HTTPS for `wan.local_fqdn` | | `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` | `email` | string | | The e-mail address of the Let's Encrypt account |
@@ -980,12 +981,14 @@ vpn-config-edit set web.shell true
| Request | Body | Answer | | Request | Body | Answer |
| --- | --- | --- | | --- | --- | --- |
| `POST /shell` | `{"password", "code"}`; `code` only for a user enrolled for TOTP | `{"ticket", "expires_in": 30}` | | `POST /shell` | `{"password", "code"}`; `code` only for a user enrolled for TOTP; `{}` within `web.shell_auth_timeout` | `{"ticket", "expires_in": 30}` |
| `GET /shell?ticket=<ticket>` | a WebSocket handshake | the terminal | | `GET /shell?ticket=<ticket>` | a WebSocket handshake | the terminal |
`POST /shell` needs an access token of `/auth`, not one of `vpn-router-token`, and signs its user `POST /shell` needs an access token of `/auth`, not one of `vpn-router-token`, and signs its user
in again: a wrong password or code gets 403 and counts as a failed login; it gets 403 too while in again: a wrong password or code gets 403 and counts as a failed login; it gets 403 too while
`web.shell` is `false` or the user's console login is off. The ticket opens one `web.shell` is `false` or the user's console login is off. Without a password it gives a ticket
while the user's last sign-in of `POST /shell` or keystroke in a shell is less than
`web.shell_auth_timeout` minutes ago, and `{"ticket": null}` otherwise. The ticket opens one
shell for the same user within 30 seconds. Over the WebSocket, binary messages of the client shell for the same user within 30 seconds. Over the WebSocket, binary messages of the client
are keystrokes, a text message `{"cols", "rows"}` sets the window size, and binary messages of are keystrokes, a text message `{"cols", "rows"}` sets the window size, and binary messages of
the router are the terminal's output. The shell is a PAM login session of the console account, the router are the terminal's output. The shell is a PAM login session of the console account,
@@ -1006,7 +1009,7 @@ when it ended and why, and each refused ticket or shell with the reason.
| `wireguard` | The public key and port of `wg0`, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key | | `wireguard` | The public key and port of `wg0`, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key |
| `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain | | `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain |
| `postgresql` | `clusters`: each PostgreSQL cluster of `pg_lsclusters` with its version, name, port, status and data directory, and for one that is online its `settings`: `server_version`, `listen_addresses`, `port`, `max_connections`, `shared_buffers`, `effective_cache_size`, `work_mem`, `password_encryption`, `ssl` and `timezone`; `null` when `pg_lsclusters` fails | | `postgresql` | `clusters`: each PostgreSQL cluster of `pg_lsclusters` with its version, name, port, status and data directory, and for one that is online its `settings`: `server_version`, `listen_addresses`, `port`, `max_connections`, `shared_buffers`, `effective_cache_size`, `work_mem`, `password_encryption`, `ssl` and `timezone`; `null` when `pg_lsclusters` fails |
| `caddy` | The installed Caddy: `version`, and `source`, `upstream` once Caddy's repository is configured and `ubuntu` otherwise; `null` when Caddy is not installed | | `caddy` | The installed Caddy: `version`, `source`, `upstream` once Caddy's repository is configured and `ubuntu` otherwise, and `service`, the state of `caddy.service`; `null` when Caddy is not installed |
### The /postgresql page ### The /postgresql page
+29
View File
@@ -1,3 +1,32 @@
vpn-router (0.25.4-1) unstable; urgency=medium
fix: a shell opens without the password and code again while the user's last use of a shell is within web.shell_auth_timeout minutes.
* New setting web.shell_auth_timeout, 15 minutes by default, 0 to 1440:
POST /shell without a password gives a ticket while the user's last
sign-in of POST /shell or keystroke in a shell is within it, and a
null ticket otherwise; 0 asks for the password and code every time.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 06:00:00 +0100
vpn-router (0.25.3-1) unstable; urgency=medium
fix: /config gives the state of caddy.service with the installed Caddy, for the Router Manager's Web page.
* caddy of /config is {"version", "source", "service"}.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 05:00:00 +0100
vpn-router (0.25.2-1) unstable; urgency=medium
fix: wireguard.address must be the router's own address in its network, not the network or broadcast address, from which the peers' addresses are told apart.
* A wireguard.address that is the network or the broadcast address of a
network larger than /31, such as 192.168.232.0/24, is refused with the
first address of the network as the example.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 02:30:00 +0100
vpn-router (0.25.1-1) unstable; urgency=medium vpn-router (0.25.1-1) unstable; urgency=medium
fix: /remote-access gives the router's WireGuard public key, endpoint and allowed IPs, which a peer's configuration needs. fix: /remote-access gives the router's WireGuard public key, endpoint and allowed IPs, which a peer's configuration needs.
@@ -58,7 +58,7 @@ SCHEMA = {
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'}, 'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
'docker': {'mode': 'auto', 'exposed_ports': []}, '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', 'shell': False, 'shell_idle_minutes': 15}, 'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15, 'shell_auth_timeout': 15},
'ssh': {'ldap_login': False}, 'ssh': {'ldap_login': False},
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'}, 'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'},
'pki': {'root_name': 'VPN Router Root CA', 'root_days': 7300, 'ca_days': 3650, 'key': 'rsa-4096', 'pki': {'root_name': 'VPN Router Root CA', 'root_days': 7300, 'ca_days': 3650, 'key': 'rsa-4096',
@@ -201,7 +201,7 @@ CA_NAME_MAX = 64
#: What / and /status of the web service show, web.status_content, from the least to the most. #: What / and /status of the web service show, web.status_content, from the least to the most.
STATUS_CONTENTS = ('basic', 'standard', 'full') STATUS_CONTENTS = ('basic', 'standard', 'full')
#: The longest web.shell_idle_minutes, a day. #: The longest web.shell_idle_minutes and web.shell_auth_timeout, a day.
SHELL_IDLE_MAX = 1440 SHELL_IDLE_MAX = 1440
DOCKER_MODES = ('auto', 'on', 'off') DOCKER_MODES = ('auto', 'on', 'off')
@@ -458,10 +458,16 @@ def validate(cfg):
wg_address = _get(cfg, 'wireguard', 'address') wg_address = _get(cfg, 'wireguard', 'address')
if wg_address: if wg_address:
try: try:
ipaddress.ip_interface(wg_address) interface = ipaddress.ip_interface(wg_address)
except ValueError: except ValueError:
raise ConfigError( raise ConfigError(
f'wireguard.address: {wg_address!r} is not an address with prefix') f'wireguard.address: {wg_address!r} is not an address with prefix')
# The router's own address on wg0, from which the peers' addresses of the same network are told apart.
network = interface.network
if network.num_addresses > 2 and interface.ip in (network.network_address, network.broadcast_address):
raise ConfigError(
f'wireguard.address: {wg_address} is the network or broadcast address of {network}; give the '
f'router\'s own address in it, such as {next(network.hosts())}/{network.prefixlen}')
port = _get(cfg, 'wireguard', 'listen_port') port = _get(cfg, 'wireguard', 'listen_port')
if not 1 <= port <= 65535: if not 1 <= port <= 65535:
@@ -493,6 +499,9 @@ def validate(cfg):
shell_idle = _get(cfg, 'web', 'shell_idle_minutes') shell_idle = _get(cfg, 'web', 'shell_idle_minutes')
if not 1 <= shell_idle <= SHELL_IDLE_MAX: if not 1 <= shell_idle <= SHELL_IDLE_MAX:
raise ConfigError(f'web.shell_idle_minutes: {shell_idle} is not between 1 and {SHELL_IDLE_MAX}') raise ConfigError(f'web.shell_idle_minutes: {shell_idle} is not between 1 and {SHELL_IDLE_MAX}')
shell_auth = _get(cfg, 'web', 'shell_auth_timeout')
if not 0 <= shell_auth <= SHELL_IDLE_MAX:
raise ConfigError(f'web.shell_auth_timeout: {shell_auth} is not between 0 and {SHELL_IDLE_MAX}')
https_ports = [] https_ports = []
for option in ('web_port', 'manager_port'): for option in ('web_port', 'manager_port'):
+25 -9
View File
@@ -38,8 +38,9 @@ Pages:
the DER certificate and CRL of the root (root) and of each issuing CA, which the the DER certificate and CRL of the root (root) and of each issuing CA, which the
certificates name in their AIA and CRL distribution points; no authentication certificates name in their AIA and CRL distribution points; no authentication
/shell while web.shell is true, a login shell of the user's console account on a terminal, for /shell while web.shell is true, a login shell of the user's console account on a terminal, for
a member of admins whose console login is on: POST signs the a member of admins whose console login is on: POST signs the user in again, unless
user in again for a one-time ticket (see shell_ticket()), GET with it is a WebSocket the user used a shell within web.shell_auth_timeout minutes, for a one-time ticket
(see shell_ticket()), GET with it is a WebSocket
that carries the terminal (see Handler.shell()) that carries the terminal (see Handler.shell())
/auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth()) /auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth())
/metrics/history /metrics/history
@@ -189,6 +190,9 @@ _auth_lock = threading.Lock()
_shell_tickets = {} _shell_tickets = {}
_shell_lock = threading.Lock() _shell_lock = threading.Lock()
#: The last use of a shell of each user, a sign-in of POST /shell or a keystroke: user -> monotonic time.
_shell_used = {}
def read_json(path): def read_json(path):
"""The object in a JSON file, or {} when the file is missing or holds no object.""" """The object in a JSON file, or {} when the file is missing or holds no object."""
@@ -403,7 +407,7 @@ def postgresql_databases():
def details(state): def details(state):
"""What /config shows: the configuration without its secrets, and what the router resolved and runs; caddy is the """What /config shows: the configuration without its secrets, and what the router resolved and runs; caddy is the
installed Caddy, {version, source}, null when it is not installed.""" installed Caddy, {version, source, service}, service the state of caddy.service, null when it is not installed."""
configuration = state.get('configuration', {}) configuration = state.get('configuration', {})
caddy = package_version('caddy') caddy = package_version('caddy')
return { return {
@@ -420,8 +424,8 @@ def details(state):
}, },
'postgresql': postgresql_configuration(), 'postgresql': postgresql_configuration(),
# Caddy is upstream's once its repository is configured (vpn-router-caddy). # Caddy is upstream's once its repository is configured (vpn-router-caddy).
'caddy': {'version': caddy, 'source': 'upstream' if vpnrouter.CADDY_APT_SOURCE.is_file() else 'ubuntu'} 'caddy': {'version': caddy, 'source': 'upstream' if vpnrouter.CADDY_APT_SOURCE.is_file() else 'ubuntu',
if caddy else None, 'service': unit_state('caddy.service')} if caddy else None,
} }
@@ -1105,19 +1109,28 @@ def shell_ticket(name, body):
"""Answer POST /shell: the HTTP status and the JSON document. """Answer POST /shell: the HTTP status and the JSON document.
The user named by the token signs in again with {"password", "code"?}; a wrong password or code is 403 and counts The user named by the token signs in again with {"password", "code"?}; a wrong password or code is 403 and counts
as a failed login. The answer is {"ticket", "expires_in"}: a ticket that opens one shell (GET /shell) for the as a failed login. Without a password, the user is not asked to sign in while its last use of a shell (see
same user within SHELL_TICKET_SECONDS. 403 while web.shell is false or the user's console login is not on. _shell_used) is less than web.shell_auth_timeout minutes ago. The answer is {"ticket", "expires_in"}: a ticket that
opens one shell (GET /shell) for the same user within SHELL_TICKET_SECONDS, or a null ticket when the user must
sign in. 403 while web.shell is false or the user's console login is not on.
""" """
if not isinstance(name, str): if not isinstance(name, str):
return 403, {'error': 'the shell needs a token of a signed-in user'} return 403, {'error': 'the shell needs a token of a signed-in user'}
if not isinstance(body, dict): if not isinstance(body, dict):
return 400, {'error': 'the body must be a JSON object'} return 400, {'error': 'the body must be a JSON object'}
try: try:
if not vpnrouter.load_config()['web']['shell']: options = vpnrouter.load_config()['web']
if not options['shell']:
return 403, {'error': 'the shell is disabled; vpn-config-edit set web.shell true enables it'} return 403, {'error': 'the shell is disabled; vpn-config-edit set web.shell true enables it'}
if console_account(name) is None: if console_account(name) is None:
return 403, {'error': f'console login is not enabled for {name}; vpn-router-user console sets it'} return 403, {'error': f'console login is not enabled for {name}; vpn-router-user console sets it'}
vpnrouter_users.login(name, body.get('password'), body.get('code')) if 'password' not in body:
with _shell_lock:
used = _shell_used.get(name)
if used is None or time.monotonic() - used >= options['shell_auth_timeout'] * 60:
return 200, {'ticket': None, 'expires_in': 0}
else:
vpnrouter_users.login(name, body.get('password'), body.get('code'))
except vpnrouter_users.Failure as failure: except vpnrouter_users.Failure as failure:
if failure.status == 5: if failure.status == 5:
return 403, {'error': 'the password or the code is wrong'} return 403, {'error': 'the password or the code is wrong'}
@@ -1132,6 +1145,7 @@ def shell_ticket(name, body):
for unused in [key for key, (_, expires) in _shell_tickets.items() if expires <= now]: for unused in [key for key, (_, expires) in _shell_tickets.items() if expires <= now]:
del _shell_tickets[unused] del _shell_tickets[unused]
_shell_tickets[ticket] = (name, now + SHELL_TICKET_SECONDS) _shell_tickets[ticket] = (name, now + SHELL_TICKET_SECONDS)
_shell_used[name] = now
return 200, {'ticket': ticket, 'expires_in': SHELL_TICKET_SECONDS} return 200, {'ticket': ticket, 'expires_in': SHELL_TICKET_SECONDS}
@@ -1336,6 +1350,8 @@ class Handler(BaseHTTPRequestHandler):
if opcode == BINARY: if opcode == BINARY:
os.write(master, payload) os.write(master, payload)
typed[0] = time.monotonic() typed[0] = time.monotonic()
with _shell_lock:
_shell_used[name] = typed[0]
elif opcode == TEXT: elif opcode == TEXT:
size = json.loads(payload) size = json.loads(payload)
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', int(size['rows']), int(size['cols']), 0, 0)) fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', int(size['rows']), int(size['cols']), 0, 0))
@@ -63,7 +63,8 @@
"trusted_proxies": [], "trusted_proxies": [],
"status_content": "basic", "status_content": "basic",
"shell": false, "shell": false,
"shell_idle_minutes": 15 "shell_idle_minutes": 15,
"shell_auth_timeout": 15
}, },
"ssh": { "ssh": {
"ldap_login": false "ldap_login": false