Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8efe21bfa6 | ||
|
|
6d90441bee | ||
|
|
ee1161f534 | ||
|
|
7f85a1a2e5 | ||
|
|
c2c33cfca0 |
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.claude
|
||||||
|
.vscode
|
||||||
|
out
|
||||||
|
tmp
|
||||||
|
examples
|
||||||
|
web/node_modules
|
||||||
|
web/dist
|
||||||
|
.venv
|
||||||
|
dev.json
|
||||||
|
**/__pycache__
|
||||||
@@ -12,3 +12,7 @@ tmp
|
|||||||
*.tfstate
|
*.tfstate
|
||||||
*.tfstate.*
|
*.tfstate.*
|
||||||
*.auto.tfvars
|
*.auto.tfvars
|
||||||
|
node_modules
|
||||||
|
web/dist
|
||||||
|
.venv
|
||||||
|
dev.json
|
||||||
@@ -49,7 +49,7 @@ the overall status and the Client IP, `standard` also the package versions and t
|
|||||||
active and other services, and `full` also each service and the state of the site-to-site, IKEv2, OpenVPN and
|
active and other services, and `full` also each service and the state of the site-to-site, IKEv2, OpenVPN and
|
||||||
WireGuard features. The Client IP is the address the request came from, through the trusted
|
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.
|
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
|
`/status` names its content, so clients such as Cloud Manager and the Router Manager show what it
|
||||||
has. `vpn-router-setup` opens the
|
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
|
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`.
|
writes the features and the facts it resolves to `/run/vpn-router/web.json`.
|
||||||
@@ -227,7 +227,7 @@ no CORS headers, so a browser cannot call it directly, and tokens kept in the pa
|
|||||||
by its scripts.
|
by its scripts.
|
||||||
|
|
||||||
Decision: `vpn-router-manager` is a Debian package of its own, built in the repository
|
Decision: `vpn-router-manager` is a Debian package of its own, built in the repository
|
||||||
`linux-cloud-router-manager`. A React and MUI application in the style of manage-ui is built
|
`linux-cloud-router-manager`. A React and MUI application in the style of Cloud Manager is built
|
||||||
with Vite when the package is built and shipped as static files. A Python server, using the
|
with Vite when the package is built and shipped as static files. A Python server, using the
|
||||||
Ubuntu packages `python3-requests` and `python3-psycopg`, serves them, signs users in through the
|
Ubuntu packages `python3-requests` and `python3-psycopg`, serves them, signs users in through the
|
||||||
router's `/auth`, keeps their tokens in a server-side session in PostgreSQL behind an
|
router's `/auth`, keeps their tokens in a server-side session in PostgreSQL behind an
|
||||||
@@ -537,7 +537,9 @@ unchanged. Both services always listen on `127.0.0.1` as well as on the interfac
|
|||||||
never takes from the client. Let's Encrypt issues at most 5 certificates a week for the same
|
never takes from the client. Let's Encrypt issues at most 5 certificates a week for the same
|
||||||
names, which routers rebuilt for tests reach; `https.acme_staging` sets Caddy's global `acme_ca`
|
names, which routers rebuilt for tests reach; `https.acme_staging` sets Caddy's global `acme_ca`
|
||||||
to Let's Encrypt's staging directory, which replaces Caddy's default CAs for every site, so no
|
to Let's Encrypt's staging directory, which replaces Caddy's default CAs for every site, so no
|
||||||
test certificate comes from a production CA.
|
test certificate comes from a production CA. `PUT /https/settings` changes `acme_staging` alone:
|
||||||
|
the other settings of the section switch HTTPS off or move its ports, which would end the
|
||||||
|
connection the Router Manager makes the change on.
|
||||||
|
|
||||||
Consequences: HTTPS needs `wan.local_fqdn` in public DNS pointing at the router and port 80
|
Consequences: HTTPS needs `wan.local_fqdn` in public DNS pointing at the router and port 80
|
||||||
reachable from the internet; until then Caddy retries and the HTTPS ports do not answer. The
|
reachable from the internet; until then Caddy retries and the HTTPS ports do not answer. The
|
||||||
@@ -648,7 +650,7 @@ Status: Accepted
|
|||||||
Context: The router had one IPsec site-to-site tunnel, the `remote` section, run as the swanctl
|
Context: The router had one IPsec site-to-site tunnel, the `remote` section, run as the swanctl
|
||||||
connection `remote-site`, with the local identity mode in `wan` and the local subnets in `local`.
|
connection `remote-site`, with the local identity mode in `wan` and the local subnets in `local`.
|
||||||
A router often links more than one site, each with its own peer, subnets and key, and an admin
|
A router often links more than one site, each with its own peer, subnets and key, and an admin
|
||||||
should add them without a shell. The install answers and the Cloud Platform describe one
|
should add them without a shell. The install answers and Cloud Manager describe one
|
||||||
connection, and the platform rewrites `vpn-router.json` when it rebuilds the VM.
|
connection, and the platform rewrites `vpn-router.json` when it rebuilds the VM.
|
||||||
|
|
||||||
Decision: `site_to_site` maps a connection name to its settings: `addrs`, `id`, `cidrs`,
|
Decision: `site_to_site` maps a connection name to its settings: `addrs`, `id`, `cidrs`,
|
||||||
@@ -656,7 +658,7 @@ Decision: `site_to_site` maps a connection name to its settings: `addrs`, `id`,
|
|||||||
without a fallback for the old format. The name, a lowercase letter followed by at most 30
|
without a fallback for the old format. The name, a lowercase letter followed by at most 30
|
||||||
lowercase letters, digits and hyphens, names the swanctl connection, its child and its secret
|
lowercase letters, digits and hyphens, names the swanctl connection, its child and its secret
|
||||||
`ike-psk-<name>`, so it is a valid swanctl section name and stays the same while the connection
|
`ike-psk-<name>`, so it is a valid swanctl section name and stays the same while the connection
|
||||||
lives. The install answers and the Cloud Platform write the connection `remote-site`, the first
|
lives. The install answers and Cloud Manager write the connection `remote-site`, the first
|
||||||
one, so its name is the one the tunnel always had.
|
one, so its name is the one the tunnel always had.
|
||||||
|
|
||||||
- **Local settings per connection:** each connection has its own `local_cidrs` and
|
- **Local settings per connection:** each connection has its own `local_cidrs` and
|
||||||
@@ -690,7 +692,7 @@ Consequences: A configuration with `remote` is refused, so the package and a con
|
|||||||
the new format go together. The status shows each connection. A rate across a rekey misses the
|
the new format go together. The status shows each connection. A rate across a rekey misses the
|
||||||
traffic of the old child SA since the previous sample. A renamed connection starts a new series.
|
traffic of the old child SA since the previous sample. A renamed connection starts a new series.
|
||||||
A connection added through the API lives in
|
A connection added through the API lives in
|
||||||
`vpn-router.json` only; the Cloud Platform's rebuild of the VM writes the file again with its
|
`vpn-router.json` only; Cloud Manager's rebuild of the VM writes the file again with its
|
||||||
one connection, dropping the others. A change through the API is answered before setup has
|
one connection, dropping the others. A change through the API is answered before setup has
|
||||||
applied it, so a configuration that setup cannot apply shows in the setup state, not in the
|
applied it, so a configuration that setup cannot apply shows in the setup state, not in the
|
||||||
answer. A connection with a key file keeps it until a key is given through the API, which then
|
answer. A connection with a key file keeps it until a key is given through the API, which then
|
||||||
@@ -1209,8 +1211,9 @@ later. S3 and Azure Blob clients are not served.
|
|||||||
|
|
||||||
Status: Accepted
|
Status: Accepted
|
||||||
|
|
||||||
Context: The router hosts authoritative domains as their primary, with optional secondary name
|
Context: The router hosts authoritative domains as their primary, with a secondary name server, and
|
||||||
servers, and a domain can give the sites their records without a registrar change per site. Ubuntu
|
a domain can give the sites their records without a registrar change per site. The router's own
|
||||||
|
name, `wan.local_fqdn`, belongs to a domain of its own, wherever that is hosted. Ubuntu
|
||||||
has BIND, Knot and PowerDNS; only PowerDNS's gpgsql backend reads records
|
has BIND, Knot and PowerDNS; only PowerDNS's gpgsql backend reads records
|
||||||
from PostgreSQL, so changes are rows, without zone files or reloads. systemd-resolved's stub holds
|
from PostgreSQL, so changes are rows, without zone files or reloads. systemd-resolved's stub holds
|
||||||
127.0.0.53 and 127.0.0.54, and the remote access clients' resolver answered on the external
|
127.0.0.53 and 127.0.0.54, and the remote access clients' resolver answered on the external
|
||||||
@@ -1220,25 +1223,33 @@ just as well, through the tunnel when it is in `local.cidrs` (ADR-43).
|
|||||||
Decision: While `dns.enabled` is true, setup runs `pdns.service` with
|
Decision: While `dns.enabled` is true, setup runs `pdns.service` with
|
||||||
`/etc/powerdns/pdns.d/vpn-router.conf`, written before the packages are installed, on the
|
`/etc/powerdns/pdns.d/vpn-router.conf`, written before the packages are installed, on the
|
||||||
addresses of `dns.listen`, with the gpgsql backend on the database `vpn-router-dns` and PowerDNS's
|
addresses of `dns.listen`, with the gpgsql backend on the database `vpn-router-dns` and PowerDNS's
|
||||||
own schema, and `primary=yes`. The domains, their secondaries and manual records are rows of the
|
own schema, and `primary=yes`. The domains and their manual records are rows of the router's tables
|
||||||
router's tables in that database, like the sites in `vpn-router-sites`, not settings of
|
in that database, like the sites in `vpn-router-sites`, not settings of `vpn-router.json`: they are
|
||||||
`vpn-router.json`: they are many and change through the web service. `dns.nameserver` and
|
many and change through the web service. The name servers are settings, the same for every domain:
|
||||||
`dns.addresses` stay settings, the router's one identity as the primary NS of every domain. Setup
|
`wan.local_fqdn`, the router's one name, is the primary, and `dns.secondary` the secondary. Setup
|
||||||
writes each domain as a MASTER zone through the procedure `dns_apply` of
|
writes each domain as a MASTER zone through the procedure `dns_apply` of
|
||||||
`/usr/share/vpn-router/sql/dns.sql`: SOA, NS of the router and the secondaries, A and AAAA records
|
`/usr/share/vpn-router/sql/dns.sql`: SOA with `wan.local_fqdn`, NS of `wan.local_fqdn` and
|
||||||
of `dns.addresses` for the apex, the name server and, with the domain's site records switch, every
|
`dns.secondary`, an A or AAAA record of every host name of a site with `dns` `router` whose closest
|
||||||
site host name whose closest domain it is, and the manual records; the serial grows only when the
|
domain it is, with the router's address the site chooses (`wan.public_address`, the internal or the
|
||||||
records change, which makes PowerDNS notify. A secondary is a name, its NS, and an address, which
|
external interface's), and the manual records; the serial grows only when the records change, which
|
||||||
goes to the domain's `ALSO-NOTIFY` and `ALLOW-AXFR-FROM` metadata, so the global
|
makes PowerDNS notify. `wan.local_fqdn` gets no record in the domains: its A record is in the DNS of
|
||||||
`allow-axfr-ips` stays the loopback default. A host name belongs to its closest domain only, and a
|
its own domain, which the router does not manage. A site of `dns` `user` gets no records, as its
|
||||||
|
names are served elsewhere. The addresses `dns.secondary` resolves to at each run go to every
|
||||||
|
domain's `ALSO-NOTIFY` and `ALLOW-AXFR-FROM` metadata, so the global `allow-axfr-ips` stays the
|
||||||
|
loopback default. The public address is a setting, since the router sees only private addresses on
|
||||||
|
its interfaces behind a cloud's NAT and resolving `wan.local_fqdn` would answer from its own DNS
|
||||||
|
when it hosts that name. `dns.nameserver` and `dns.addresses` of an earlier configuration are left
|
||||||
|
out when it is read, so that it loads; a missing `dns.secondary` makes setup warn, not fail, so that
|
||||||
|
a router keeps running until it is set. A host name belongs to its closest domain only, and a
|
||||||
domain inside another is delegated from it with NS records, so a name is never answered by two
|
domain inside another is delegated from it with NS records, so a name is never answered by two
|
||||||
zones. The remote access clients' resolver answers on the addresses of the setting
|
zones. The remote access clients' resolver answers on the addresses of the setting
|
||||||
`p2s.dns_listen`, by default the internal one; a `dns.listen` that shares an interface with it is
|
`p2s.dns_listen`, by default the internal one; a `dns.listen` that shares an interface with it is
|
||||||
refused while P2S or OpenVPN is enabled.
|
refused while P2S or OpenVPN is enabled.
|
||||||
|
|
||||||
Consequences: The domains follow the sites at each setup run. Transfers are authorized by address,
|
Consequences: The domains follow the sites at each setup run. Transfers are authorized by the
|
||||||
without TSIG, and there is no DNSSEC; the delegation of each domain is made at its parent zone by
|
address the secondary's name resolves to, without TSIG, and there is no DNSSEC; the delegation of
|
||||||
hand.
|
each domain, with glue for the name servers inside it, is made at its parent zone by hand. Every
|
||||||
|
domain has the same two name servers.
|
||||||
|
|
||||||
## ADR-43: The remote access clients' resolver is dnsmasq, not systemd-resolved
|
## ADR-43: The remote access clients' resolver is dnsmasq, not systemd-resolved
|
||||||
|
|
||||||
@@ -1364,6 +1375,7 @@ last line of defence, every server replaces the default reports:
|
|||||||
|
|
||||||
- `vpnrouter_serve.serve`, which the web service and the metrics service run, sets the servers'
|
- `vpnrouter_serve.serve`, which the web service and the metrics service run, sets the servers'
|
||||||
`handle_error`, `threading.excepthook` and `sys.excepthook`.
|
`handle_error`, `threading.excepthook` and `sys.excepthook`.
|
||||||
|
- `vpnrouter_manager.serve`, which the Router Manager runs, does the same.
|
||||||
- The bootstrap server sets its server's `handle_error` and `sys.excepthook`.
|
- The bootstrap server sets its server's `handle_error` and `sys.excepthook`.
|
||||||
|
|
||||||
A `ConnectionError` of a client that went away is not logged; any other exception is logged in
|
A `ConnectionError` of a client that went away is not logged; any other exception is logged in
|
||||||
@@ -1371,3 +1383,161 @@ one line with its type and message.
|
|||||||
|
|
||||||
Consequences: The logs hold one line per failure, without its stack. A failure that reaches the
|
Consequences: The logs hold one line per failure, without its stack. A failure that reaches the
|
||||||
last line of defence is found by its message and fixed where it occurs, with its own handling.
|
last line of defence is found by its message and fixed where it occurs, with its own handling.
|
||||||
|
|
||||||
|
## ADR-49: A changelog summary is a paragraph wrapped at 80 columns
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Context: The summary of an entry of `debian/changelog` is the commit message of its release,
|
||||||
|
which `release.py` reads from the changelog. Summaries are often longer than the 80 columns
|
||||||
|
Debian's tools expect of a changelog line.
|
||||||
|
|
||||||
|
Decision: The summary is the entry's first paragraph, indented by two spaces and wrapped at 80
|
||||||
|
columns. `release.py` joins its lines with single spaces into the commit message.
|
||||||
|
|
||||||
|
Consequences: The commit message is one line however the summary is wrapped, so a summary
|
||||||
|
rewrapped later still matches the commit of its release.
|
||||||
|
|
||||||
|
## ADR-50: The help is Markdown rendered in the browser, in a panel that pushes the page
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Context: The Router Manager's pages were explained only in its README. Help should be written as
|
||||||
|
plain files next to the code, shipped with the package, readable only after signing in, and able
|
||||||
|
to show pictures. serve-markdown renders Markdown in the browser with marked, DOMPurify and
|
||||||
|
github-markdown-css. Google Cloud's console shows its Learn panel on the right and moves the page
|
||||||
|
left, so the help never covers what it explains.
|
||||||
|
|
||||||
|
Decision: The help is the Markdown files of `docs/` (plain Markdown, no MDX), with SVG and PNG
|
||||||
|
pictures, installed to `/usr/share/vpn-router/manager/docs` and served by the manager at
|
||||||
|
`/docs/<file>` behind the session, with no-sniff and a CSP that keeps an SVG from running
|
||||||
|
scripts. The browser renders them with marked (GFM) and DOMPurify, styled with
|
||||||
|
github-markdown-css in the Manager's light or dark scheme, without syntax highlighting or
|
||||||
|
diagrams. The help icon in the header opens a persistent drawer on the right whose width the page
|
||||||
|
gives up; it opens at the topic of the page and tab shown, and links between topics stay in the
|
||||||
|
panel.
|
||||||
|
|
||||||
|
Consequences: Help is edited as Markdown and released with the package. The Manager depends on
|
||||||
|
marked, DOMPurify and github-markdown-css. A page without a topic of its own shows the index. The
|
||||||
|
help is not reachable without a session.
|
||||||
|
|
||||||
|
## ADR-51: The files of web sites pass the manager streamed, outside the JSON proxy
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Context: The Web Sites page uploads and downloads the files of the router's web sites, up to the
|
||||||
|
router's `sites.upload_max_mb`, by default 512 MiB. The manager's proxy reads a request body of at
|
||||||
|
most 64 KiB into memory, sends it as JSON and holds the whole answer; the API helper of the page
|
||||||
|
parses every answer as JSON. Encoding files in JSON would grow them by a third and still need the
|
||||||
|
whole file in memory.
|
||||||
|
|
||||||
|
Decision: `GET`, `HEAD` and `PUT` of `/api/sites/<name>/files/...` take their own route in the
|
||||||
|
manager: the request body is passed to the router as it is read, with its `Content-Type` and
|
||||||
|
`Content-Length`, and the router's answer is passed back as it arrives, with its `Content-Type`,
|
||||||
|
`Content-Length` and `Content-Disposition`. The page uploads with `XMLHttpRequest`, whose upload
|
||||||
|
events give the progress `fetch` does not, sending the file as the raw body, a zip archive with
|
||||||
|
`?unzip=true`, and downloads with `fetch` into a blob. Folders, renaming and removal stay JSON
|
||||||
|
calls of the usual proxy.
|
||||||
|
|
||||||
|
Consequences: Large files cross the manager without filling its memory. An upload needs a
|
||||||
|
`Content-Length`, which browsers send for a file. Dropped folders are not uploaded as trees; a zip
|
||||||
|
archive carries a tree.
|
||||||
|
|
||||||
|
## ADR-52: On a workstation, the router's URL is the Terraform output router_web_url
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Context: With `terraform_dir`, the manager built `http://<router_private_ip_addresses.int>:7081`.
|
||||||
|
The port of the router's web service is `web.port` and the interfaces it listens on are `web.listen`,
|
||||||
|
which the platform sets from its variables; the manager that `dev.sh` runs on a workstation cannot read them.
|
||||||
|
|
||||||
|
Decision: `router_url` is the output `router_web_url` of the platform, which derives the URL from
|
||||||
|
the same variables that configure the router.
|
||||||
|
|
||||||
|
Consequences: The manager follows the router's port and interfaces without settings of its own; a
|
||||||
|
state written before the platform had the output gives no `router_url` until the next deploy.
|
||||||
|
|
||||||
|
## ADR-53: The system's packages are upgraded with apt-get upgrade, apart from the router's package
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Context: The Overview tab upgrades only `vpn-router`. The router's
|
||||||
|
operating system needs its other packages upgraded too, from the same page. An upgrade restarts
|
||||||
|
services, the manager among them, so it cannot run inside the manager's unit.
|
||||||
|
|
||||||
|
Decision: The System tab lists what `apt-get -s upgrade` would upgrade and runs `apt-get upgrade
|
||||||
|
-y` through the same helper as the router's package, with `--system`, in its own transient unit
|
||||||
|
and with its own state and log files, so each tab shows its own last upgrade. `upgrade`, not
|
||||||
|
`dist-upgrade` or `full-upgrade`, so nothing is installed or removed; a package that needs a new
|
||||||
|
dependency is kept back. The manager refuses an upgrade while the other runs; the check for
|
||||||
|
updates is the same `apt-get update` for both.
|
||||||
|
|
||||||
|
Consequences: Kept-back packages, a new kernel series among them, stay until upgraded on the
|
||||||
|
router. An upgrade of the system also upgrades the router's package when it is newer, which
|
||||||
|
the Overview tab's state does not record.
|
||||||
|
|
||||||
|
## ADR-54: The Shell page's editor is Monaco, opened by vre and loaded on demand
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Context: Administrators edit files on the router from the Shell page. The page needs no file
|
||||||
|
browser: the shell names the files, as `$EDITOR` would. Monaco is VS Code's editor component and
|
||||||
|
is published as the ESM package `monaco-editor`. Its full entry point brings the language services
|
||||||
|
of CSS, HTML, JSON and TypeScript, each with a worker, and its bundle is several megabytes.
|
||||||
|
|
||||||
|
Decision: The router's `vre` names a file in a text message over the shell's WebSocket, which the
|
||||||
|
manager relays unchanged. The page reads and saves the file through the JSON proxy, whose body
|
||||||
|
limit is raised for `/api/shell/editor/` alone to hold a file of up to 1 MiB escaped in JSON.
|
||||||
|
Monaco 0.57 is bundled from npm through its tree-shakeable entry points: `monaco-editor/editor`
|
||||||
|
with all editor features and all language definitions, and only the editor worker. It is a chunk
|
||||||
|
of its own, imported when the first file opens, and Vite's preload helper has a chunk of its own,
|
||||||
|
so the entry does not load Monaco.
|
||||||
|
|
||||||
|
Consequences: The editor colours the syntax of every language Monaco defines, but has no
|
||||||
|
validation, completion or formatting by language services. The page loads Monaco, about 1.1 MB
|
||||||
|
compressed, the first time a file opens.
|
||||||
|
|
||||||
|
## ADR-55: index.html sets the color scheme before the first paint
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Context: Each page of the manager is a document of its own, so a page switch loads `index.html`
|
||||||
|
again. MUI sets the color scheme class on `<html>` and the page background from JavaScript, once
|
||||||
|
React renders, which waits for the bundle and for `GET /info`. Until then the browser paints its
|
||||||
|
default white page.
|
||||||
|
|
||||||
|
Decision: An inline script in the head of `index.html` reads the mode MUI stores in localStorage
|
||||||
|
(`mui-mode`, `system` when not stored), as MUI's `InitColorSchemeScript` does, and adds the class
|
||||||
|
`light` or `dark` to `<html>`. An inline style gives `html.dark` the dark color scheme and MUI's
|
||||||
|
default dark background, `#121212`. The page has no meta color-scheme, which would follow the
|
||||||
|
system against a stored light or dark choice.
|
||||||
|
|
||||||
|
Consequences: A page in the dark theme is dark from its first paint. The script depends on the
|
||||||
|
storage key and class names of MUI's defaults, which `colorSchemeSelector: 'class'` uses.
|
||||||
|
|
||||||
|
## ADR-56: The Router Manager is part of the vpn-router package
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Context: The Router Manager runs only on the router: it imports `vpnrouter_net`, `vpn-router-setup`
|
||||||
|
opens its ports and creates its first administrator, Caddy proxies it and the status reports it.
|
||||||
|
As a package of its own it would pin the router's version and the router its, and a change of the
|
||||||
|
router's API would need two repositories and two changelogs.
|
||||||
|
|
||||||
|
Decision: One package, `vpn-router`, installs the manager with the router. The manager's programs
|
||||||
|
are in `/usr/lib/vpn-router`, its application and help in `/usr/share/vpn-router/manager`, its
|
||||||
|
settings and secrets in `/etc/vpn-router` (`manager.json`, `manager-database.secret`,
|
||||||
|
`manager-admin.json`) and the state of its upgrades in `/var/lib/vpn-router`. The unit
|
||||||
|
`vpn-router-manager.service`, its transient units and its database keep their names. The debconf
|
||||||
|
questions of its first administrator are `vpn-router/admin_username` and `admin_password`. The
|
||||||
|
router's bootstrap page shows the manager, and the manager has no bootstrap service of its own.
|
||||||
|
The package carries no migration from the separate package: it has no relation to
|
||||||
|
`vpn-router-manager`, and dpkg refuses to install it beside that package, since both have
|
||||||
|
`vpn-router-manager.service`. `dev.sh` still runs the manager on a workstation, with
|
||||||
|
`terraform_dir` and `bind`.
|
||||||
|
|
||||||
|
Consequences: A router with the separate package is moved to this one by hand: remove
|
||||||
|
`vpn-router-manager` without purging it, move its files to the new paths, then install
|
||||||
|
`vpn-router`. A backup made before the merge restores the manager's files to
|
||||||
|
`/etc/vpn-router-manager`, where the manager no longer reads them. The repository holds the
|
||||||
|
manager's `web/` and `docs/`, and its build runs from the root.
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
|
ARG NODE_VERSION=24
|
||||||
ARG UBUNTU_VERSION=24.04
|
ARG UBUNTU_VERSION=24.04
|
||||||
|
|
||||||
|
FROM node:${NODE_VERSION}-bookworm-slim AS web
|
||||||
|
WORKDIR /web
|
||||||
|
COPY web/package.json web/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY web/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
FROM ubuntu:${UBUNTU_VERSION}
|
FROM ubuntu:${UBUNTU_VERSION}
|
||||||
ARG HOST_UID
|
ARG HOST_UID
|
||||||
ARG HOST_GID
|
ARG HOST_GID
|
||||||
@@ -12,7 +21,9 @@ RUN apt-get update \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY . src/
|
COPY debian-package/ src/
|
||||||
|
COPY docs/ src/docs/
|
||||||
|
COPY --from=web /web/dist src/web/dist/
|
||||||
RUN cd src && dpkg-buildpackage -us -uc -b
|
RUN cd src && dpkg-buildpackage -us -uc -b
|
||||||
|
|
||||||
RUN lintian --no-tag-display-limit /build/*.changes || true
|
RUN lintian --no-tag-display-limit /build/*.changes || true
|
||||||
@@ -15,7 +15,7 @@ A Debian package that configures a Linux host or virtual machine as a VPN router
|
|||||||
- metrics of the router kept in PostgreSQL, served for an OpenTelemetry Collector and pushed
|
- metrics of the router kept in PostgreSQL, served for an OpenTelemetry Collector and pushed
|
||||||
over OTLP
|
over OTLP
|
||||||
- static web sites served by Caddy, uploaded through the web service or WebDAV, and an optional
|
- static web sites served by Caddy, uploaded through the web service or WebDAV, and an optional
|
||||||
primary name server (PowerDNS) of the router's domains, with secondaries and records for the sites
|
primary name server (PowerDNS) of the router's domains, with a secondary and records for the sites
|
||||||
|
|
||||||
Nothing in the package is specific to a cloud provider. It is built on the external and
|
Nothing in the package is specific to a cloud provider. It is built on the external and
|
||||||
internal NIC names, and a `mode` setting says how much of the rest you are supplying and
|
internal NIC names, and a `mode` setting says how much of the rest you are supplying and
|
||||||
@@ -108,7 +108,8 @@ must have the type shown below; an unknown section or setting is an error.
|
|||||||
| `general` | `mode` | string | `manual` | How much you supply: `manual`, `interfaces` or `auto` |
|
| `general` | `mode` | string | `manual` | How much you supply: `manual`, `interfaces` or `auto` |
|
||||||
| `interfaces` | `external` | string | | Name of the NIC facing the untrusted network |
|
| `interfaces` | `external` | string | | Name of the NIC facing the untrusted network |
|
||||||
| `interfaces` | `internal` | string | | Name of the NIC facing the protected network |
|
| `interfaces` | `internal` | string | | Name of the NIC facing the protected network |
|
||||||
| `wan` | `local_fqdn` | string | | This router's name or IP address: its IKE identity in `fqdn` mode, the name resolved in `public_ip` mode, and the name of HTTPS, IKEv2 and OpenVPN |
|
| `wan` | `local_fqdn` | string | | This router's name or IP address: its IKE identity in `fqdn` mode, the name resolved in `public_ip` mode, the name of HTTPS, IKEv2 and OpenVPN, and the primary name server of its domains |
|
||||||
|
| `wan` | `public_address` | string | | The router's public IP address, which its own interfaces do not show behind a cloud's NAT: the address of the records of the sites with `dns_address` `public` |
|
||||||
| `local` | `cidrs` | list | | Local subnets routed via the internal side, masqueraded to the external one, and offered to IKEv2 and OpenVPN clients |
|
| `local` | `cidrs` | list | | Local subnets routed via the internal side, masqueraded to the external one, and offered to IKEv2 and OpenVPN clients |
|
||||||
| `local` | `int_addr` | string | | This host's address on the internal network |
|
| `local` | `int_addr` | string | | This host's address on the internal network |
|
||||||
| `local` | `int_gateway_ip` | string | | Next hop on the internal side |
|
| `local` | `int_gateway_ip` | string | | Next hop on the internal side |
|
||||||
@@ -148,9 +149,8 @@ must have the type shown below; an unknown section or setting is an error.
|
|||||||
| `sites` | `upload_max_mb` | integer | `512` | The largest upload of a site's file or zip archive, in MiB, 1 to 10240 |
|
| `sites` | `upload_max_mb` | integer | `512` | The largest upload of a site's file or zip archive, in MiB, 1 to 10240 |
|
||||||
| `sites` | `webdav` | boolean | `false` | WebDAV of the sites' folders, which needs `https.enabled` and `wan.local_fqdn` (see WebDAV) |
|
| `sites` | `webdav` | boolean | `false` | WebDAV of the sites' folders, which needs `https.enabled` and `wan.local_fqdn` (see WebDAV) |
|
||||||
| `sites` | `webdav_port` | integer | `7445` | HTTPS port of WebDAV |
|
| `sites` | `webdav_port` | integer | `7445` | HTTPS port of WebDAV |
|
||||||
| `dns` | `enabled` | boolean | `false` | The primary name server of the router's domains, which needs `dns.nameserver` and `dns.addresses` (see Name server) |
|
| `dns` | `enabled` | boolean | `false` | The primary name server of the router's domains, `wan.local_fqdn`, which must then be a DNS name (see Name server) |
|
||||||
| `dns` | `nameserver` | string | | The name server's host name, the primary NS of every domain |
|
| `dns` | `secondary` | string | | The secondary name server of every domain, a host name other than `wan.local_fqdn`; it may transfer the domains |
|
||||||
| `dns` | `addresses` | list | | The public addresses of the name server, the domains' apexes and the sites' host names |
|
|
||||||
| `dns` | `listen` | string | `external` | The interfaces the name server answers on: `internal`, `external` or `both` |
|
| `dns` | `listen` | string | `external` | The interfaces the name server answers on: `internal`, `external` or `both` |
|
||||||
| `dns` | `ttl` | integer | `300` | TTL of the records, 60 to 86400 seconds |
|
| `dns` | `ttl` | integer | `300` | TTL of the records, 60 to 86400 seconds |
|
||||||
| `pki` | `root_name` | string | `VPN Router Root CA` | Common name of the root CA, when the router creates it |
|
| `pki` | `root_name` | string | `VPN Router Root CA` | Common name of the root CA, when the router creates it |
|
||||||
@@ -769,9 +769,9 @@ address, and `--email ''` removes it. Changing a user's password, disabling, unl
|
|||||||
refresh tokens of `/auth` and its codes, tokens and sessions of the OpenID Provider. The exit status is 0 on success, 2 for an invalid request, 3 when the
|
refresh tokens of `/auth` and its codes, tokens and sessions of the OpenID Provider. The exit status is 0 on success, 2 for an invalid request, 3 when the
|
||||||
user does not exist, 4 when the user exists and 1 for any other failure.
|
user does not exist, 4 when the user exists and 1 for any other failure.
|
||||||
|
|
||||||
When the Router Manager is installed with an administrator (its debconf questions
|
When the package is installed with a Router Manager administrator (the debconf questions
|
||||||
`vpn-router-manager/admin_username` and `admin_password`), its package leaves the answers in
|
`vpn-router/admin_username` and `admin_password`), it leaves the answers in
|
||||||
`/etc/vpn-router-manager/admin.json` (root only). `vpn-router-setup` then creates that user as a
|
`/etc/vpn-router/manager-admin.json` (root only). `vpn-router-setup` then creates that user as a
|
||||||
Manager-only account, adds it to the group `admins`, which it creates when missing, and removes
|
Manager-only account, adds it to the group `admins`, which it creates when missing, and removes
|
||||||
the file.
|
the file.
|
||||||
|
|
||||||
@@ -943,7 +943,7 @@ configuration: no key, identity, subnet, or address but the client's. They show
|
|||||||
| `status_content` | `/` and `/status` show |
|
| `status_content` | `/` and `/status` show |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `basic` (default) | The overall status (`ok` or `degraded`), the time and the Client IP |
|
| `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 |
|
| `standard` | Also the `vpn-router` version and the number of active services and of services in any other state, without their names |
|
||||||
| `full` | Also each service and its state, Docker, how many site-to-site connections are set up and how many of them are established (`site_to_site`: `connections` and `established`, `null` when swanctl cannot be asked), IKEv2, OpenVPN and WireGuard |
|
| `full` | Also each service and its state, Docker, how many site-to-site connections are set up and how many of them are established (`site_to_site`: `connections` and `established`, `null` when swanctl cannot be asked), IKEv2, OpenVPN and WireGuard |
|
||||||
|
|
||||||
The Client IP is the address the request came from: the last entry of `X-Forwarded-For` for a
|
The Client IP is the address the request came from: the last entry of `X-Forwarded-For` for a
|
||||||
@@ -966,16 +966,16 @@ The service is `/usr/lib/vpn-router/web`, which starts it, and the package
|
|||||||
### Bootstrap diagnostics
|
### Bootstrap diagnostics
|
||||||
|
|
||||||
Until a run of `vpn-router-setup` has succeeded, every run starts `vpn-router-bootstrap`, which
|
Until a run of `vpn-router-setup` has succeeded, every run starts `vpn-router-bootstrap`, which
|
||||||
serves diagnostics as plain text on port 17081 of every address, and the firewall opens 17081 and
|
serves diagnostics as plain text on port 17081 of every address, and the firewall opens 17081
|
||||||
17080 (the Router Manager's bootstrap) on the internal interface. The first successful run stops
|
on the internal interface. The first successful run stops
|
||||||
the bootstrap services and closes their ports, and writes `/var/lib/vpn-router/setup.done`, after
|
the bootstrap service and closes its port, and writes `/var/lib/vpn-router/setup.done`, after
|
||||||
which they never run again; a failed run leaves them running, so the reason stays readable until
|
which it never runs again; a failed run leaves it running, so the reason stays readable until
|
||||||
a later run succeeds. The unit is never enabled.
|
a later run succeeds. The unit is never enabled.
|
||||||
|
|
||||||
The page, read again every 10 seconds, shows the state, the step and the failure of the last
|
The page, read again every 10 seconds, shows the state, the step and the failure of the last
|
||||||
setup run (`/run/vpn-router/setup.json`, which setup writes at every step), the state of the
|
setup run (`/run/vpn-router/setup.json`, which setup writes at every step), the state of the
|
||||||
router's services, the failed units, the addresses and routes, `cloud-init status`, and the end of
|
router's services, the failed units, the addresses and routes, `cloud-init status`, and the end of
|
||||||
the logs of `vpn-router-setup` and `vpn-router-web`:
|
the logs of `vpn-router-setup`, `vpn-router-web` and `vpn-router-manager`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://10.0.0.4:17081/
|
curl http://10.0.0.4:17081/
|
||||||
@@ -999,11 +999,10 @@ curl http://10.0.0.4:17081/
|
|||||||
|
|
||||||
The status holds the state of `vpn-router-setup`, `slapd` and each PostgreSQL cluster unit
|
The status holds the state of `vpn-router-setup`, `slapd` and each PostgreSQL cluster unit
|
||||||
(`postgresql@<version>-<cluster>`, or `postgresql` as `no cluster` without one), of
|
(`postgresql@<version>-<cluster>`, or `postgresql` as `no cluster` without one), of
|
||||||
`vpn-router-manager` when that package is installed, of `strongswan` when site-to-site or
|
`vpn-router-manager`, of `strongswan` when site-to-site or
|
||||||
IKEv2 access is configured, of `wg-quick@wg0` when WireGuard is, and of
|
IKEv2 access is configured, of `wg-quick@wg0` when WireGuard is, and of
|
||||||
`openvpn-server@vpn-router` when OpenVPN is; the number of connected
|
`openvpn-server@vpn-router` when OpenVPN is; the number of connected
|
||||||
OpenVPN clients, from the server's status file, which OpenVPN rewrites every 5 seconds; the package version and,
|
OpenVPN clients, from the server's status file, which OpenVPN rewrites every 5 seconds; the package version; whether
|
||||||
as `manager_version`, the installed version of `vpn-router-manager` (`null`, shown as not installed, without it); whether
|
|
||||||
Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker`
|
Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker`
|
||||||
(the Docker rules), which do not change `status`; as `site_to_site`, the number of site-to-site
|
(the Docker rules), which do not change `status`; as `site_to_site`, the number of site-to-site
|
||||||
connections that are set up (`connections`) and of those established (`established`, `null` when
|
connections that are set up (`connections`) and of those established (`established`, `null` when
|
||||||
@@ -1018,7 +1017,7 @@ curl http://10.0.0.4:7081/status
|
|||||||
|
|
||||||
### Tokens and signing in
|
### Tokens and signing in
|
||||||
|
|
||||||
`/config`, `/metrics/history`, `/metrics/settings`, `/users`, `/service-accounts`, `/groups`, `/site-to-site`, `/backup`, `/remote-access`, `/ca`, `/logs`, `/sites`, `/dns`, `/shell` and `/account` need a JWT in an `Authorization: Bearer` header, signed with
|
`/config`, `/metrics/history`, `/metrics/settings`, `/https/settings`, `/users`, `/service-accounts`, `/groups`, `/site-to-site`, `/backup`, `/remote-access`, `/ca`, `/logs`, `/sites`, `/dns`, `/shell` and `/account` need a JWT in an `Authorization: Bearer` header, signed with
|
||||||
HS256 by the key the web service makes on its first run, `/etc/vpn-router/web.key` (root only).
|
HS256 by the key the web service makes on its first run, `/etc/vpn-router/web.key` (root only).
|
||||||
The key never leaves the router; while it does not exist these paths and `/auth` are not served
|
The key never leaves the router; while it does not exist these paths and `/auth` are not served
|
||||||
(404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with
|
(404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with
|
||||||
@@ -1150,7 +1149,7 @@ vre /etc/vpn-router/vpn-router.json ~/notes.txt
|
|||||||
|
|
||||||
### The /config page
|
### The /config page
|
||||||
|
|
||||||
`/config` shows what `/` and `/status` leave out, for a client such as manage-ui:
|
`/config` shows what `/` and `/status` leave out, for a client such as Cloud Manager:
|
||||||
|
|
||||||
| Key | Content |
|
| Key | Content |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -1226,13 +1225,11 @@ curl -X PUT -H "Authorization: Bearer $TOKEN" --data-binary @site.zip \
|
|||||||
|
|
||||||
| Request | Answer |
|
| Request | Answer |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `GET /dns` | `{"settings", "installed", "running", "domains", "problem"}`: the `dns` section, whether PowerDNS is installed and runs, the domains, each `{"name", "sites", "ttl", "secondaries", "manual", "records"}` with its secondaries `{"nameserver", "address"}`, its manual records and the records it serves, and why they cannot be read, `null` when they can |
|
| `GET /dns` | `{"settings", "nameserver", "public_address", "installed", "running", "domains", "problem"}`: the `dns` section, `wan.local_fqdn`, the primary name server, `wan.public_address`, whether PowerDNS is installed and runs, the domains, each `{"name", "ttl", "manual", "records"}` with its manual records and the records it serves, and why they cannot be read, `null` when they can |
|
||||||
| `PUT /dns` | Changes any of the settings of the `dns` section |
|
| `PUT /dns` | Changes any of the settings of the `dns` section, and with `public_address` `wan.public_address`; answers `{"settings", "public_address"}` |
|
||||||
| `POST /dns/domains` | Adds a domain: `{"name", "sites"?, "ttl"?}`; an existing name is 409 |
|
| `POST /dns/domains` | Adds a domain: `{"name", "ttl"?}`; an existing name is 409 |
|
||||||
| `PUT /dns/domains/<domain>` | Changes `sites` or `ttl` of the domain |
|
| `PUT /dns/domains/<domain>` | Changes `ttl` of the domain |
|
||||||
| `DELETE /dns/domains/<domain>` | Removes the domain with its secondaries and manual records |
|
| `DELETE /dns/domains/<domain>` | Removes the domain with its manual records |
|
||||||
| `POST /dns/domains/<domain>/secondaries` | Adds a secondary name server, or changes its address: `{"nameserver", "address"}` |
|
|
||||||
| `DELETE /dns/domains/<domain>/secondaries/<nameserver>` | Removes the secondary name server |
|
|
||||||
| `POST /dns/domains/<domain>/records` | Adds a manual record: `{"name", "type", "content", "ttl"?}`; `name` is relative to the domain, `@` for the domain itself, or absolute with a final dot, and `ttl` is the domain's without it |
|
| `POST /dns/domains/<domain>/records` | Adds a manual record: `{"name", "type", "content", "ttl"?}`; `name` is relative to the domain, `@` for the domain itself, or absolute with a final dot, and `ttl` is the domain's without it |
|
||||||
| `DELETE /dns/domains/<domain>/records?name=&type=&content=` | Removes the manual records of the name and type, only the one of `content` when it is given |
|
| `DELETE /dns/domains/<domain>/records?name=&type=&content=` | Removes the manual records of the name and type, only the one of `content` when it is given |
|
||||||
|
|
||||||
@@ -1405,6 +1402,18 @@ A change is made, checked and applied as for `/site-to-site`.
|
|||||||
|
|
||||||
A change is made, checked and applied as for `/site-to-site`.
|
A change is made, checked and applied as for `/site-to-site`.
|
||||||
|
|
||||||
|
### The /https/settings API
|
||||||
|
|
||||||
|
`/https/settings` shows the settings of HTTPS and changes the CA of Caddy's certificates, with the
|
||||||
|
same token as `/users`.
|
||||||
|
|
||||||
|
| Request | Body | Answer |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET /https/settings` | | `{"settings"}`: the `https` section |
|
||||||
|
| `PUT /https/settings` | `{"acme_staging"}`; any other setting of the `https` section is 400, as it is changed on the router with `vpn-config-edit` | `{"settings"}` |
|
||||||
|
|
||||||
|
A change is made, checked and applied as for `/site-to-site`.
|
||||||
|
|
||||||
### The /remote-access API
|
### The /remote-access API
|
||||||
|
|
||||||
`/remote-access` shows and changes the remote access systems, IKEv2 (`p2s`),
|
`/remote-access` shows and changes the remote access systems, IKEv2 (`p2s`),
|
||||||
@@ -1467,7 +1476,7 @@ and the facts for `/config` from `/run/vpn-router/web.json` (readable by root on
|
|||||||
## HTTPS
|
## HTTPS
|
||||||
|
|
||||||
Caddy serves the status web service on `https.web_port` (7444 by
|
Caddy serves the status web service on `https.web_port` (7444 by
|
||||||
default), while the Router Manager is installed the manager on `https.manager_port` (7443), and
|
default), the Router Manager on `https.manager_port` (7443), and
|
||||||
while metrics are enabled the metrics service on `metrics.https_port` (9465), all for `wan.local_fqdn`, while `https.enabled` is `true` and `wan.local_fqdn` is set. It obtains
|
while metrics are enabled the metrics service on `metrics.https_port` (9465), all for `wan.local_fqdn`, while `https.enabled` is `true` and `wan.local_fqdn` is set. It obtains
|
||||||
and renews the certificate from Let's Encrypt with the HTTP challenge, so `wan.local_fqdn` must
|
and renews the certificate from Let's Encrypt with the HTTP challenge, so `wan.local_fqdn` must
|
||||||
resolve to the router's public address and port 80 must be reachable from the internet; the
|
resolve to the router's public address and port 80 must be reachable from the internet; the
|
||||||
@@ -1484,6 +1493,9 @@ obtains from an ACME CA, those of the web sites with `tls` `acme` included.
|
|||||||
vpn-config-edit set --restart https.acme_staging true
|
vpn-config-edit set --restart https.acme_staging true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The Router Manager switches it on the Overview, and `PUT /https/settings` changes it (see The
|
||||||
|
/https/settings API).
|
||||||
|
|
||||||
`vpn-router-setup` writes Caddy's configuration to `/etc/caddy/vpn-router.Caddyfile` (root, group
|
`vpn-router-setup` writes Caddy's configuration to `/etc/caddy/vpn-router.Caddyfile` (root, group
|
||||||
`caddy`, the group Caddy runs as, mode 0640), which the
|
`caddy`, the group Caddy runs as, mode 0640), which the
|
||||||
package's drop-in `/usr/lib/systemd/system/caddy.service.d/vpn-router.conf` makes `caddy.service`
|
package's drop-in `/usr/lib/systemd/system/caddy.service.d/vpn-router.conf` makes `caddy.service`
|
||||||
@@ -1726,10 +1738,15 @@ Router Manager, each change applied by restarting `vpn-router-setup`. A site has
|
|||||||
| `index_file` | The file served for a folder, `index.html` by default |
|
| `index_file` | The file served for a folder, `index.html` by default |
|
||||||
| `spa` | Whether a path without a file is served the site's `index_file`, for single-page applications |
|
| `spa` | Whether a path without a file is served the site's `index_file`, for single-page applications |
|
||||||
| `editors` | The group whose members may change the site's files besides the admins |
|
| `editors` | The group whose members may change the site's files besides the admins |
|
||||||
|
| `dns` | Who serves the records of its host names: `user` (the default), a DNS provider outside the router, or `router`, the router's name server (see Name server) |
|
||||||
|
| `dns_address` | With `dns` `router`, the router's address in the records: `public` (the default, `wan.public_address`), `internal` or `external` |
|
||||||
|
|
||||||
A site may not take a port of the router's own services (SSH, `web.port`, the HTTPS ports, the
|
A site may not take a port of the router's own services (SSH, `web.port`, the HTTPS ports, the
|
||||||
metrics ports, the Router Manager's, `sites.webdav_port`, 7446, 53, the bootstrap ports, a TCP
|
metrics ports, the Router Manager's, `sites.webdav_port`, 7446, 53, the bootstrap ports, a TCP
|
||||||
`openvpn.port` and `docker.exposed_ports`) or `wan.local_fqdn`. Enabled sites on one port need host
|
`openvpn.port` and `docker.exposed_ports`). It may use `wan.local_fqdn` as a host name, on a port
|
||||||
|
of its own, such as 443, which the router's services leave free; its record is then kept where the
|
||||||
|
domain of `wan.local_fqdn` is, so `dns` `router` takes neither it, nor wildcards or addresses, and
|
||||||
|
`public` needs `wan.public_address`. Enabled sites on one port need host
|
||||||
names of their own, the same `listen` and the same kind of TLS (plain HTTP or HTTPS). A site of
|
names of their own, the same `listen` and the same kind of TLS (plain HTTP or HTTPS). A site of
|
||||||
`acme` needs host names, without wildcards or addresses, that resolve to the router, and port 80,
|
`acme` needs host names, without wildcards or addresses, that resolve to the router, and port 80,
|
||||||
which setup opens on the external interface. The certificate of `file` must be the key's and name
|
which setup opens on the external interface. The certificate of `file` must be the key's and name
|
||||||
@@ -1788,32 +1805,43 @@ missing, without the recommended bind backend.
|
|||||||
|
|
||||||
| Setting | Meaning |
|
| Setting | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `dns.nameserver` | The name server's host name, the primary NS and the SOA's primary of every domain |
|
| `wan.local_fqdn` | The primary name server of every domain: the SOA's primary and the first NS |
|
||||||
| `dns.addresses` | The public addresses of the domains' apexes, of the name server in a domain it is inside, and of the sites' host names |
|
| `dns.secondary` | The secondary name server of every domain, the second NS; the addresses it resolves to are notified of every change and may transfer the domains (AXFR) |
|
||||||
| `dns.ttl` | The TTL of the records and the SOA's negative TTL of a domain without its own, 60 to 86400 seconds, 300 by default |
|
| `dns.ttl` | The TTL of the records and the SOA's negative TTL of a domain without its own, 60 to 86400 seconds, 300 by default |
|
||||||
|
|
||||||
|
A domain the router hosts, for example `example.com`, with `wan.local_fqdn` `router.other.net`,
|
||||||
|
`dns.secondary` `ns2.provider.net` and a site of `www.example.com` and `example.com` with `dns`
|
||||||
|
`router` and `dns_address` `public` (`wan.public_address` `203.0.113.10`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
example.com. SOA router.other.net. hostmaster.example.com. <serial> 10800 3600 604800 300
|
||||||
|
example.com. NS router.other.net.
|
||||||
|
example.com. NS ns2.provider.net.
|
||||||
|
example.com. A 203.0.113.10
|
||||||
|
www.example.com. A 203.0.113.10
|
||||||
|
```
|
||||||
|
|
||||||
The domains are added with `/dns` or the Router Manager, each with:
|
The domains are added with `/dns` or the Router Manager, each with:
|
||||||
|
|
||||||
- **Site records**: every host name of an enabled site gets A and AAAA records of `dns.addresses`
|
|
||||||
in the closest domain it is in, when that domain has site records;
|
|
||||||
- **TTL**: the TTL of its records, `dns.ttl` when not set;
|
- **TTL**: the TTL of its records, `dns.ttl` when not set;
|
||||||
- **Secondaries**: name servers, each a host name, which becomes an NS of the domain, and the
|
|
||||||
address it transfers the domain from. The router notifies that address of every change and
|
|
||||||
allows it zone transfers (AXFR); a secondary's name inside the domain gets an A or AAAA record of
|
|
||||||
that address;
|
|
||||||
- **Manual records** (A, AAAA, CNAME, TXT, MX, SRV, CAA), served too, but not those of a name and
|
- **Manual records** (A, AAAA, CNAME, TXT, MX, SRV, CAA), served too, but not those of a name and
|
||||||
type the domain's own records have.
|
type the domain's own records have.
|
||||||
|
|
||||||
A domain inside another domain of the router is delegated from it with NS records. Setup writes the
|
The host names of an enabled site with `dns` `router` get an A or AAAA record of its `dns_address`
|
||||||
domains at each run, and after each change through `/dns`; the SOA's serial grows only when the
|
in the closest domain they are in; a site with `dns` `user` gets none, and a host name in no domain
|
||||||
records change. While IKEv2, OpenVPN or WireGuard is enabled, dnsmasq answers their clients on port 53
|
of the router gets none either. `wan.local_fqdn` gets no record in the domains: its A record is kept
|
||||||
of the addresses of `p2s.dns_listen`, by default the internal address, so `dns.listen` must not
|
by the DNS of its own domain, wherever that is. A domain inside another domain of the router is
|
||||||
share an interface with it. The parent zone of each domain must delegate it to `dns.nameserver` and
|
delegated from it with NS records. Setup writes the domains at each run, and after each change
|
||||||
the secondaries, with glue records for name servers inside the domain.
|
through `/dns`; the SOA's serial grows only when the records change. While IKEv2, OpenVPN or
|
||||||
|
WireGuard is enabled, dnsmasq answers their clients on port 53 of the addresses of
|
||||||
|
`p2s.dns_listen`, by default the internal address, so `dns.listen` must not share an interface with
|
||||||
|
it. The parent zone of each domain must delegate it to `wan.local_fqdn` and `dns.secondary`, with
|
||||||
|
glue records for those of them inside the domain. While `dns.secondary` is not set, the domains have
|
||||||
|
`wan.local_fqdn` as their only name server, and setup warns.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
vpn-config-edit set dns.nameserver ns1.example.com
|
vpn-config-edit set dns.secondary ns2.provider.net
|
||||||
vpn-config-edit set dns.addresses 203.0.113.10
|
vpn-config-edit set wan.public_address 203.0.113.10
|
||||||
vpn-config-edit set --restart dns.enabled true
|
vpn-config-edit set --restart dns.enabled true
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1938,10 +1966,9 @@ A backup holds the state that `vpn-router-setup` cannot make again:
|
|||||||
|
|
||||||
| Item | Content |
|
| Item | Content |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `/etc/vpn-router` | `vpn-router.json`, the secrets and `rclone.conf` |
|
| `/etc/vpn-router` | `vpn-router.json`, the secrets, the Router Manager's settings and `rclone.conf` |
|
||||||
| `/etc/wireguard` | The WireGuard keys and the peers |
|
| `/etc/wireguard` | The WireGuard keys and the peers |
|
||||||
| `/etc/ufw`, `/etc/default/ufw` | The firewall rules |
|
| `/etc/ufw`, `/etc/default/ufw` | The firewall rules |
|
||||||
| `/etc/vpn-router-manager` | The Router Manager's settings, when it is installed |
|
|
||||||
| `files/<path>` | Each `psk_file` outside `/etc/vpn-router` |
|
| `files/<path>` | Each `psk_file` outside `/etc/vpn-router` |
|
||||||
| `ldap/vpn-router.ldif` | The user directory, written by `slapcat`: users, groups, service accounts, password hashes and TOTP secrets, and the PKI: the CAs and their keys, the root key while it is on the router, and the certificates issued, with the keys of the router's and the users' certificates |
|
| `ldap/vpn-router.ldif` | The user directory, written by `slapcat`: users, groups, service accounts, password hashes and TOTP secrets, and the PKI: the CAs and their keys, the root key while it is on the router, and the certificates issued, with the keys of the router's and the users' certificates |
|
||||||
| `postgresql/vpn-router.dump` | With `backup.metrics`, the database of the metrics and the refresh tokens, written by `pg_dump -Fc` |
|
| `postgresql/vpn-router.dump` | With `backup.metrics`, the database of the metrics and the refresh tokens, written by `pg_dump -Fc` |
|
||||||
@@ -2022,13 +2049,398 @@ What it replaces is kept in `/var/lib/vpn-router/backup/before-restore/<UTC time
|
|||||||
|
|
||||||
## Router Manager
|
## Router Manager
|
||||||
|
|
||||||
The `vpn-router-manager` package, built in the repository `linux-cloud-router-manager`, is a web
|
The Router Manager is the package's web application: it shows the state of the router and manages
|
||||||
application that shows the state of the router and manages its users, site-to-site
|
the users of its OpenVPN and IKEv2 access. Users sign in with their user name, password
|
||||||
connections, backups and certificates through the status web service. It can be installed on the router or on another Linux system. While it is installed on
|
and, when enrolled for one-time passwords, the code, or with a passkey they added on their
|
||||||
the router, `vpn-router-setup` opens its port (`port` in `/etc/vpn-router-manager/manager.json`,
|
account page; only members of the router's group `admins`
|
||||||
7080 by default, HTTP; HTTPS is Caddy's, on `https.manager_port`) on the internal and the external
|
can use the tabs, as the router decides. Every user has an account page at `/user`, which is the
|
||||||
interface; restart `vpn-router-setup` after changing it. The manager's `listen` chooses the
|
only page of a user who is not a member of `admins`. Administrators switch between Router (`/`:
|
||||||
interfaces it listens on.
|
the Overview, Metrics, Logs, Backup and System tabs), Identities (`/user-manager`: the Users, Groups and Service
|
||||||
|
Accounts tabs), Connectivity (`/site-to-site-manager`: the Status, Network and Connections tabs), Remote
|
||||||
|
Access (`/remote-access-manager`: the IKEv2, OpenVPN and WireGuard tabs), Certificates
|
||||||
|
(`/ca-manager`), Web Sites (`/sites-manager`: the Sites, Files and DNS tabs) and
|
||||||
|
Docker (`/docker-manager`) with the page menu at the
|
||||||
|
right end of the header, a button naming the page shown. The button with the signed-in user's
|
||||||
|
name, left of Sign out, opens the account page, and the terminal icon left of it the Shell
|
||||||
|
(`/shell`); pressed on its page, each returns to the page it was opened from.
|
||||||
|
|
||||||
|
- **Help** (the question mark in the header between the theme switcher and the page menu, for every
|
||||||
|
signed-in user): a panel on the right, under
|
||||||
|
which the page moves left, with the help topic of the page and tab shown. The topics are the
|
||||||
|
Markdown files of `docs/` (`index.md` and one per page or tab), served by the manager at `/docs`
|
||||||
|
and rendered in the browser with marked and DOMPurify, in the style of github-markdown-css in
|
||||||
|
the light or dark scheme. A link to another topic opens it in the panel; Back returns to the
|
||||||
|
previous one and the house icon to `index.md`. Topics link to SVG and PNG pictures by paths
|
||||||
|
relative to their folder. The browser keeps whether the panel is open.
|
||||||
|
|
||||||
|
- **Settings** (the gear in the header, hidden on the sign-in and the account page): how often the Overview and the Metrics tab read the router
|
||||||
|
on their own, kept in the browser's localStorage, so each browser has its own. Each tab reads
|
||||||
|
manually, or every 5, 10 or 30 seconds, 1, 5 or 15 minutes; the Overview tab every 10 seconds and
|
||||||
|
the Metrics tab every minute by default. Manual reads when the tab opens and on Refresh. A change applies at once, also in the browser's other tabs; Defaults sets both back.
|
||||||
|
|
||||||
|
- **Account** (`/user`): the Account and the Passkeys panel side by side, with One-time passwords
|
||||||
|
and VPN profiles below. The user's name, kind, names, e-mail address, whether they are an
|
||||||
|
administrator and whether they are enrolled for one-time passwords; Change password in the
|
||||||
|
Account panel, which opens a dialog and signs the user out after the change; enrolling an authenticator app or replacing it; and the user's passkeys,
|
||||||
|
each with its label and when it was added, with Add a passkey and a remove icon. Each change but
|
||||||
|
the removal of a passkey asks for the current password and, for an enrolled user, a code not
|
||||||
|
already used to sign in. Removing the enrolment is left to an administrator. A VPN user downloads
|
||||||
|
the own OpenVPN profile (`.ovpn`) and the Apple VPN profile of the router's IKEv2 access
|
||||||
|
(`.mobileconfig`), each with the current password and the code when enrolled, the Apple profile
|
||||||
|
also with a password that protects the key in it.
|
||||||
|
|
||||||
|
- **Passkeys:** Sign in with a passkey on the sign-in page uses a passkey the browser or the
|
||||||
|
user's password manager offers for the router's `wan.local_fqdn`, without the user name, the
|
||||||
|
password or the code. Passkeys work on the Manager's HTTPS address on that name only, not on
|
||||||
|
another name or in development on `localhost`, and in browsers with the JSON methods of WebAuthn
|
||||||
|
Level 3. They sign in to the Manager, and open a shell on the Shell page in place of the password
|
||||||
|
and code.
|
||||||
|
|
||||||
|
- **Certificates** (`/ca-manager`, in the page menu of administrators): the router's two-level CA, from its `/ca`, in tabs:
|
||||||
|
- **Root:** the root CA's subject, expiry, whether its key is on the router, marked with a warning sign, or offline, and its CRL, with Download root CA, the root's certificate alone, and Upload CRL for a root CRL signed offline; the general information: where certificates and CRLs are published, the CA key type, the lifetimes, and the CRL validity; the certificates of the issuing CAs, each active one with a download icon for its CA bundle, and the retired ones.
|
||||||
|
- **Templates:** the certificate templates with their key, lifetime, subject, usages, alternative names, certificate policies and the CAs that issue them; Add template, Edit on each, Remove on one of the router's own and Reset on a changed built-in one, which brings back its defaults. A template's extended key usages are chosen from those of the next tab, and its certificate policies are OIDs typed in.
|
||||||
|
- **Extended key usages:** the usages templates may give, with their OIDs and the templates that give them; the built-in ones are shown and not changed, and custom ones are added with a name and an OID, and changed or removed.
|
||||||
|
- **P2S Issuing CA** and **S2S Issuing CA:** the CA's common name, state (active, or pending while it waits for the offline root), expiry, CRL and templates, and the certificates it issued. An active CA gives its CA bundle, the issuing CA followed by the root, with Download CA bundle; a pending CA gives its CSR with the openssl extensions to sign it with, and takes the certificate signed by the root.
|
||||||
|
- **Issuing CAs:** the general-purpose CAs with their state, expiry, CRL, templates and ACME directory URL; Add issuing CA, and Edit and Remove on each: its name, fixed once it exists, its common name, the templates it issues and its ACME server; an active CA's download icon gives its CA bundle. A removed CA is retired by the router, which revokes its certificate on the root CRL. Below, their certificates, filtered by CA. - **Certificates:** each with its kind, template, names, expiry and state; a certificate of another system or of the router is revoked after a confirmation with a reason.
|
||||||
|
- **Issue** (above the tabs): from a CSR, saving the certificate with its chain, or with a key the router makes, saving a PFX file protected by a password; the router keeps no copy of the key.
|
||||||
|
- **Settings** (above the tabs): the names, lifetimes, key type, CRL validity and publishing endpoints.
|
||||||
|
- **Warnings** (above the tabs): what the router says needs attention, such as a CRL about to expire.
|
||||||
|
|
||||||
|
The root key is exported, removed and imported on the router with `vpn-router-ca`, never through the Manager.
|
||||||
|
|
||||||
|
- **Overview**: the router's overall status, ok or degraded, on the title bar of the Status panel;
|
||||||
|
at the top of that panel, a table of `vpn-router`
|
||||||
|
with the installed version and, when the repository has a newer one, that version; Check update
|
||||||
|
is always in its top right corner, and Upgrade is added beside it when an update is found. Then
|
||||||
|
IKEv2, OpenVPN and WireGuard, read from the router's authenticated `/status/full`, and from its `/config` the status web service and its port, whether HTTPS is on and for which name, with a chip while Caddy takes Let's Encrypt's staging CA and a settings button that switches it (`https.acme_staging`, through the router's `/https/settings`), and Caddy, its HTTPS proxy: its version, whether it comes from Ubuntu or upstream (switched on the router with `vpn-router-caddy use-upstream`) with the configured `https.caddy_source` when that differs, and the state of `caddy.service`. The Services panel shows the state of each of the router's
|
||||||
|
systemd services and of Caddy, with a chip on its title bar telling whether the router's services are active. The Addresses panel links, opening in a new tab, to the status page over HTTPS and over plain HTTP on the addresses `web.listen` names, and to the Router Manager over HTTPS, and shows the issuer and the discovery URL of the router's OpenID Provider, each with an icon that copies it, or off while the provider is disabled. Further panels
|
||||||
|
show each PostgreSQL cluster of the
|
||||||
|
router with its main settings and its databases with their sizes, and one Configuration panel
|
||||||
|
with a group of rows under a heading for each section the other pages do not show, the status web service (`web`) and HTTPS (`https`) among them, read
|
||||||
|
every 10 seconds by default (see Settings). Nothing is read, and Refresh is off, while a check
|
||||||
|
for updates, an upgrade or a reboot runs; the tab reads again when it ends.
|
||||||
|
- **Panel controls:** each panel folds by its title bar, and every panel but Status hides by its [X]; Panels shows them again.
|
||||||
|
- **Edit layout:** moves panels by dragging and switches each between one column and both.
|
||||||
|
- **Layout storage:** kept in the browser.
|
||||||
|
- **Metrics**: charts of the router's CPU, memory, free disk space in bytes and in percent, the
|
||||||
|
bytes per second of each interface, the VPN clients and the load averages, and, with a line per
|
||||||
|
site-to-site connection, Site-to-site up, Site-to-site traffic and Site-to-site packets. They
|
||||||
|
cover the last hour, day, month or year, each bucket as its average or its maximum. Site-to-site
|
||||||
|
up is the percentage of time each connection was up, 0 to 100, in slots of 5 minutes for an
|
||||||
|
hour, 1 hour for a day, 1 day for a month and 1 month for a year, as the router computes it;
|
||||||
|
Average and Maximum do not change it. The charts are read every minute by default (see
|
||||||
|
Settings) from the router's `/metrics/history`. The charts fold, hide, move and change width as the Router
|
||||||
|
tab's panels do, with a layout of their own. A click on a line's legend item hides or shows the
|
||||||
|
line, for example the 1 and 5 minute load averages to see the 15 minute one; the browser keeps
|
||||||
|
the hidden lines of each chart. Settings edits the router's `metrics` section through its
|
||||||
|
`/metrics/settings`: whether the metrics service runs, the interfaces and the ports of
|
||||||
|
`/metrics`, its service account, the OTLP endpoint with its user name and password, and how long
|
||||||
|
each level of the history is kept. An empty password keeps the current one.
|
||||||
|
- **Logs**: the last lines of the router's logs, from its `/logs`. The Journal sub-tab shows the
|
||||||
|
systemd journal of a unit the router names, or of all units, and the Files sub-tab a log file of
|
||||||
|
the router's `web.log_files`, set on the router with `vpn-config-edit`. 100, 200, 500 or 1000
|
||||||
|
lines, up to the router's limit of one read, are read when the unit, the file or the number
|
||||||
|
changes and on Refresh. While Follow is on, the log is read every 2 seconds and kept scrolled to
|
||||||
|
its last line, as `tail -f` shows it.
|
||||||
|
- **Users**: the users with their names and e-mail addresses, without the service accounts, all
|
||||||
|
of them, the VPN users or the Manager-only accounts, searched by user name, names and e-mail
|
||||||
|
address, with adding (user name, first
|
||||||
|
name, last name, e-mail address and password, or, with Manager access only, a Manager-only
|
||||||
|
account with the user name, the password and an optional e-mail address: no certificate and no
|
||||||
|
VPN access), editing the names and the e-mail address of VPN users and the e-mail address of
|
||||||
|
Manager-only accounts,
|
||||||
|
setting the password, disabling, enabling, unlocking after failed logins, removing and
|
||||||
|
downloading the OpenVPN client profile, offered only while the router runs OpenVPN, and the Apple VPN profile
|
||||||
|
(`.mobileconfig`, IKEv2), offered only while the router runs IKEv2 access, whose dialog takes the
|
||||||
|
password that protects the user's key in it, and whether each is enrolled for TOTP one-time passwords. Enrolling shows a new secret as a QR
|
||||||
|
code and as text for an authenticator app, and stores it once a code of the app is entered;
|
||||||
|
the enrolment can be removed. The Passkeys column shows how many passkeys each user has, and
|
||||||
|
the remove icon removes all of them after a confirmation. The Console column shows the console
|
||||||
|
login of each member of `admins`: off, the user's own LDAP POSIX account with its uid, or the
|
||||||
|
local account it runs as, marked as refused while the user is not enrolled for TOTP; root
|
||||||
|
switches it on the router with `vpn-router-user console`, the Manager does not.
|
||||||
|
- **Groups**: the groups with their descriptions and members, searched by name, description and
|
||||||
|
member, with adding a group, editing its
|
||||||
|
name, description and members in one dialog, and removing it. A group has at least one member;
|
||||||
|
the members offered are the users and the service accounts but the internal ones.
|
||||||
|
- **Service Accounts**: the accounts other systems sign in with to use the router's services, such
|
||||||
|
as an OpenTelemetry Collector reading `/metrics`, from the router's `/service-accounts`; they
|
||||||
|
cannot sign in to the Manager. Each is shown with its display name, description, kind, e-mail
|
||||||
|
address, OpenID Provider settings and state, searched by name, display name, description
|
||||||
|
and e-mail address, with adding one (name, a new GUID unless a user name replaces it, display
|
||||||
|
name, description, an optional e-mail address, the OpenID Provider settings and a password),
|
||||||
|
editing the display name, the description, the e-mail address and the OpenID Provider settings,
|
||||||
|
setting the password, disabling, enabling, unlocking after failed logins and removing after a
|
||||||
|
confirmation. An account with redirect URIs or grants is a client of the router's OpenID
|
||||||
|
Provider, its name the client ID and its password the secret; its settings are the redirect URIs
|
||||||
|
and the post-logout redirect URIs, one per line, the allowed groups, chosen from the router's
|
||||||
|
groups, whose members alone may sign in to it, the client credentials and device authorization
|
||||||
|
grants, whether it is a public client without a secret, and the scopes of resources it may ask
|
||||||
|
for. Every account is also a resource of the provider, with its scopes and its roles, each
|
||||||
|
assigned to groups, users and service accounts, which the tokens for it carry. While the
|
||||||
|
provider is enabled, the title of the list has the number of its clients.
|
||||||
|
Internal accounts, which the router creates and manages for its own services with all their
|
||||||
|
properties, display name and description included (at present the metrics service's), are
|
||||||
|
marked Internal and only listed: the router refuses every change of them through its API, so
|
||||||
|
they have no actions here.
|
||||||
|
- **Connectivity**, Connections tab: the router's IPsec site-to-site connections with their remote peer, remote
|
||||||
|
identity, remote and local CIDRs, local identity mode, authentication (whether the pre-shared key
|
||||||
|
is set, read from a file or missing, or, with certificates, whether the peer's CA is the router's
|
||||||
|
site-to-site CA or a remote CA) and whether the tunnel is established; with adding a connection,
|
||||||
|
editing it and removing it after a confirmation. With a pre-shared key, the key is typed as a
|
||||||
|
password, required while the connection has none and kept when left empty, and is never shown;
|
||||||
|
with certificates, the PEM certificate of a remote CA is optional. The router applies a change in the
|
||||||
|
background by running its setup again; the list is read again after each change. The
|
||||||
|
connection the router was installed with is `remote-site`; Cloud Manager names the one it
|
||||||
|
deploys with `router_site_to_site_name`, `remote-site` by default.
|
||||||
|
- **Connectivity**, Status tab: each connection the router set up, with the local and the
|
||||||
|
remote identity and the subnets it resolved, and how long the connection has been up, and their
|
||||||
|
IPsec security associations, child SAs, connections and connection children, from the router's
|
||||||
|
`/config`. The other IPsec connections, IKEv2 access, are on the IKEv2 tab of Remote Access.
|
||||||
|
- **Connectivity**, Network tab: the external and the internal interface with their addresses, the
|
||||||
|
internal gateway and the local CIDRs, and the IPv4 routes, from the router's `/config`; the panels
|
||||||
|
fold, hide, move and change width, and the browser keeps their layout.
|
||||||
|
- **Remote Access** (`/remote-access-manager`): a tab each for IKEv2,
|
||||||
|
OpenVPN and WireGuard access, from the router's `/remote-access` and `/status/full`: the
|
||||||
|
settings, whether the router runs the system, why an enabled one does not run yet, and the
|
||||||
|
connected clients; for IKEv2 the IPsec security associations, child SAs, connections and
|
||||||
|
connection children of IKEv2 access, all but the site-to-site ones, from the router's `/config`;
|
||||||
|
for WireGuard each connection with its user, found by the peer's key (a short
|
||||||
|
key for a peer added to `wg0.conf` by hand), its IPs, endpoint, handshake and traffic, from the
|
||||||
|
router's `/config` and `/users`. Edit enables or
|
||||||
|
disables the system and sets its address pool, for WireGuard the address of `wg0`, for IKEv2 the
|
||||||
|
DNS domains its clients resolve with the router's DNS, one per line, and the port, with OpenVPN's
|
||||||
|
protocol; the router applies a change in the background. The WireGuard tab also
|
||||||
|
lists the peers of the VPN users, each a device with its user, public key and IPs: a peer is
|
||||||
|
removed with its button after a confirmation, and added with Add peer, whose dialog takes the
|
||||||
|
user, the device's public key and the IPs, which start as the next free address of the `wg0`
|
||||||
|
network. The router keeps the peers with their users and checks that keys are unique and
|
||||||
|
IPs do not overlap. Its Server section shows the router's public key, with a copy button, the
|
||||||
|
endpoint and the allowed IPs a device needs; Show config opens a device's configuration, ready to
|
||||||
|
copy, with placeholders for the device's private key and IPs and, while it runs, the router's
|
||||||
|
resolver as the device's DNS server. The eye of a peer opens its device's
|
||||||
|
configuration the same way, with the peer's IPs as its address. In both dialogs a switch leaves
|
||||||
|
out the `[Interface]` and `PrivateKey` lines, which WireGuard for Windows fills in for a new empty
|
||||||
|
tunnel, so the rest pastes below them; the browser keeps its position with its other settings.
|
||||||
|
Each peer's configuration downloads as
|
||||||
|
`<user>-<n>.conf` with them filled in and a placeholder for the device's private key, which the
|
||||||
|
router never has. IKEv2 and OpenVPN also need `wan.local_fqdn` and the
|
||||||
|
router's VPN server certificate. A cloud firewall in front of the router, such as an Azure
|
||||||
|
network security group, must allow the OpenVPN and WireGuard ports itself.
|
||||||
|
- **Web Sites** (`/sites-manager`): the static web sites the router's Caddy serves, from its
|
||||||
|
`/sites` and `/dns`, in tabs:
|
||||||
|
- **Sites:** the Settings panel shows whether the sites are served, the largest upload and
|
||||||
|
WebDAV with its URL and a copy icon, edited with Settings. The list shows each site with its
|
||||||
|
URLs, linked, its port, the interfaces whose firewall opens it, its TLS (none, Let's Encrypt,
|
||||||
|
an issuing CA of the router or a certificate and key) and its editors group, searched by name,
|
||||||
|
host name and group; Add and Edit take the name, fixed once added, the host names, one per
|
||||||
|
line, the port, the interfaces, the TLS with the issuing CA or the PEM certificate chain and
|
||||||
|
key, pasted or read from files, the key kept when left empty, the index file, the single-page
|
||||||
|
fallback and the editors group. Enable, Disable and Remove, which removes the site's folder too
|
||||||
|
when asked; the folder icon opens the site on the Files tab.
|
||||||
|
- **Files:** the files of the chosen site, a folder at a time, with the path linked up to the
|
||||||
|
site's folder. Upload sends chosen files, or files dropped on the list, into the folder shown,
|
||||||
|
with a progress bar; while Extract zip archives is on, a `.zip` file is extracted there
|
||||||
|
instead. New folder, Rename, Remove and Download. A file larger than the router's largest
|
||||||
|
upload is not sent.
|
||||||
|
- **DNS:** the router's primary name server: its settings with whether PowerDNS is installed
|
||||||
|
and runs, the router's FQDN as the primary name server, edited with Settings (enabled,
|
||||||
|
secondary name server, public IP address, interfaces, TTL); the domains, added with Add
|
||||||
|
domain (domain, TTL), edited and removed with their icons; and for the selected domain the
|
||||||
|
records it serves, and its manual records, added with Add record (name, type, content and
|
||||||
|
TTL) and removed with their icon.
|
||||||
|
- **Docker** (`/docker-manager`): whether Docker is installed, the state of `docker.service` and of
|
||||||
|
the router's Docker rules, the ports exposed on the external interface and the rules of the
|
||||||
|
`VPN-ROUTER-DOCKER` chain, from the router's `/status/full` and `/config`.
|
||||||
|
- **Shell** (`/shell`, the terminal icon in the header): a login shell on the router in xterm.js, the terminal of VS Code, as the
|
||||||
|
signed-in user's console account, the user's own LDAP POSIX account or a local account. Opening
|
||||||
|
it asks for the password and the code again, or Open with a passkey takes one of the user's
|
||||||
|
passkeys, unlocked with the device's PIN or biometrics, unless the user used a shell within the
|
||||||
|
router's `web.shell_auth_timeout` minutes. A user without console login sees how root switches it on,
|
||||||
|
and one not enrolled for one-time passwords that the shell needs the enrolment. The router offers the shell only while its `web.shell` is `true`, set
|
||||||
|
on the router with `vpn-config-edit`, and closes it after `web.shell_idle_minutes` without a
|
||||||
|
keystroke; the page then shows the reason and keeps the terminal readable.
|
||||||
|
- **Editor:** `vre FILE...` (`vpn-router-editor`) in the shell opens the files in a Monaco editor on the page, one tab per file, and returns at once. The router names each file in a text message of the shell's WebSocket, and the page reads and saves it through `/api/shell/editor/<session>/<id>`, as the console account. Saves have a body limit of 2 MiB plus 64 KiB, for a file of up to 1 MiB escaped in JSON; other calls keep 64 KiB.
|
||||||
|
- **Editor icon:** while a file is open, an icon left of the shell icon switches the page between the terminal and the editor, and the shell keeps running. A button left of Save shows or hides the minimap, remembered by the browser. Save or Ctrl+S writes the file; a file changed on the router since it was read offers Reload or Overwrite. Closing a tab with unsaved changes asks first, and the icon goes when the last tab closes or the shell ends.
|
||||||
|
- **Loading:** Monaco is bundled with the page, without its language services, and loaded only when the first file opens.
|
||||||
|
- **Backup**: the router's backups, from its `/backup`:
|
||||||
|
- **Settings:** whether scheduled backups run, the schedule (hourly, daily or weekly), the target and whether the metrics database is included. For a local folder it also shows the folder, the archives kept and the cloud copy with rclone; for a Proxmox Backup Server, the repository, whether the token secret is set, the fingerprint, the namespace and the encryption key file.
|
||||||
|
- **Last run:** its result, times, archive or snapshot, size, cloud copy and error, and the next run.
|
||||||
|
- **Local archives:** with their times and sizes, searched by name and time, and shown 10, 25, 50 or 100 at a time.
|
||||||
|
- **Edit:** changes the settings in one dialog. Only the retention settings that apply to the schedule are offered. The rclone remote is chosen from the remotes of the router's `/etc/vpn-router/rclone.conf`, with a path and the mode, sync or copy. The token secret is typed as a password and kept when left empty. The router applies a change in the background.
|
||||||
|
- **Run now:** starts a backup, and the tab reads every 3 seconds until it ends.
|
||||||
|
- **Warnings:** shown when the PBS client or rclone the settings need is not installed on the router.
|
||||||
|
|
||||||
|
Restoring is done on the router with `vpn-router-backup restore`. The Overview tab has a Backup panel with the result of the last run, the next run and Run now.
|
||||||
|
- **System**: the router's operating system. The System updates panel lists the packages `apt-get upgrade` would upgrade, each with its installed and its new version, from `/api/system-updates`; Check update is always in its top right corner, and Upgrade is added beside it when a package can be upgraded. Upgrade runs `apt-get upgrade -y` after a confirmation and shows its output in a dialog, as Upgrade on the Overview tab does, with Reload page and Reboot when they apply.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
`vpn-router-manager.service` runs `/usr/lib/vpn-router/manager`, a Python server that
|
||||||
|
calls the router with `python3-requests` and keeps sessions in PostgreSQL with `python3-psycopg`.
|
||||||
|
It serves the application from `/usr/share/vpn-router/manager/web`.
|
||||||
|
|
||||||
|
- `POST /api/session` with `{"name", "password", "code"}` signs in through the router's `/auth`,
|
||||||
|
passing the browser's address in `X-Forwarded-For`; on success the manager keeps the user's
|
||||||
|
access and refresh tokens in the table `sessions` and sets the cookie
|
||||||
|
`vpn_router_manager_session` (`HttpOnly`, `SameSite=Strict`), which holds a random id of which
|
||||||
|
only the SHA-256 is stored. `GET /api/session` answers `{"name"}` of the signed-in user, and
|
||||||
|
`DELETE /api/session` signs out and revokes the refresh token.
|
||||||
|
- Every other call to `/api/<path>` needs a session and is passed to the router's status web
|
||||||
|
service at `/<path>` with the session's access token, renewed through `/auth/refresh` when it
|
||||||
|
expires within 30 seconds. The method, the body and the router's answer pass unchanged. A
|
||||||
|
session ends when the router refuses to renew it; sessions are kept across restarts.
|
||||||
|
- `GET`, `HEAD` and `PUT` of a web site's files, `/api/sites/<name>/files/<path>`, pass streamed:
|
||||||
|
the body of a `PUT` goes to the router as it arrives, with its `Content-Type` and
|
||||||
|
`Content-Length`, which it needs, and without the limit of 65536 bytes, and the router's answer
|
||||||
|
comes back with its `Content-Type`, `Content-Length` and `Content-Disposition`, so a download is
|
||||||
|
not held in memory either. The router's `sites.upload_max_mb` limits an upload.
|
||||||
|
- `GET /docs/<file>`, with a session, gives a file of the help from
|
||||||
|
`/usr/share/vpn-router/manager/docs`, which the package installs from the repository's `docs/`:
|
||||||
|
Markdown (`text/markdown`), SVG or PNG; any other file, or a path outside the folder, is 404.
|
||||||
|
The answers carry `X-Content-Type-Options: nosniff` and a `Content-Security-Policy` that keeps
|
||||||
|
an SVG opened on its own from running scripts.
|
||||||
|
- A WebSocket handshake on `/api/shell`, with a session and an `Origin` of the page's own host,
|
||||||
|
is passed to the router's `/shell` with the session's access token; after both handshakes the
|
||||||
|
manager relays the WebSocket's bytes both ways until either side closes. `POST /api/shell`, which
|
||||||
|
gives the ticket the handshake needs, and `POST /api/shell/passkey`, which begins the passkey
|
||||||
|
proof it may take, pass as every other call does.
|
||||||
|
- `/api/info` gives the version of the package and the URL of the router's status page, without a
|
||||||
|
session. On the router, while `router_url` and `terraform_dir` are not set and the status web
|
||||||
|
service listens on the address the call came in on, the URL has the name or address the browser
|
||||||
|
used and `web.port`, for example `http://router.example.com:7081`, and for a page opened over
|
||||||
|
the router's HTTPS proxy `https` and `https.web_port`, for example
|
||||||
|
`https://router.example.com:7444`; otherwise it is `router_url`.
|
||||||
|
- `/api/updates`, with a session, gives the installed and the candidate version of `vpn-router`
|
||||||
|
from `apt-cache policy`, that is from the repository apt is configured with,
|
||||||
|
as of apt's last update of its package lists (`/var/lib/apt/periodic/update-success-stamp`,
|
||||||
|
answered as `checked`), whether an update is available, and as `checking` whether a check for
|
||||||
|
updates runs. The Overview tab shows it at the top of its Status panel. Where apt cannot be asked, such as
|
||||||
|
on a workstation, it answers `"available": false`.
|
||||||
|
- `POST /api/updates`, by a member of `admins`, checks for updates: it runs `apt-get update` in the
|
||||||
|
transient unit `vpn-router-manager-check` outside the manager's unit, waiting up to 5 minutes for
|
||||||
|
apt's lock, and touches `update-success-stamp` when it succeeds. The page's Check update button
|
||||||
|
starts it and reads the versions again until it ends.
|
||||||
|
It answers 202 when started, 409 while a check, an upgrade or a reboot runs, 403 for a user who
|
||||||
|
is not an admin, and 503 where apt is missing.
|
||||||
|
- `/api/system-updates`, with a session, gives the packages `apt-get upgrade` would upgrade, from
|
||||||
|
its simulation (`apt-get -s upgrade`), each with its installed and its candidate version, in the
|
||||||
|
shape of `/api/updates`. The System tab shows it; Check update there is `POST /api/updates`.
|
||||||
|
- `POST /api/upgrade`, by a member of `admins` (the router's `/account` decides), upgrades
|
||||||
|
`vpn-router`; the page's Upgrade button starts it.
|
||||||
|
- **Where it runs:** the helper `/usr/lib/vpn-router/upgrade`, in the transient unit `vpn-router-manager-upgrade` (`systemd-run`) outside the manager's unit. The manager's own restart and the router web service's restart during the upgrade do not end it.
|
||||||
|
- **What it does:** first `dpkg --configure -a`, then `apt-get install --only-upgrade`. It asks nothing, keeps changed configuration files, and waits up to 5 minutes for the dpkg lock.
|
||||||
|
- **Record:** the state goes to `/var/lib/vpn-router/upgrade.json` and the output to `upgrade.log`.
|
||||||
|
- **Answers:** 202 when started, 409 while an upgrade or a reboot runs, 403 for a user who is not an admin, 503 where apt is missing.
|
||||||
|
- `GET /api/upgrade`, with a session, gives that state and the last 200 lines of the output.
|
||||||
|
- **States:** `idle`, `running`, `succeeded`, `failed`, or `interrupted` when the upgrade ended without recording a result.
|
||||||
|
- **Reboot information:** whether `/var/run/reboot-required` exists, the packages that ask for it, and whether a reboot is about to happen.
|
||||||
|
- `POST /api/system-upgrade`, by a member of `admins`, upgrades the system's packages, and `GET /api/system-upgrade` gives its state, as `/api/upgrade` does for `vpn-router`.
|
||||||
|
- **What it does:** the helper with `--system`, in the transient unit `vpn-router-manager-system-upgrade`: first `dpkg --configure -a`, then `apt-get upgrade -y`, with the same options.
|
||||||
|
- **Record:** `/var/lib/vpn-router/system-upgrade.json` and `system-upgrade.log`.
|
||||||
|
- **Answers:** as those of `/api/upgrade`; only one of the two upgrades runs at a time, and either answers 409 while the other runs.
|
||||||
|
- `POST /api/reboot`, by a member of `admins`, reboots the router 5 seconds later (the transient timer `vpn-router-manager-reboot`), and answers 409 while an upgrade runs. The page offers it when a reboot is required.
|
||||||
|
|
||||||
|
| Answer | When |
|
||||||
|
|--------|------|
|
||||||
|
| 401 | Not signed in, or the session has ended |
|
||||||
|
| 403 | The router refuses the user, who is not a member of `admins` |
|
||||||
|
| 502 | The router cannot be reached |
|
||||||
|
| 503 | No router or no session database is configured, or the database fails |
|
||||||
|
| 413 | The request body is larger than 65536 bytes, but for the upload of a site's file |
|
||||||
|
| 411 | The upload of a site's file has no `Content-Length` |
|
||||||
|
|
||||||
|
The router's rate limit of sign-ins counts the browser's address when the manager's address is
|
||||||
|
in the router's `web.trusted_proxies`; by default that is the router's address the call came in
|
||||||
|
on, which is the address a manager on the router calls from, so it needs nothing more.
|
||||||
|
|
||||||
|
On the router, the router's Caddy serves the manager over HTTPS on `https.manager_port` of
|
||||||
|
`vpn-router.json` (7443 by default) for `wan.local_fqdn`, with a certificate from Let's Encrypt,
|
||||||
|
and calls it on `127.0.0.1`, where the manager also listens. For a call from `127.0.0.1` with
|
||||||
|
`X-Forwarded-For`, the manager takes the browser's address from the last entry of that header.
|
||||||
|
|
||||||
|
The service needs nothing else to start and keeps running. Every 30 seconds it works out where to
|
||||||
|
listen and starts, moves or stops a server on each address to match, so a change of `listen`,
|
||||||
|
`bind` or `port` takes effect within 30 seconds, or when the service restarts. Each request
|
||||||
|
reads the settings again.
|
||||||
|
|
||||||
|
Before it listens, it creates its sessions database on the router when it is missing and connects
|
||||||
|
to the sessions database, retrying every 15 seconds and logging each failure, for example while
|
||||||
|
PostgreSQL is not running yet.
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
`/etc/vpn-router/manager.json` (all optional; see
|
||||||
|
`/usr/share/doc/vpn-router/manager.json.example`):
|
||||||
|
|
||||||
|
| Setting | Meaning | Default |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| `router_url` | The router's status web service | `http://<first address of the web service>:<web.port>`, the internal address when it listens on both |
|
||||||
|
| `database` | A PostgreSQL connection string for the sessions | The `vpn-router-manager` database on the router |
|
||||||
|
| `listen` | The interfaces to listen on when `bind` is not set: `internal`, `external` or `both`, and always `127.0.0.1` | `both` |
|
||||||
|
| `bind` | The one address to listen on; the router's HTTPS proxy reaches the manager only when it is `127.0.0.1` | none, `listen` applies |
|
||||||
|
| `port` | The port to listen on, HTTP | `7080` |
|
||||||
|
| `terraform_dir` | A Terraform root directory whose outputs give `router_url` when it is not set; `dev.sh` sets it | none |
|
||||||
|
| `iac_tool` | The tool that reads the outputs of `terraform_dir`: `terraform` or `tofu` | `terraform` |
|
||||||
|
|
||||||
|
From the outputs of `<iac_tool> -chdir=<terraform_dir> output -json`, read every 30 seconds at
|
||||||
|
most, `router_url` is the output `router_web_url`, the status web service's URL on the router's
|
||||||
|
private address with its port. It needs that tool, the
|
||||||
|
one the root directory was initialized with, and access to the state.
|
||||||
|
|
||||||
|
The addresses
|
||||||
|
of the interfaces `listen` names are worked out as `general.mode` directs, as the status web
|
||||||
|
service does, and `vpn-router-setup` opens `port` on the internal and the external interface;
|
||||||
|
restart `vpn-router-setup` after changing `port`. When `database` is not set, the manager creates the role and the database
|
||||||
|
`vpn-router-manager` on the router's PostgreSQL on its first run, with a password in
|
||||||
|
`/etc/vpn-router/manager-database.secret` (root only), and reaches it over TCP on `localhost`.
|
||||||
|
Make `manager.json` readable by root only when its `database` holds a password.
|
||||||
|
|
||||||
|
#### First administrator
|
||||||
|
|
||||||
|
The package asks two debconf questions on a fresh installation, `vpn-router/admin_username`
|
||||||
|
and `vpn-router/admin_password`. With both answered, it writes them to
|
||||||
|
`/etc/vpn-router/manager-admin.json` (root only) and clears them from debconf;
|
||||||
|
`vpn-router-setup` creates that user as a Manager-only account in the group `admins`, creating
|
||||||
|
the group when missing, and removes the file. Without a user name no account and no group is
|
||||||
|
created. They can be preseeded:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
echo 'vpn-router vpn-router/admin_username string admin' | debconf-set-selections
|
||||||
|
echo 'vpn-router vpn-router/admin_password password s3cr3t-pass' | debconf-set-selections
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running the application on a workstation
|
||||||
|
|
||||||
|
`dev.sh` runs the manager and the Vite development server, which reloads on changes, on the
|
||||||
|
workstation; the application is at http://localhost:5180. Its first argument is the
|
||||||
|
`config.json` of the platform that deployed the router. Its `module_root`, relative to the
|
||||||
|
directory of that file, is the Terraform root module whose outputs give the router's address.
|
||||||
|
The second argument, optional, is the connection string of a PostgreSQL database for the
|
||||||
|
sessions; without it the sessions are kept in the database `PGDATABASE` names, or the one named
|
||||||
|
after the login, and libpq takes the server, port, user, password and SSL mode from the `PG*`
|
||||||
|
variables of the environment, such as those of the platform's `pg.env.sh`. `dev.sh` writes them
|
||||||
|
as `terraform_dir` and `database`, with the platform's `iac_tool` and `bind` `127.0.0.1`, to `dev.json` in the repository root, which is not in Git, and
|
||||||
|
the manager reads that and listens on `127.0.0.1:7080`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./dev.sh /path/to/azure-platform/config.json
|
||||||
|
./dev.sh /path/to/azure-platform/config.json 'host=localhost dbname=manager user=manager password=secret'
|
||||||
|
```
|
||||||
|
|
||||||
|
It creates `.venv` with `psycopg[binary]` and `requests` when they are missing, and installs the npm dependencies
|
||||||
|
when they are missing or out of date. The manager also takes `--config <file>` in place of
|
||||||
|
`/etc/vpn-router/manager.json`.
|
||||||
|
|
||||||
|
The Vite servers pass `/api` to `VITE_MANAGER` (`http://127.0.0.1:7080` by default), so the built
|
||||||
|
application runs against any manager, at http://localhost:4180:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
VITE_MANAGER=http://10.0.0.4:7080 npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
## Removal
|
## Removal
|
||||||
|
|
||||||
@@ -2041,8 +2453,9 @@ may be in use by something else.
|
|||||||
`apt-get purge` additionally removes the generated files and the firewall rules, restoring
|
`apt-get purge` additionally removes the generated files and the firewall rules, restoring
|
||||||
`/etc/ufw/before.rules` to its original content, and hands `vpn-router.json` back to `ucf`
|
`/etc/ufw/before.rules` to its original content, and hands `vpn-router.json` back to `ucf`
|
||||||
before deleting it so a later reinstall starts clean. It also removes the web service's
|
before deleting it so a later reinstall starts clean. It also removes the web service's
|
||||||
signing key and the OpenID Provider's, the password file of the metrics service account, the database of the refresh
|
signing key and the OpenID Provider's, the password file of the metrics service account, the Router Manager's
|
||||||
tokens and the metrics and those of the web sites and the name server, their roles and their password
|
settings and the record of its last upgrades, the database of the refresh
|
||||||
|
tokens and the metrics and those of the web sites, the name server and the Router Manager's sessions, their roles and their password
|
||||||
files when the package created them, Caddy's configuration `/etc/caddy/vpn-router.Caddyfile` and PowerDNS's
|
files when the package created them, Caddy's configuration `/etc/caddy/vpn-router.Caddyfile` and PowerDNS's
|
||||||
`/etc/powerdns/pdns.d/vpn-router.conf`; the certificate in
|
`/etc/powerdns/pdns.d/vpn-router.conf`; the certificate in
|
||||||
`/var/lib/caddy` belongs to the `caddy` package. It removes the backup timer's schedule, the
|
`/var/lib/caddy` belongs to the `caddy` package. It removes the backup timer's schedule, the
|
||||||
@@ -2057,11 +2470,19 @@ key pair, everything under `/etc/swanctl`, the folders of the web sites in `/var
|
|||||||
## Building
|
## Building
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd debian-package
|
|
||||||
./build.sh
|
./build.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The build runs in a container and writes the package to `debian-package/out/`.
|
The build runs in a container from the repository root: a Node.js stage builds the Router
|
||||||
`./publish.sh` uploads it to the component `main` of the distributions `noble` and `resolute` of
|
Manager's application in `web/` with Vite, and an Ubuntu stage builds the package from
|
||||||
the configured repository, skipping a distribution that has the version already;
|
`debian-package/`, with the application and the help in `docs/`, and writes it to `out/`. The
|
||||||
|
container runtime is Apple's `container` on macOS and Docker on Linux, or `CONTAINER_RUNTIME`
|
||||||
|
when set, for example Docker on a remote engine:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
CONTAINER_RUNTIME=docker DOCKER_CONTEXT=build-host ./build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`./publish.sh` uploads the package to the component `main` of the distributions `noble` and
|
||||||
|
`resolute` of the configured repository, skipping a distribution that has the version already;
|
||||||
`DISTRIBUTIONS` names others.
|
`DISTRIBUTIONS` names others.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# CONTAINER_RUNTIME from the environment, for example docker with DOCKER_CONTEXT naming a remote engine;
|
||||||
|
# otherwise Apple container CLI on macOS and Docker CE on Linux.
|
||||||
|
if [ -z "${CONTAINER_RUNTIME:-}" ]; then
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin) CONTAINER_RUNTIME=container ;;
|
||||||
|
Linux) CONTAINER_RUNTIME=docker ;;
|
||||||
|
*) printf 'ERROR: unsupported platform %s\n' "$(uname -s)" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p out
|
||||||
|
|
||||||
|
$CONTAINER_RUNTIME build --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) -t vpn-router-builder "$@" .
|
||||||
|
# Artifacts are streamed out as a tar archive, so no bind mount is needed.
|
||||||
|
$CONTAINER_RUNTIME run --rm vpn-router-builder tar -cf - -C /out . | tar -xf - -C out
|
||||||
|
|
||||||
|
echo "Build artifacts written to out/"
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Apple container CLI on macOS, Docker CE on Linux.
|
|
||||||
case "$(uname -s)" in
|
|
||||||
Darwin) CONTAINER_RUNTIME=container ;;
|
|
||||||
Linux) CONTAINER_RUNTIME=docker ;;
|
|
||||||
*) printf 'ERROR: unsupported platform %s\n' "$(uname -s)" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
mkdir -p out
|
|
||||||
|
|
||||||
$CONTAINER_RUNTIME build --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) -t vpn-router-builder "$@" .
|
|
||||||
# Artifacts are streamed out as a tar archive, so no bind mount is needed.
|
|
||||||
$CONTAINER_RUNTIME run --rm vpn-router-builder tar -cf - -C /out . | tar -xf - -C out
|
|
||||||
|
|
||||||
echo "Build artifacts written to out/"
|
|
||||||
+230
-60
@@ -1,6 +1,97 @@
|
|||||||
|
vpn-router (0.40.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
feature: the package installs the Router Manager, which is no longer a
|
||||||
|
package of its own.
|
||||||
|
|
||||||
|
* The Router Manager, vpn-router-manager.service, is installed and started
|
||||||
|
with the router; its programs are in /usr/lib/vpn-router, its application
|
||||||
|
and help in /usr/share/vpn-router/manager.
|
||||||
|
* Its settings are /etc/vpn-router/manager.json, its database password
|
||||||
|
/etc/vpn-router/manager-database.secret and the state of its upgrades
|
||||||
|
/var/lib/vpn-router/upgrade.json and system-upgrade.json.
|
||||||
|
* The debconf questions vpn-router/admin_username and admin_password give
|
||||||
|
its first administrator, written to /etc/vpn-router/manager-admin.json.
|
||||||
|
* The Overview tab and the upgrade helper know the package vpn-router
|
||||||
|
alone.
|
||||||
|
* The bootstrap page on port 17081 shows the manager's service and log;
|
||||||
|
port 17080 and vpn-router-manager-bootstrap are gone.
|
||||||
|
* The status has no manager_version; the manager's unit is always among
|
||||||
|
the services.
|
||||||
|
* The package cannot be installed beside the package vpn-router-manager,
|
||||||
|
which has to be removed first.
|
||||||
|
* A purge also removes the manager's settings, its database and role, and
|
||||||
|
the record of its upgrades.
|
||||||
|
* build.sh, publish.sh and the Dockerfile are in the repository root, the
|
||||||
|
build also builds the application in web/, and CONTAINER_RUNTIME chooses
|
||||||
|
the container runtime.
|
||||||
|
* The README and the ADR call the product that deploys the router Cloud
|
||||||
|
Manager, also where they named manage-ui, and the docstring of the GCP
|
||||||
|
platform names Google Cloud.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 13 Nov 2026 20:15:15 +0100
|
||||||
|
|
||||||
|
vpn-router (0.39.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
fix: the web service starts again after its first start with 0.39.0, and the
|
||||||
|
name server's domains can be changed from any process.
|
||||||
|
|
||||||
|
* The schemas of the web sites and of the name server can be applied any
|
||||||
|
number of times, so the web service no longer keeps retrying with
|
||||||
|
'function "site_put" already exists' and never listening, and
|
||||||
|
vpn-router-site and setup no longer fail the same way.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 06 Nov 2026 20:16:57 +0100
|
||||||
|
|
||||||
|
vpn-router (0.39.0-2) unstable; urgency=medium
|
||||||
|
|
||||||
|
update: vpn-router is published in the koszewscy package registry.
|
||||||
|
|
||||||
|
* publish.sh uploads to the owner koszewscy unless OWNER names another.
|
||||||
|
* repo_url of the Azure and GCP examples defaults to
|
||||||
|
https://gitea.koszewscy.waw.pl/api/packages/koszewscy/debian.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 06 Nov 2026 20:08:29 +0100
|
||||||
|
|
||||||
|
vpn-router (0.39.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
feature: the name server's domains have wan.local_fqdn and dns.secondary as
|
||||||
|
their name servers, and a site with dns router gets records of the router's
|
||||||
|
public, internal or external address.
|
||||||
|
|
||||||
|
* The SOA and the first NS of every domain are wan.local_fqdn, and the
|
||||||
|
second NS is dns.secondary, a host name, whose addresses are notified
|
||||||
|
of every change and may transfer the domains; dns.nameserver is gone.
|
||||||
|
* wan.local_fqdn gets no record in the domains; its record is kept in
|
||||||
|
the DNS of its own domain.
|
||||||
|
* A site has dns, user (the default), served elsewhere, or router, whose
|
||||||
|
host names get an A or AAAA record in their closest domain, with
|
||||||
|
dns_address public (wan.public_address, new), internal or external.
|
||||||
|
dns.addresses, the domains' site records switch and the per-domain
|
||||||
|
secondaries with their routes are gone; dns.nameserver and
|
||||||
|
dns.addresses of an earlier configuration are left out when read.
|
||||||
|
* A site may use wan.local_fqdn as a host name on a port of its own.
|
||||||
|
* GET /dns answers nameserver, wan.local_fqdn, and public_address,
|
||||||
|
which PUT /dns changes.
|
||||||
|
* The summaries of the changelog entries are wrapped at 80 columns, and
|
||||||
|
release.py joins the lines of a summary into the commit message.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 21:30:01 +0100
|
||||||
|
|
||||||
|
vpn-router (0.38.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
fix: /https/settings shows the https section and switches https.acme_staging,
|
||||||
|
for the Router Manager.
|
||||||
|
|
||||||
|
* GET /https/settings shows the https section, and PUT /https/settings
|
||||||
|
changes acme_staging; the other settings of the section stay with
|
||||||
|
vpn-config-edit.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 02:43:22 +0100
|
||||||
|
|
||||||
vpn-router (0.38.0-1) unstable; urgency=medium
|
vpn-router (0.38.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: https.acme_staging takes Caddy's certificates from Let's Encrypt's staging environment.
|
feature: https.acme_staging takes Caddy's certificates from Let's Encrypt's
|
||||||
|
staging environment.
|
||||||
|
|
||||||
* While https.acme_staging is true, Caddy's only CA is Let's Encrypt's
|
* While https.acme_staging is true, Caddy's only CA is Let's Encrypt's
|
||||||
staging environment, for the router's services and the web sites
|
staging environment, for the router's services and the web sites
|
||||||
@@ -10,7 +101,8 @@ vpn-router (0.38.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.37.3-1) unstable; urgency=medium
|
vpn-router (0.37.3-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: an answer whose client went away ends quietly, and a shell whose client left before it opened is stopped.
|
fix: an answer whose client went away ends quietly, and a shell whose client
|
||||||
|
left before it opened is stopped.
|
||||||
|
|
||||||
* The web service, the metrics service and the bootstrap server stop
|
* The web service, the metrics service and the bootstrap server stop
|
||||||
writing an answer when its client closes the connection, and a site
|
writing an answer when its client closes the connection, and a site
|
||||||
@@ -22,7 +114,9 @@ vpn-router (0.37.3-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.37.2-1) unstable; urgency=medium
|
vpn-router (0.37.2-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: the web service, the metrics service and the bootstrap server log a failure in one line instead of a traceback, and nothing when a client goes away.
|
fix: the web service, the metrics service and the bootstrap server log a
|
||||||
|
failure in one line instead of a traceback, and nothing when a client goes
|
||||||
|
away.
|
||||||
|
|
||||||
* A request whose client closed the connection is no longer logged.
|
* A request whose client closed the connection is no longer logged.
|
||||||
* Any other failure of a request, a thread or the service is logged in
|
* Any other failure of a request, a thread or the service is logged in
|
||||||
@@ -41,7 +135,9 @@ vpn-router (0.37.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.37.0-1) unstable; urgency=medium
|
vpn-router (0.37.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: the OpenID Provider has the client credentials and device authorization grants, public clients, and service accounts as resources with roles and scopes in JWT access tokens.
|
feature: the OpenID Provider has the client credentials and device
|
||||||
|
authorization grants, public clients, and service accounts as resources with
|
||||||
|
roles and scopes in JWT access tokens.
|
||||||
|
|
||||||
* A client may have the grants client_credentials, by which a
|
* A client may have the grants client_credentials, by which a
|
||||||
confidential client gets an access token as its own service account,
|
confidential client gets an access token as its own service account,
|
||||||
@@ -70,7 +166,8 @@ vpn-router (0.37.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.36.0-1) unstable; urgency=medium
|
vpn-router (0.36.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: a shell opens with one of the user's passkeys in place of the password and the TOTP code.
|
feature: a shell opens with one of the user's passkeys in place of the
|
||||||
|
password and the TOTP code.
|
||||||
|
|
||||||
* POST /shell/passkey begins a proof for one of the signed-in user's own
|
* POST /shell/passkey begins a proof for one of the signed-in user's own
|
||||||
passkeys, with user verification; POST /shell takes the browser's
|
passkeys, with user verification; POST /shell takes the browser's
|
||||||
@@ -84,7 +181,8 @@ vpn-router (0.36.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.35.2-2) unstable; urgency=medium
|
vpn-router (0.35.2-2) unstable; urgency=medium
|
||||||
|
|
||||||
update: the package is published for Ubuntu 26.04 (resolute) as well as 24.04 (noble).
|
update: the package is published for Ubuntu 26.04 (resolute) as well as 24.04
|
||||||
|
(noble).
|
||||||
|
|
||||||
* publish.sh uploads the package to the distributions noble and resolute
|
* publish.sh uploads the package to the distributions noble and resolute
|
||||||
of the repository, component main, skipping a distribution that has
|
of the repository, component main, skipping a distribution that has
|
||||||
@@ -95,7 +193,8 @@ vpn-router (0.35.2-2) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.35.2-1) unstable; urgency=medium
|
vpn-router (0.35.2-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: the web service answers /, /status and /status/full again while IKEv2 runs.
|
fix: the web service answers /, /status and /status/full again while IKEv2
|
||||||
|
runs.
|
||||||
|
|
||||||
* The status of IKEv2 clients is collected again; the requests that
|
* The status of IKEv2 clients is collected again; the requests that
|
||||||
collect the status were dropped with a NameError since 0.35.1.
|
collect the status were dropped with a NameError since 0.35.1.
|
||||||
@@ -104,7 +203,8 @@ vpn-router (0.35.2-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.35.1-1) unstable; urgency=medium
|
vpn-router (0.35.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
update: the router web service is split into the package vpnrouter_web, with one module per family of paths.
|
update: the router web service is split into the package vpnrouter_web, with
|
||||||
|
one module per family of paths.
|
||||||
|
|
||||||
* /usr/lib/vpn-router/web initializes and serves; the package
|
* /usr/lib/vpn-router/web initializes and serves; the package
|
||||||
/usr/lib/vpn-router/vpnrouter_web/ answers the requests, with a module
|
/usr/lib/vpn-router/vpnrouter_web/ answers the requests, with a module
|
||||||
@@ -116,7 +216,8 @@ vpn-router (0.35.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.35.0-1) unstable; urgency=medium
|
vpn-router (0.35.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: vre opens files from the Router Manager's shell in the editor of its Shell page.
|
feature: vre opens files from the Router Manager's shell in the editor of its
|
||||||
|
Shell page.
|
||||||
|
|
||||||
* vpn-router-editor FILE..., or vre, in the shell of the Router Manager
|
* vpn-router-editor FILE..., or vre, in the shell of the Router Manager
|
||||||
opens the files in the editor of the Shell page and returns at once;
|
opens the files in the editor of the Shell page and returns at once;
|
||||||
@@ -131,7 +232,9 @@ vpn-router (0.35.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.34.0-1) unstable; urgency=medium
|
vpn-router (0.34.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: the web service shows and changes the settings of the metrics service at /metrics/settings, and OpenVPN starts on a new router, as setup installs CRLs that revoke nothing.
|
feature: the web service shows and changes the settings of the metrics service
|
||||||
|
at /metrics/settings, and OpenVPN starts on a new router, as setup installs
|
||||||
|
CRLs that revoke nothing.
|
||||||
|
|
||||||
* GET and PUT /metrics/settings show and change the metrics section;
|
* GET and PUT /metrics/settings show and change the metrics section;
|
||||||
otlp_password is never shown, an empty one keeps it, and an empty
|
otlp_password is never shown, an empty one keeps it, and an empty
|
||||||
@@ -164,7 +267,8 @@ vpn-router (0.33.2-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.33.1-1) unstable; urgency=medium
|
vpn-router (0.33.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: the name server is the primary of any number of domains, each with secondary name servers and a switch for the records of the sites.
|
fix: the name server is the primary of any number of domains, each with
|
||||||
|
secondary name servers and a switch for the records of the sites.
|
||||||
|
|
||||||
* The domains, their secondaries and manual records are kept in the
|
* The domains, their secondaries and manual records are kept in the
|
||||||
vpn-router-dns database and changed through /dns/domains; dns.zone is
|
vpn-router-dns database and changed through /dns/domains; dns.zone is
|
||||||
@@ -182,7 +286,8 @@ vpn-router (0.33.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.33.0-1) unstable; urgency=medium
|
vpn-router (0.33.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: road-warrior clients resolve the domains of the new setting p2s.dns_domains with the router's resolver.
|
feature: road-warrior clients resolve the domains of the new setting
|
||||||
|
p2s.dns_domains with the router's resolver.
|
||||||
|
|
||||||
* p2s.dns_domains lists up to 16 domains; setup pushes them to IKEv2
|
* p2s.dns_domains lists up to 16 domains; setup pushes them to IKEv2
|
||||||
clients as INTERNAL_DNS_DOMAIN (RFC 8598, attribute 25) of the
|
clients as INTERNAL_DNS_DOMAIN (RFC 8598, attribute 25) of the
|
||||||
@@ -194,7 +299,8 @@ vpn-router (0.33.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.32.1-1) unstable; urgency=medium
|
vpn-router (0.32.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: a VPN user downloads the own OpenVPN client profile through /account, with the current password and code.
|
fix: a VPN user downloads the own OpenVPN client profile through /account,
|
||||||
|
with the current password and code.
|
||||||
|
|
||||||
* POST /account/profile answers the user's OpenVPN client profile; it
|
* POST /account/profile answers the user's OpenVPN client profile; it
|
||||||
needs the current password and, when enrolled, a code.
|
needs the current password and, when enrolled, a code.
|
||||||
@@ -203,7 +309,8 @@ vpn-router (0.32.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.32.0-1) unstable; urgency=medium
|
vpn-router (0.32.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: the web service gives Apple VPN profiles that set up IKEv2 road-warrior access on macOS, iOS and iPadOS.
|
feature: the web service gives Apple VPN profiles that set up IKEv2
|
||||||
|
road-warrior access on macOS, iOS and iPadOS.
|
||||||
|
|
||||||
* POST /users/<name>/apple-profile and POST /account/apple-profile answer
|
* POST /users/<name>/apple-profile and POST /account/apple-profile answer
|
||||||
an unsigned .mobileconfig with the P2S CA and the root CA, the user's
|
an unsigned .mobileconfig with the P2S CA and the root CA, the user's
|
||||||
@@ -217,7 +324,9 @@ vpn-router (0.32.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.31.3-1) unstable; urgency=medium
|
vpn-router (0.31.3-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: WireGuard clients may use the router's DNS resolver, which runs while WireGuard does, and /remote-access names its addresses for their configuration.
|
fix: WireGuard clients may use the router's DNS resolver, which runs while
|
||||||
|
WireGuard does, and /remote-access names its addresses for their
|
||||||
|
configuration.
|
||||||
|
|
||||||
* The firewall opens port 53 of the remote access clients' resolver for
|
* The firewall opens port 53 of the remote access clients' resolver for
|
||||||
the WireGuard network, as it does for the IKEv2 and OpenVPN pools.
|
the WireGuard network, as it does for the IKEv2 and OpenVPN pools.
|
||||||
@@ -231,7 +340,8 @@ vpn-router (0.31.3-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.31.2-1) unstable; urgency=medium
|
vpn-router (0.31.2-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: dnsmasq answers the remote access clients' DNS queries, so they no longer get the router's own host name with all its addresses from systemd-resolved.
|
fix: dnsmasq answers the remote access clients' DNS queries, so they no longer
|
||||||
|
get the router's own host name with all its addresses from systemd-resolved.
|
||||||
|
|
||||||
* vpn-router-resolver.service runs dnsmasq on the addresses of
|
* vpn-router-resolver.service runs dnsmasq on the addresses of
|
||||||
p2s.dns_listen, forwarding to systemd-resolved's stub on 127.0.0.53;
|
p2s.dns_listen, forwarding to systemd-resolved's stub on 127.0.0.53;
|
||||||
@@ -246,7 +356,9 @@ vpn-router (0.31.2-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.31.1-1) unstable; urgency=medium
|
vpn-router (0.31.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: the remote access clients' resolver answers on the internal address by default, chosen by p2s.dns_listen, so the name server can take port 53 of the external addresses.
|
fix: the remote access clients' resolver answers on the internal address by
|
||||||
|
default, chosen by p2s.dns_listen, so the name server can take port 53 of the
|
||||||
|
external addresses.
|
||||||
|
|
||||||
* The new setting p2s.dns_listen chooses the addresses of the resolver the
|
* The new setting p2s.dns_listen chooses the addresses of the resolver the
|
||||||
road-warrior and OpenVPN clients are given: internal, the default,
|
road-warrior and OpenVPN clients are given: internal, the default,
|
||||||
@@ -259,7 +371,9 @@ vpn-router (0.31.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.31.0-1) unstable; urgency=medium
|
vpn-router (0.31.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: Caddy serves static web sites from folders on the router, defined in their own database, with files uploaded through the web service or WebDAV, and an optional PowerDNS name server answers for a zone of the sites.
|
feature: Caddy serves static web sites from folders on the router, defined in
|
||||||
|
their own database, with files uploaded through the web service or WebDAV, and
|
||||||
|
an optional PowerDNS name server answers for a zone of the sites.
|
||||||
|
|
||||||
* The new database vpn-router-sites holds the web sites: host names,
|
* The new database vpn-router-sites holds the web sites: host names,
|
||||||
port, interfaces, TLS (none, Let's Encrypt, an issuing CA or a
|
port, interfaces, TLS (none, Let's Encrypt, an issuing CA or a
|
||||||
@@ -295,7 +409,8 @@ vpn-router (0.31.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.30.0-1) unstable; urgency=medium
|
vpn-router (0.30.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: the web service serves the last lines of the journal of the router's units and of the log files web.log_files lists, at /logs.
|
feature: the web service serves the last lines of the journal of the router's
|
||||||
|
units and of the log files web.log_files lists, at /logs.
|
||||||
|
|
||||||
* GET /logs lists the units whose journal and the files whose lines it
|
* GET /logs lists the units whose journal and the files whose lines it
|
||||||
serves; GET /logs/journal?unit=&lines= and /logs/file?path=&lines=
|
serves; GET /logs/journal?unit=&lines= and /logs/file?path=&lines=
|
||||||
@@ -308,7 +423,8 @@ vpn-router (0.30.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.29.0-1) unstable; urgency=medium
|
vpn-router (0.29.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: vpn-config-edit set and remove take --restart, which applies the change with vpn-router-setup and waits for it.
|
feature: vpn-config-edit set and remove take --restart, which applies the
|
||||||
|
change with vpn-router-setup and waits for it.
|
||||||
|
|
||||||
* vpn-config-edit set --restart and remove --restart restart
|
* vpn-config-edit set --restart and remove --restart restart
|
||||||
vpn-router-setup after writing the configuration and wait until it has
|
vpn-router-setup after writing the configuration and wait until it has
|
||||||
@@ -319,7 +435,8 @@ vpn-router (0.29.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.28.0-1) unstable; urgency=medium
|
vpn-router (0.28.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: bash completes the vpn-* commands, their options and the names they take.
|
feature: bash completes the vpn-* commands, their options and the names they
|
||||||
|
take.
|
||||||
|
|
||||||
* Bash completes the commands, options and arguments of vpn-router-user,
|
* Bash completes the commands, options and arguments of vpn-router-user,
|
||||||
vpn-router-group, vpn-router-token, vpn-config-edit, vpn-router-backup,
|
vpn-router-group, vpn-router-token, vpn-config-edit, vpn-router-backup,
|
||||||
@@ -332,7 +449,9 @@ vpn-router (0.28.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.27.0-1) unstable; urgency=medium
|
vpn-router (0.27.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: the web service is an OpenID Provider, which signs the directory's users in to other systems, the clients being service accounts with redirect URIs.
|
feature: the web service is an OpenID Provider, which signs the directory's
|
||||||
|
users in to other systems, the clients being service accounts with redirect
|
||||||
|
URIs.
|
||||||
|
|
||||||
* oidc.enabled turns on the OpenID Provider at the issuer
|
* oidc.enabled turns on the OpenID Provider at the issuer
|
||||||
https://<wan.local_fqdn>:<https.web_port>, with discovery, JWKS,
|
https://<wan.local_fqdn>:<https.web_port>, with discovery, JWKS,
|
||||||
@@ -361,7 +480,8 @@ vpn-router (0.27.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.26.2-1) unstable; urgency=medium
|
vpn-router (0.26.2-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: a service account may be named by a GUID in lowercase, as the Router Manager proposes for a new one.
|
fix: a service account may be named by a GUID in lowercase, as the Router
|
||||||
|
Manager proposes for a new one.
|
||||||
|
|
||||||
* vpn-router-user add --service and POST /service-accounts take a GUID
|
* vpn-router-user add --service and POST /service-accounts take a GUID
|
||||||
in lowercase as the name, besides a user name; users and groups keep
|
in lowercase as the name, besides a user name; users and groups keep
|
||||||
@@ -371,7 +491,8 @@ vpn-router (0.26.2-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.26.1-1) unstable; urgency=medium
|
vpn-router (0.26.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: the package installs vpnrouter_passkeys, without which the web service and vpn-router-user do not start.
|
fix: the package installs vpnrouter_passkeys, without which the web service
|
||||||
|
and vpn-router-user do not start.
|
||||||
|
|
||||||
* /usr/lib/vpn-router/vpnrouter_passkeys.py is part of the package.
|
* /usr/lib/vpn-router/vpnrouter_passkeys.py is part of the package.
|
||||||
|
|
||||||
@@ -379,7 +500,8 @@ vpn-router (0.26.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.26.0-1) unstable; urgency=medium
|
vpn-router (0.26.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: users sign in to the Router Manager with passkeys, and console login needs a TOTP token, so a user without one is refused the shell, SSH and sudo.
|
feature: users sign in to the Router Manager with passkeys, and console login
|
||||||
|
needs a TOTP token, so a user without one is refused the shell, SSH and sudo.
|
||||||
|
|
||||||
* Console login is switched on only for a user enrolled for TOTP. A user
|
* Console login is switched on only for a user enrolled for TOTP. A user
|
||||||
whose console login is on without a token is refused POST /shell, the
|
whose console login is on without a token is refused POST /shell, the
|
||||||
@@ -403,7 +525,8 @@ vpn-router (0.26.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.25.4-1) unstable; urgency=medium
|
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.
|
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:
|
* 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
|
POST /shell without a password gives a ticket while the user's last
|
||||||
@@ -414,7 +537,8 @@ vpn-router (0.25.4-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.25.3-1) unstable; urgency=medium
|
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.
|
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"}.
|
* caddy of /config is {"version", "source", "service"}.
|
||||||
|
|
||||||
@@ -422,7 +546,9 @@ vpn-router (0.25.3-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.25.2-1) unstable; urgency=medium
|
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.
|
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
|
* 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
|
network larger than /31, such as 192.168.232.0/24, is refused with the
|
||||||
@@ -432,7 +558,8 @@ vpn-router (0.25.2-1) unstable; urgency=medium
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
* GET /remote-access answers wireguard_server: {"public_key",
|
* GET /remote-access answers wireguard_server: {"public_key",
|
||||||
"endpoint", "allowed_ips"}.
|
"endpoint", "allowed_ips"}.
|
||||||
@@ -441,7 +568,8 @@ vpn-router (0.25.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.25.0-1) unstable; urgency=medium
|
vpn-router (0.25.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: a VPN user has WireGuard peers, each a device with its public key and IPs, kept in the directory and written into wg0.conf by setup.
|
feature: a VPN user has WireGuard peers, each a device with its public key and
|
||||||
|
IPs, kept in the directory and written into wg0.conf by setup.
|
||||||
|
|
||||||
* PUT /users/<name> takes wireguard_peers, [{"public_key", "ips"}],
|
* PUT /users/<name> takes wireguard_peers, [{"public_key", "ips"}],
|
||||||
which replaces the user's peers; keys are unique and IPs may not
|
which replaces the user's peers; keys are unique and IPs may not
|
||||||
@@ -457,7 +585,8 @@ vpn-router (0.25.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.24.1-1) unstable; urgency=medium
|
vpn-router (0.24.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: the status gives the number of connected OpenVPN clients at most 5 seconds old instead of up to a minute.
|
fix: the status gives the number of connected OpenVPN clients at most 5
|
||||||
|
seconds old instead of up to a minute.
|
||||||
|
|
||||||
* The OpenVPN server writes its status file every 5 seconds, so a
|
* The OpenVPN server writes its status file every 5 seconds, so a
|
||||||
refresh of the status shows clients that connected or left since.
|
refresh of the status shows clients that connected or left since.
|
||||||
@@ -466,7 +595,9 @@ vpn-router (0.24.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.24.0-1) unstable; urgency=medium
|
vpn-router (0.24.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: /remote-access of the web service shows and changes the settings of IKEv2 road-warrior, OpenVPN and WireGuard access and whether each runs, for the Router Manager's Remote access page.
|
feature: /remote-access of the web service shows and changes the settings of
|
||||||
|
IKEv2 road-warrior, OpenVPN and WireGuard access and whether each runs, for
|
||||||
|
the Router Manager's Remote access page.
|
||||||
|
|
||||||
* GET /remote-access answers the p2s, openvpn and wireguard sections,
|
* GET /remote-access answers the p2s, openvpn and wireguard sections,
|
||||||
which of them setup runs, and wan.local_fqdn; PUT
|
which of them setup runs, and wan.local_fqdn; PUT
|
||||||
@@ -478,7 +609,9 @@ vpn-router (0.24.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.23.2-1) unstable; urgency=medium
|
vpn-router (0.23.2-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: the shell of a user's own POSIX account opens on the first setup that sets up console login, and the journal records each refused shell with the reason.
|
fix: the shell of a user's own POSIX account opens on the first setup that
|
||||||
|
sets up console login, and the journal records each refused shell with the
|
||||||
|
reason.
|
||||||
|
|
||||||
* Setup restarts vpn-router-web when it adds ldap to the name service
|
* Setup restarts vpn-router-web when it adds ldap to the name service
|
||||||
switch, so the web service finds the POSIX accounts of the directory.
|
switch, so the web service finds the POSIX accounts of the directory.
|
||||||
@@ -489,7 +622,9 @@ vpn-router (0.23.2-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.23.1-1) unstable; urgency=medium
|
vpn-router (0.23.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: the shell runs as the console account of each member of admins, a local account or the user's own POSIX account in the directory, which also signs in with SSH and sudo, instead of one web.shell_user.
|
fix: the shell runs as the console account of each member of admins, a local
|
||||||
|
account or the user's own POSIX account in the directory, which also signs in
|
||||||
|
with SSH and sudo, instead of one web.shell_user.
|
||||||
|
|
||||||
* vpn-router-user console <name> --posix | --account LOCAL | --off, and
|
* vpn-router-user console <name> --posix | --account LOCAL | --off, and
|
||||||
PUT /users/<name>/console with a token of vpn-router-token, switch a
|
PUT /users/<name>/console with a token of vpn-router-token, switch a
|
||||||
@@ -507,7 +642,9 @@ vpn-router (0.23.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.23.0-1) unstable; urgency=medium
|
vpn-router (0.23.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: web.shell lets /shell of the web service open a login shell of web.shell_user over a WebSocket for the Router Manager's Shell page, after the user signs in again.
|
feature: web.shell lets /shell of the web service open a login shell of
|
||||||
|
web.shell_user over a WebSocket for the Router Manager's Shell page, after the
|
||||||
|
user signs in again.
|
||||||
|
|
||||||
* web.shell (false), web.shell_user and web.shell_idle_minutes (15).
|
* web.shell (false), web.shell_user and web.shell_idle_minutes (15).
|
||||||
* POST /shell takes the password and the TOTP code and answers a
|
* POST /shell takes the password and the TOTP code and answers a
|
||||||
@@ -519,7 +656,8 @@ vpn-router (0.23.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.22.1-1) unstable; urgency=medium
|
vpn-router (0.22.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: the status gives the number of site-to-site connections set up and established, not each connection by name.
|
fix: the status gives the number of site-to-site connections set up and
|
||||||
|
established, not each connection by name.
|
||||||
|
|
||||||
* site_to_site of the status document is {"connections", "established"},
|
* site_to_site of the status document is {"connections", "established"},
|
||||||
and the status page shows "Site to site: N of M established".
|
and the status page shows "Site to site: N of M established".
|
||||||
@@ -528,7 +666,9 @@ vpn-router (0.22.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.22.0-1) unstable; urgency=medium
|
vpn-router (0.22.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: pki.extended_key_usages adds custom extended key usages by their OIDs, which templates give by name, and a template's policies put certificate policy OIDs into its certificates.
|
feature: pki.extended_key_usages adds custom extended key usages by their
|
||||||
|
OIDs, which templates give by name, and a template's policies put certificate
|
||||||
|
policy OIDs into its certificates.
|
||||||
|
|
||||||
* pki.extended_key_usages maps a name to the OID of a custom extended
|
* pki.extended_key_usages maps a name to the OID of a custom extended
|
||||||
key usage; a template's extended_key_usage takes the built-in and the
|
key usage; a template's extended_key_usage takes the built-in and the
|
||||||
@@ -542,7 +682,8 @@ vpn-router (0.22.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.21.5-1) unstable; urgency=medium
|
vpn-router (0.21.5-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: /status/full of the status web service gives an authenticated client the whole status, whatever web.status_content says.
|
fix: /status/full of the status web service gives an authenticated client the
|
||||||
|
whole status, whatever web.status_content says.
|
||||||
|
|
||||||
* /status/full takes the token of /config and answers the status
|
* /status/full takes the token of /config and answers the status
|
||||||
document as web.status_content full shows it.
|
document as web.status_content full shows it.
|
||||||
@@ -551,7 +692,8 @@ vpn-router (0.21.5-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.21.4-1) unstable; urgency=medium
|
vpn-router (0.21.4-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: vpn-router-setup, vpn-router-ca and /ca read a CA's key and CRL from the directory each as itself, instead of failing on a CA with a CRL.
|
fix: vpn-router-setup, vpn-router-ca and /ca read a CA's key and CRL from the
|
||||||
|
directory each as itself, instead of failing on a CA with a CRL.
|
||||||
|
|
||||||
* A CA's CRL was read as its key and its key as its CRL, so every
|
* A CA's CRL was read as its key and its key as its CRL, so every
|
||||||
operation on a CA with a CRL failed with "Could not deserialize key
|
operation on a CA with a CRL failed with "Could not deserialize key
|
||||||
@@ -561,7 +703,10 @@ vpn-router (0.21.4-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.21.3-1) unstable; urgency=medium
|
vpn-router (0.21.3-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: the PKI is kept in ou=pki of the router's directory instead of files under /etc/vpn-router/pki, and the router's site-to-site certificate names only the identities its connections present, and /ca/bundle gives the certificate bundle of a CA.
|
fix: the PKI is kept in ou=pki of the router's directory instead of files
|
||||||
|
under /etc/vpn-router/pki, and the router's site-to-site certificate names
|
||||||
|
only the identities its connections present, and /ca/bundle gives the
|
||||||
|
certificate bundle of a CA.
|
||||||
|
|
||||||
* The CAs with their certificates, keys, CSRs and CRLs, and every
|
* The CAs with their certificates, keys, CSRs and CRLs, and every
|
||||||
certificate issued with its revocation, are entries of the
|
certificate issued with its revocation, are entries of the
|
||||||
@@ -585,7 +730,8 @@ vpn-router (0.21.3-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.21.2-1) unstable; urgency=medium
|
vpn-router (0.21.2-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: pki.root_key_warning silences the warning of vpn-router-ca status and /ca about the root key on the router.
|
fix: pki.root_key_warning silences the warning of vpn-router-ca status and /ca
|
||||||
|
about the root key on the router.
|
||||||
|
|
||||||
* pki.root_key_warning, true by default, set to false silences the
|
* pki.root_key_warning, true by default, set to false silences the
|
||||||
warning that the root key is on the router.
|
warning that the root key is on the router.
|
||||||
@@ -594,7 +740,8 @@ vpn-router (0.21.2-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.21.1-1) unstable; urgency=medium
|
vpn-router (0.21.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: vpn-router-ca status and /ca warn about a CRL the router signs only once vpn-router-pki.timer has not renewed it in time.
|
fix: vpn-router-ca status and /ca warn about a CRL the router signs only once
|
||||||
|
vpn-router-pki.timer has not renewed it in time.
|
||||||
|
|
||||||
* A CRL whose CA key is on the router is reported once past half of its
|
* A CRL whose CA key is on the router is reported once past half of its
|
||||||
validity, when the timer should have signed it again; a root CRL
|
validity, when the timer should have signed it again; a root CRL
|
||||||
@@ -604,7 +751,10 @@ vpn-router (0.21.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.21.0-1) unstable; urgency=medium
|
vpn-router (0.21.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: the router has a two-level CA with an optional offline root, issuing CAs for P2S, site-to-site and general use, templates, published certificates and CRLs and an ACME server, site-to-site connections authenticate with certificates, and setup installs Caddy from Ubuntu or Caddy's repository.
|
feature: the router has a two-level CA with an optional offline root, issuing
|
||||||
|
CAs for P2S, site-to-site and general use, templates, published certificates
|
||||||
|
and CRLs and an ACME server, site-to-site connections authenticate with
|
||||||
|
certificates, and setup installs Caddy from Ubuntu or Caddy's repository.
|
||||||
|
|
||||||
* The root CA signs the issuing CAs p2s, which issues the router's VPN
|
* The root CA signs the issuing CAs p2s, which issues the router's VPN
|
||||||
server certificate and the users' certificates, s2s, which issues
|
server certificate and the users' certificates, s2s, which issues
|
||||||
@@ -643,7 +793,9 @@ vpn-router (0.21.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.20.0-1) unstable; urgency=medium
|
vpn-router (0.20.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: the router makes scheduled backups to a local folder, copied to the cloud with rclone, or to a Proxmox Backup Server, and vpn-router-backup restores them.
|
feature: the router makes scheduled backups to a local folder, copied to the
|
||||||
|
cloud with rclone, or to a Proxmox Backup Server, and vpn-router-backup
|
||||||
|
restores them.
|
||||||
|
|
||||||
* The backup section of vpn-router.json switches on backups and sets
|
* The backup section of vpn-router.json switches on backups and sets
|
||||||
the schedule (hourly, daily or weekly), the target (local or pbs),
|
the schedule (hourly, daily or weekly), the target (local or pbs),
|
||||||
@@ -676,7 +828,8 @@ vpn-router (0.20.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.19.1-3) unstable; urgency=medium
|
vpn-router (0.19.1-3) unstable; urgency=medium
|
||||||
|
|
||||||
update: ADR-24 names Ubuntu's apt-daily timer, not a cron job, as what updates the package lists.
|
update: ADR-24 names Ubuntu's apt-daily timer, not a cron job, as what updates
|
||||||
|
the package lists.
|
||||||
|
|
||||||
* ADR-24 says that Ubuntu's apt-daily timer updates the package lists
|
* ADR-24 says that Ubuntu's apt-daily timer updates the package lists
|
||||||
twice a day, and that apt-daily or unattended-upgrades may hold the
|
twice a day, and that apt-daily or unattended-upgrades may hold the
|
||||||
@@ -695,7 +848,8 @@ vpn-router (0.19.1-2) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.19.1-1) unstable; urgency=medium
|
vpn-router (0.19.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: /metrics/history gives the availability of each site-to-site connection as the percentage of time it was up in slots of the view.
|
fix: /metrics/history gives the availability of each site-to-site connection
|
||||||
|
as the percentage of time it was up in slots of the view.
|
||||||
|
|
||||||
* site_to_site_up of /metrics/history divides the view into slots of 5
|
* site_to_site_up of /metrics/history divides the view into slots of 5
|
||||||
minutes for an hour, 1 hour for a day, 1 day for a month and 1 month
|
minutes for an hour, 1 hour for a day, 1 day for a month and 1 month
|
||||||
@@ -706,7 +860,8 @@ vpn-router (0.19.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.19.0-1) unstable; urgency=medium
|
vpn-router (0.19.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: service accounts have a display name and a description, which the router sets for its internal ones.
|
feature: service accounts have a display name and a description, which the
|
||||||
|
router sets for its internal ones.
|
||||||
|
|
||||||
* vpnRouterAccount of the package's schema also takes displayName and
|
* vpnRouterAccount of the package's schema also takes displayName and
|
||||||
description; vpn-router-setup updates the schema of an existing
|
description; vpn-router-setup updates the schema of an existing
|
||||||
@@ -724,7 +879,9 @@ vpn-router (0.19.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.18.3-1) unstable; urgency=medium
|
vpn-router (0.18.3-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: internal service accounts are the router's own, of which the metrics service's is the one at present, and the documentation says what a service account is.
|
fix: internal service accounts are the router's own, of which the metrics
|
||||||
|
service's is the one at present, and the documentation says what a service
|
||||||
|
account is.
|
||||||
|
|
||||||
* /service-accounts, /groups and the internal field of the answers
|
* /service-accounts, /groups and the internal field of the answers
|
||||||
take every internal service account, the accounts the router creates
|
take every internal service account, the accounts the router creates
|
||||||
@@ -739,7 +896,8 @@ vpn-router (0.18.3-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.18.2-1) unstable; urgency=medium
|
vpn-router (0.18.2-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: service accounts are managed at /service-accounts, not /users, and the internal one of the metrics service is only listed.
|
fix: service accounts are managed at /service-accounts, not /users, and the
|
||||||
|
internal one of the metrics service is only listed.
|
||||||
|
|
||||||
* /service-accounts, for members of admins and admin tokens, lists the
|
* /service-accounts, for members of admins and admin tokens, lists the
|
||||||
service accounts, adds one with a password and an optional e-mail
|
service accounts, adds one with a password and an optional e-mail
|
||||||
@@ -756,7 +914,9 @@ vpn-router (0.18.2-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.18.1-1) unstable; urgency=medium
|
vpn-router (0.18.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: vpn-router-setup owns only the creation of the metrics service's account, which is marked internal, and administrators add service accounts with vpn-router-user add --service and /users.
|
fix: vpn-router-setup owns only the creation of the metrics service's account,
|
||||||
|
which is marked internal, and administrators add service accounts with
|
||||||
|
vpn-router-user add --service and /users.
|
||||||
|
|
||||||
* POST /users takes "service": true and adds a service account, which
|
* POST /users takes "service": true and adds a service account, which
|
||||||
cannot sign in to /auth; vpn-router-user add takes --service.
|
cannot sign in to /auth; vpn-router-user add takes --service.
|
||||||
@@ -780,7 +940,8 @@ vpn-router (0.18.0-2) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.18.0-1) unstable; urgency=medium
|
vpn-router (0.18.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: site_to_site holds any number of named site-to-site connections, which /site-to-site lists, adds, changes and removes.
|
feature: site_to_site holds any number of named site-to-site connections,
|
||||||
|
which /site-to-site lists, adds, changes and removes.
|
||||||
|
|
||||||
* site_to_site maps a connection name to addrs, id, cidrs, local_cidrs,
|
* site_to_site maps a connection name to addrs, id, cidrs, local_cidrs,
|
||||||
local_id_mode, psk and psk_file; it replaces remote and
|
local_id_mode, psk and psk_file; it replaces remote and
|
||||||
@@ -820,7 +981,8 @@ vpn-router (0.18.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.17.0-1) unstable; urgency=medium
|
vpn-router (0.17.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: /config shows the router's PostgreSQL clusters and their settings, and /postgresql their databases with their sizes.
|
feature: /config shows the router's PostgreSQL clusters and their settings,
|
||||||
|
and /postgresql their databases with their sizes.
|
||||||
|
|
||||||
* /config has a postgresql section: each cluster of pg_lsclusters with
|
* /config has a postgresql section: each cluster of pg_lsclusters with
|
||||||
its version, name, port, status and data directory, and the main
|
its version, name, port, status and data directory, and the main
|
||||||
@@ -832,7 +994,8 @@ vpn-router (0.17.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.16.0-1) unstable; urgency=medium
|
vpn-router (0.16.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: the router keeps its metrics in PostgreSQL, serves them for an OpenTelemetry Collector and pushes them over OTLP.
|
feature: the router keeps its metrics in PostgreSQL, serves them for an
|
||||||
|
OpenTelemetry Collector and pushes them over OTLP.
|
||||||
|
|
||||||
* vpn-router-metrics takes a sample every minute: CPU, memory, free
|
* vpn-router-metrics takes a sample every minute: CPU, memory, free
|
||||||
space of /, bytes per second on the external and the internal
|
space of /, bytes per second on the external and the internal
|
||||||
@@ -884,7 +1047,8 @@ vpn-router (0.14.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.14.0-1) unstable; urgency=medium
|
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.
|
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:
|
* / 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
|
the last X-Forwarded-For entry for a request from a trusted proxy, so
|
||||||
@@ -899,7 +1063,8 @@ vpn-router (0.14.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.13.0-1) unstable; urgency=medium
|
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.
|
feature: Caddy serves the status page and the Router Manager over HTTPS with a
|
||||||
|
certificate from Let's Encrypt.
|
||||||
|
|
||||||
* The package depends on caddy. While https.enabled is true, the
|
* The package depends on caddy. While https.enabled is true, the
|
||||||
default, and wan.local_fqdn is set, vpn-router-setup writes
|
default, and wan.local_fqdn is set, vpn-router-setup writes
|
||||||
@@ -922,7 +1087,8 @@ vpn-router (0.13.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.12.3-1) unstable; urgency=medium
|
vpn-router (0.12.3-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: the web service listens on the internal address, the external address or both.
|
fix: the web service listens on the internal address, the external address or
|
||||||
|
both.
|
||||||
|
|
||||||
* web.listen in vpn-router.json sets the interfaces the web service
|
* web.listen in vpn-router.json sets the interfaces the web service
|
||||||
listens on: internal, external or both, the default. It is read
|
listens on: internal, external or both, the default. It is read
|
||||||
@@ -1008,7 +1174,8 @@ vpn-router (0.9.2-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.9.1-1) unstable; urgency=medium
|
vpn-router (0.9.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: the web service waits for the PostgreSQL driver instead of failing at start.
|
fix: the web service waits for the PostgreSQL driver instead of failing at
|
||||||
|
start.
|
||||||
|
|
||||||
* The web service imports psycopg as the first step of its
|
* The web service imports psycopg as the first step of its
|
||||||
initialization, retrying every 15 seconds and logging the failure while
|
initialization, retrying every 15 seconds and logging the failure while
|
||||||
@@ -1038,7 +1205,8 @@ vpn-router (0.9.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.8.0-1) unstable; urgency=medium
|
vpn-router (0.8.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: the web service creates its key and database itself and shows its starting phase.
|
feature: the web service creates its key and database itself and shows its
|
||||||
|
starting phase.
|
||||||
|
|
||||||
* vpn-router-web makes its signing key and creates the refresh tokens'
|
* vpn-router-web makes its signing key and creates the refresh tokens'
|
||||||
database on its first run, retrying every 15 seconds until PostgreSQL
|
database on its first run, retrying every 15 seconds until PostgreSQL
|
||||||
@@ -1053,7 +1221,8 @@ vpn-router (0.8.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.7.1-1) unstable; urgency=medium
|
vpn-router (0.7.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: a failing user directory or token database no longer leaves the tunnels down.
|
fix: a failing user directory or token database no longer leaves the tunnels
|
||||||
|
down.
|
||||||
|
|
||||||
* vpn-router-setup brings up strongSwan and WireGuard before it provisions
|
* vpn-router-setup brings up strongSwan and WireGuard before it provisions
|
||||||
the user directory and the token database. A failure of either no
|
the user directory and the token database. A failure of either no
|
||||||
@@ -1063,7 +1232,8 @@ vpn-router (0.7.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.7.0-1) unstable; urgency=medium
|
vpn-router (0.7.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: users sign in to the web service, and only members of admins manage the router.
|
feature: users sign in to the web service, and only members of admins manage
|
||||||
|
the router.
|
||||||
|
|
||||||
* POST /auth signs a user in with the password, and the TOTP code for an
|
* POST /auth signs a user in with the password, and the TOTP code for an
|
||||||
enrolled user, and answers a 5 minute access token and an 8 hour refresh
|
enrolled user, and answers a 5 minute access token and an 8 hour refresh
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ fi
|
|||||||
# preseeded completes without prompting. The defaults are platform "none" and
|
# preseeded completes without prompting. The defaults are platform "none" and
|
||||||
# mode "manual", which together mean: install the files, configure nothing.
|
# mode "manual", which together mean: install the files, configure nothing.
|
||||||
|
|
||||||
|
# The Router Manager's first administrator, asked whatever the platform is; the password only with a name.
|
||||||
|
db_input medium vpn-router/admin_username || true
|
||||||
|
db_go || true
|
||||||
|
|
||||||
|
db_get vpn-router/admin_username
|
||||||
|
if [ -n "$RET" ]; then
|
||||||
|
db_input medium vpn-router/admin_password || true
|
||||||
|
db_go || true
|
||||||
|
fi
|
||||||
|
|
||||||
db_input high vpn-router/platform || true
|
db_input high vpn-router/platform || true
|
||||||
db_go || true
|
db_go || true
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ Depends: ${misc:Depends},
|
|||||||
python3-psycopg,
|
python3-psycopg,
|
||||||
python3-psutil,
|
python3-psutil,
|
||||||
python3-prometheus-client,
|
python3-prometheus-client,
|
||||||
|
python3-requests,
|
||||||
postgresql,
|
postgresql,
|
||||||
slapd,
|
slapd,
|
||||||
nslcd,
|
nslcd,
|
||||||
@@ -46,7 +47,7 @@ Suggests: caddy,
|
|||||||
pdns-server,
|
pdns-server,
|
||||||
pdns-backend-pgsql,
|
pdns-backend-pgsql,
|
||||||
proxmox-backup-client-static
|
proxmox-backup-client-static
|
||||||
Description: Linux VPN router with IPSec, IKEv2, OpenVPN and WireGuard support
|
Description: Linux VPN router with IPSec, IKEv2, OpenVPN, WireGuard and a web manager
|
||||||
Configures a Linux host or virtual machine as a VPN router: site-to-site
|
Configures a Linux host or virtual machine as a VPN router: site-to-site
|
||||||
IKEv2 IPSec (strongSwan swanctl) with pre-shared keys or certificates,
|
IKEv2 IPSec (strongSwan swanctl) with pre-shared keys or certificates,
|
||||||
optional IKEv2 access using EAP-TLS, optional OpenVPN with a
|
optional IKEv2 access using EAP-TLS, optional OpenVPN with a
|
||||||
@@ -64,6 +65,11 @@ Description: Linux VPN router with IPSec, IKEv2, OpenVPN and WireGuard support
|
|||||||
or to a Proxmox Backup Server. Nothing in the package is specific to a cloud
|
or to a Proxmox Backup Server. Nothing in the package is specific to a cloud
|
||||||
provider.
|
provider.
|
||||||
.
|
.
|
||||||
|
The Router Manager, a web application served by the router, shows the
|
||||||
|
router's state and manages its users, connections, certificates, web sites,
|
||||||
|
name server, backups and packages. Users sign in through the router's web
|
||||||
|
service; their sessions are kept in PostgreSQL.
|
||||||
|
.
|
||||||
Settings live in /etc/vpn-router/vpn-router.json, which is created on first
|
Settings live in /etc/vpn-router/vpn-router.json, which is created on first
|
||||||
install and owned by the administrator afterwards. Editing it and restarting
|
install and owned by the administrator afterwards. Editing it and restarting
|
||||||
vpn-router-setup applies the change; the same service reconciles the system
|
vpn-router-setup applies the change; the same service reconciles the system
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ etc/vpn-router
|
|||||||
usr/lib/vpn-router
|
usr/lib/vpn-router
|
||||||
usr/lib/vpn-router/vpnrouter_platforms
|
usr/lib/vpn-router/vpnrouter_platforms
|
||||||
usr/share/vpn-router/templates
|
usr/share/vpn-router/templates
|
||||||
|
usr/share/vpn-router/manager/web
|
||||||
var/lib/vpn-router/sites
|
var/lib/vpn-router/sites
|
||||||
@@ -37,9 +37,15 @@ src/usr/lib/vpn-router/metrics usr/lib/vpn-router/
|
|||||||
src/usr/lib/vpn-router/bootstrap usr/lib/vpn-router/
|
src/usr/lib/vpn-router/bootstrap usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/wait-for-libpq usr/lib/vpn-router/
|
src/usr/lib/vpn-router/wait-for-libpq usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/
|
src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/
|
||||||
|
src/usr/lib/vpn-router/manager usr/lib/vpn-router/
|
||||||
|
src/usr/lib/vpn-router/vpnrouter_manager.py usr/lib/vpn-router/
|
||||||
|
src/usr/lib/vpn-router/upgrade usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/
|
src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/
|
||||||
src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/
|
src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/
|
||||||
src/usr/share/vpn-router/sql/* usr/share/vpn-router/sql/
|
src/usr/share/vpn-router/sql/* usr/share/vpn-router/sql/
|
||||||
src/usr/share/bash-completion/completions/*.bash usr/share/bash-completion/completions/
|
src/usr/share/bash-completion/completions/*.bash usr/share/bash-completion/completions/
|
||||||
src/usr/lib/systemd/system/caddy.service.d/vpn-router.conf usr/lib/systemd/system/caddy.service.d/
|
src/usr/lib/systemd/system/caddy.service.d/vpn-router.conf usr/lib/systemd/system/caddy.service.d/
|
||||||
src/usr/share/doc/vpn-router/vpn-router.json.example usr/share/doc/vpn-router/
|
src/usr/share/doc/vpn-router/vpn-router.json.example usr/share/doc/vpn-router/
|
||||||
|
src/usr/share/doc/vpn-router/manager.json.example usr/share/doc/vpn-router/
|
||||||
|
web/dist/* usr/share/vpn-router/manager/web/
|
||||||
|
docs/* usr/share/vpn-router/manager/docs/
|
||||||
@@ -63,6 +63,22 @@ case "$1" in
|
|||||||
# not blank the key on the next dpkg-reconfigure.
|
# not blank the key on the next dpkg-reconfigure.
|
||||||
db_set vpn-router/psk ""
|
db_set vpn-router/psk ""
|
||||||
|
|
||||||
|
# The Router Manager's administrator account for vpn-router-setup to create, when a name and a
|
||||||
|
# password were given; the answers are cleared so that a later run does not ask for it again.
|
||||||
|
db_get vpn-router/admin_username
|
||||||
|
ADMIN_USERNAME="$RET"
|
||||||
|
db_get vpn-router/admin_password
|
||||||
|
ADMIN_PASSWORD="$RET"
|
||||||
|
if [ -n "$ADMIN_USERNAME" ] && [ -n "$ADMIN_PASSWORD" ]; then
|
||||||
|
(umask 077; ADMIN_USERNAME="$ADMIN_USERNAME" ADMIN_PASSWORD="$ADMIN_PASSWORD" python3 -c \
|
||||||
|
'import json, os, sys; json.dump({"username": os.environ["ADMIN_USERNAME"], "password": os.environ["ADMIN_PASSWORD"]}, sys.stdout)' \
|
||||||
|
> /etc/vpn-router/manager-admin.json)
|
||||||
|
elif [ -n "$ADMIN_USERNAME" ]; then
|
||||||
|
echo 'vpn-router: no administrator password was given; no Router Manager administrator is created' >&2
|
||||||
|
fi
|
||||||
|
db_set vpn-router/admin_username ""
|
||||||
|
db_set vpn-router/admin_password ""
|
||||||
|
|
||||||
# Apply the sysctl drop-in shipped by this package so it takes effect
|
# Apply the sysctl drop-in shipped by this package so it takes effect
|
||||||
# without waiting for a reboot.
|
# without waiting for a reboot.
|
||||||
sysctl --system >/dev/null
|
sysctl --system >/dev/null
|
||||||
@@ -71,12 +87,13 @@ case "$1" in
|
|||||||
# setting the forward policy all belong to vpn-router-setup, which does
|
# setting the forward policy all belong to vpn-router-setup, which does
|
||||||
# them only once the configuration says this host is a router.
|
# them only once the configuration says this host is a router.
|
||||||
|
|
||||||
# The setup and web services wait for libpq in the linker cache, which
|
# The setup, web and manager services wait for libpq in the linker cache,
|
||||||
# dpkg refreshes after this script, so they are started without waiting
|
# which dpkg refreshes after this script, so they are started without
|
||||||
# for them.
|
# waiting for them.
|
||||||
if [ -d /run/systemd/system ]; then
|
if [ -d /run/systemd/system ]; then
|
||||||
systemctl --no-block restart vpn-router-setup.service >/dev/null 2>&1 || true
|
systemctl --no-block restart vpn-router-setup.service >/dev/null 2>&1 || true
|
||||||
systemctl --no-block restart vpn-router-web.service >/dev/null 2>&1 || true
|
systemctl --no-block restart vpn-router-web.service >/dev/null 2>&1 || true
|
||||||
|
systemctl --no-block restart vpn-router-manager.service >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ case "$1" in
|
|||||||
/etc/vpn-router/web.key \
|
/etc/vpn-router/web.key \
|
||||||
/etc/vpn-router/oidc.key \
|
/etc/vpn-router/oidc.key \
|
||||||
/etc/vpn-router/metrics.secret \
|
/etc/vpn-router/metrics.secret \
|
||||||
|
/etc/vpn-router/manager.json \
|
||||||
|
/etc/vpn-router/manager-admin.json \
|
||||||
|
/var/lib/vpn-router/upgrade.json \
|
||||||
|
/var/lib/vpn-router/upgrade.log \
|
||||||
|
/var/lib/vpn-router/system-upgrade.json \
|
||||||
|
/var/lib/vpn-router/system-upgrade.log \
|
||||||
/etc/systemd/system/vpn-router-backup.timer.d/schedule.conf \
|
/etc/systemd/system/vpn-router-backup.timer.d/schedule.conf \
|
||||||
/etc/sudoers.d/vpn-router \
|
/etc/sudoers.d/vpn-router \
|
||||||
/etc/ssh/sshd_config.d/vpn-router.conf \
|
/etc/ssh/sshd_config.d/vpn-router.conf \
|
||||||
@@ -76,8 +82,9 @@ case "$1" in
|
|||||||
fi
|
fi
|
||||||
rm -f /etc/vpn-router/database.secret
|
rm -f /etc/vpn-router/database.secret
|
||||||
|
|
||||||
# The databases and roles of the web sites and of the name server, when the package created them.
|
# The databases and roles of the web sites, of the name server and of the Router Manager's sessions,
|
||||||
for database in vpn-router-sites vpn-router-dns; do
|
# when the package created them.
|
||||||
|
for database in vpn-router-sites vpn-router-dns vpn-router-manager; do
|
||||||
secret="/etc/vpn-router/${database#vpn-router-}-database.secret"
|
secret="/etc/vpn-router/${database#vpn-router-}-database.secret"
|
||||||
if [ -f "$secret" ] && command -v psql >/dev/null 2>&1; then
|
if [ -f "$secret" ] && command -v psql >/dev/null 2>&1; then
|
||||||
runuser -u postgres -- psql -X -q -c "DROP DATABASE IF EXISTS \"$database\"" || true
|
runuser -u postgres -- psql -X -q -c "DROP DATABASE IF EXISTS \"$database\"" || true
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
%:
|
%:
|
||||||
dh $@
|
dh $@
|
||||||
|
|
||||||
|
override_dh_auto_build:
|
||||||
|
test -f web/dist/index.html
|
||||||
|
|
||||||
override_dh_installsystemd:
|
override_dh_installsystemd:
|
||||||
dh_installsystemd --name=vpn-router-setup --no-start
|
dh_installsystemd --name=vpn-router-setup --no-start
|
||||||
|
dh_installsystemd --name=vpn-router-manager --no-start
|
||||||
dh_installsystemd --name=vpn-router-docker
|
dh_installsystemd --name=vpn-router-docker
|
||||||
dh_installsystemd --name=vpn-router-web --no-start
|
dh_installsystemd --name=vpn-router-web --no-start
|
||||||
dh_installsystemd --name=vpn-router-metrics --no-enable --no-start
|
dh_installsystemd --name=vpn-router-metrics --no-enable --no-start
|
||||||
|
|||||||
@@ -13,6 +13,24 @@ Description: Platform:
|
|||||||
.
|
.
|
||||||
generic configures the router with no platform-specific additions.
|
generic configures the router with no platform-specific additions.
|
||||||
|
|
||||||
|
Template: vpn-router/admin_username
|
||||||
|
Type: string
|
||||||
|
Default:
|
||||||
|
Description: Name of the Router Manager's administrator account:
|
||||||
|
The Router Manager can have a first administrator: a Manager-only account
|
||||||
|
of the router's user directory, a member of the group admins, which may
|
||||||
|
sign in to the Router Manager but has no VPN access. vpn-router-setup
|
||||||
|
creates it.
|
||||||
|
.
|
||||||
|
Leave this empty to create no account and no group.
|
||||||
|
|
||||||
|
Template: vpn-router/admin_password
|
||||||
|
Type: password
|
||||||
|
Description: Password of the administrator account:
|
||||||
|
The password of the administrator account, 8 to 128 characters. It is
|
||||||
|
passed to vpn-router-setup in a file readable by root only, which is removed
|
||||||
|
once the account exists, and not kept by debconf.
|
||||||
|
|
||||||
Template: vpn-router/mode
|
Template: vpn-router/mode
|
||||||
Type: select
|
Type: select
|
||||||
Choices: manual, interfaces, auto
|
Choices: manual, interfaces, auto
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=VPN Router Manager web application
|
||||||
|
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||||
|
After=network-online.target postgresql.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStartPre=/usr/lib/vpn-router/wait-for-libpq
|
||||||
|
ExecStart=/usr/bin/python3 /usr/lib/vpn-router/manager
|
||||||
|
TimeoutStartSec=5min
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=15
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=-/etc/vpn-router
|
||||||
|
ProtectHome=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -16,14 +16,16 @@ PORT = 17081
|
|||||||
#: The title of each section and its command.
|
#: The title of each section and its command.
|
||||||
COMMANDS = (
|
COMMANDS = (
|
||||||
('Setup state', ['cat', '/run/vpn-router/setup.json']),
|
('Setup state', ['cat', '/run/vpn-router/setup.json']),
|
||||||
('Services', ['systemctl', 'is-active', 'vpn-router-setup', 'vpn-router-web', 'slapd', 'postgresql',
|
('Services', ['systemctl', 'is-active', 'vpn-router-setup', 'vpn-router-web', 'vpn-router-manager', 'slapd',
|
||||||
'strongswan', 'openvpn-server@vpn-router', 'wg-quick@wg0']),
|
'postgresql', 'strongswan', 'openvpn-server@vpn-router', 'wg-quick@wg0']),
|
||||||
('Failed units', ['systemctl', '--failed', '--no-legend', '--plain']),
|
('Failed units', ['systemctl', '--failed', '--no-legend', '--plain']),
|
||||||
('Addresses', ['ip', '-brief', 'address']),
|
('Addresses', ['ip', '-brief', 'address']),
|
||||||
('Routes', ['ip', 'route']),
|
('Routes', ['ip', 'route']),
|
||||||
('cloud-init', ['cloud-init', 'status', '--long']),
|
('cloud-init', ['cloud-init', 'status', '--long']),
|
||||||
('vpn-router-setup log', ['journalctl', '--unit', 'vpn-router-setup', '--lines', '60', '--no-pager', '--output', 'short-iso']),
|
('vpn-router-setup log', ['journalctl', '--unit', 'vpn-router-setup', '--lines', '60', '--no-pager', '--output', 'short-iso']),
|
||||||
('vpn-router-web log', ['journalctl', '--unit', 'vpn-router-web', '--lines', '30', '--no-pager', '--output', 'short-iso']),
|
('vpn-router-web log', ['journalctl', '--unit', 'vpn-router-web', '--lines', '30', '--no-pager', '--output', 'short-iso']),
|
||||||
|
('vpn-router-manager log', ['journalctl', '--unit', 'vpn-router-manager', '--lines', '30', '--no-pager', '--output',
|
||||||
|
'short-iso']),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+903
@@ -0,0 +1,903 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
"""Serve the Router Manager: the web application and its API, which passes calls to the router.
|
||||||
|
|
||||||
|
Run by vpn-router-manager.service. It uses python3-requests to call the router and
|
||||||
|
python3-psycopg to keep the sessions; its settings and its listening loop are in
|
||||||
|
vpnrouter_manager.
|
||||||
|
|
||||||
|
Pages:
|
||||||
|
/api/info the version of this package and the router it manages, as JSON
|
||||||
|
/api/session POST signs in through the router's /auth with {"name", "password", "code"?},
|
||||||
|
GET answers the signed-in user, DELETE signs out
|
||||||
|
/api/session/passkey
|
||||||
|
POST begins a sign-in with a passkey at the router's /auth/passkey;
|
||||||
|
/api/session/passkey/complete POST signs in through /auth/passkey/complete
|
||||||
|
/api/updates GET the installed and the candidate version of vpn-router
|
||||||
|
from apt (see updates()), POST updates apt's package lists (see start_check());
|
||||||
|
needs a session, POST a member of admins
|
||||||
|
/api/upgrade GET the state and the output of the upgrade of that package (see upgrade_state()),
|
||||||
|
POST starts it (see start_upgrade()); needs a session, POST a member of admins
|
||||||
|
/api/system-updates
|
||||||
|
GET the packages of the system apt-get upgrade would upgrade, with their installed
|
||||||
|
and candidate versions (see system_updates()); needs a session
|
||||||
|
/api/system-upgrade
|
||||||
|
GET the state and the output of the upgrade of the system's packages, POST starts
|
||||||
|
it (see start_upgrade()); needs a session, POST a member of admins
|
||||||
|
/api/reboot POST reboots the system (see start_reboot()); needs a member of admins
|
||||||
|
/api/shell a WebSocket handshake goes to the router's /shell, and the WebSocket is relayed
|
||||||
|
(see Handler.relay()); POST passes as every other path does
|
||||||
|
/api/<path> the router's web service at /<path>, with the access token of the session;
|
||||||
|
the method, the body and the answer pass unchanged; the files of a web site,
|
||||||
|
/api/sites/<name>/files/..., pass streamed for GET, HEAD and PUT (see Handler.transfer())
|
||||||
|
/docs/<file> a file of the help in DOCS_DIR, Markdown, SVG or PNG (see doc_file()); needs a session
|
||||||
|
/<file> the web application from WEB_DIR; a path that names no file gives index.html
|
||||||
|
|
||||||
|
A session is a row of the table sessions in PostgreSQL, found by the SHA-256 of a random id in
|
||||||
|
the HttpOnly cookie COOKIE; it holds the user's access and refresh tokens and ends with the
|
||||||
|
refresh token. An access token that expires within REFRESH_MARGIN seconds is renewed through
|
||||||
|
the router's /auth/refresh before a call; a refused renewal ends the session. /api/<path>
|
||||||
|
without a session answers 401. Sign-in calls carry the browser's address in X-Forwarded-For,
|
||||||
|
which the router reads for its rate limit when the manager is one of its trusted proxies.
|
||||||
|
Without router_url the API answers 503.
|
||||||
|
|
||||||
|
The manager first initializes, without listening: on the router, unless the settings name a
|
||||||
|
database, it creates its role, password and database when they are missing; then it connects to
|
||||||
|
the sessions database and creates its table, retrying every INIT_RETRY_SECONDS until it succeeds
|
||||||
|
and logging each failure. Then it listens. Its unit starts it only once wait-for-libpq has found
|
||||||
|
libpq in the linker cache, where psycopg looks it up.
|
||||||
|
|
||||||
|
The service needs nothing else to start and does not end on its own. Every DISCOVERY_SECONDS it
|
||||||
|
works out where to listen, bind or the interfaces listen names, and runs a server at each address
|
||||||
|
(vpnrouter_manager.serve). Every request
|
||||||
|
reads the settings when it is made.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import http.cookies
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
from http.server import BaseHTTPRequestHandler
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
import requests
|
||||||
|
import urllib3
|
||||||
|
|
||||||
|
import vpnrouter_manager
|
||||||
|
from vpnrouter_manager import DATABASE, DATABASE_SECRET, ROUTER_CONFIG_FILE
|
||||||
|
|
||||||
|
#: The address a proxy on this host calls from.
|
||||||
|
LOOPBACK = '127.0.0.1'
|
||||||
|
|
||||||
|
CONFIG_FILE = vpnrouter_manager.CONFIG_FILE
|
||||||
|
WEB_DIR = '/usr/share/vpn-router/manager/web'
|
||||||
|
DOCS_DIR = '/usr/share/vpn-router/manager/docs'
|
||||||
|
|
||||||
|
#: The files of DOCS_DIR /docs serves, by extension, and their content types.
|
||||||
|
DOC_TYPES = {'.md': 'text/markdown; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png'}
|
||||||
|
|
||||||
|
#: The headers of every /docs answer: no type guessing, and an SVG opened on its own runs no script.
|
||||||
|
DOC_HEADERS = (('X-Content-Type-Options', 'nosniff'), ('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'"))
|
||||||
|
|
||||||
|
#: The cookie that holds the session id.
|
||||||
|
COOKIE = 'vpn_router_manager_session'
|
||||||
|
|
||||||
|
#: Seconds before its expiry an access token is renewed.
|
||||||
|
REFRESH_MARGIN = 30
|
||||||
|
|
||||||
|
#: Seconds between two attempts of the initialization.
|
||||||
|
INIT_RETRY_SECONDS = 15
|
||||||
|
|
||||||
|
#: The packages /api/updates reports.
|
||||||
|
PACKAGES = ('vpn-router',)
|
||||||
|
|
||||||
|
#: Touched by apt at every successful update of its package lists.
|
||||||
|
APT_UPDATED = '/var/lib/apt/periodic/update-success-stamp'
|
||||||
|
|
||||||
|
#: The transient units of the upgrade, of the reboot and of the update of the package lists, outside the manager's unit.
|
||||||
|
UPGRADE_UNIT = 'vpn-router-manager-upgrade'
|
||||||
|
SYSTEM_UPGRADE_UNIT = 'vpn-router-manager-system-upgrade'
|
||||||
|
REBOOT_UNIT = 'vpn-router-manager-reboot'
|
||||||
|
CHECK_UNIT = 'vpn-router-manager-check'
|
||||||
|
|
||||||
|
#: Seconds the update of the package lists waits for apt's lock, which Ubuntu's apt-daily or unattended-upgrades may hold.
|
||||||
|
CHECK_LOCK_SECONDS = 300
|
||||||
|
|
||||||
|
#: The upgrade helper, and the state and the output it records of the router's package and of the system's.
|
||||||
|
UPGRADE = '/usr/lib/vpn-router/upgrade'
|
||||||
|
UPGRADE_STATE = '/var/lib/vpn-router/upgrade.json'
|
||||||
|
UPGRADE_LOG = '/var/lib/vpn-router/upgrade.log'
|
||||||
|
SYSTEM_UPGRADE_STATE = '/var/lib/vpn-router/system-upgrade.json'
|
||||||
|
SYSTEM_UPGRADE_LOG = '/var/lib/vpn-router/system-upgrade.log'
|
||||||
|
|
||||||
|
#: A package apt-get -s upgrade would upgrade: Inst <name> [<installed>] (<candidate> <release> [<architecture>]).
|
||||||
|
SIMULATED = re.compile(r'^Inst (\S+) (?:\[([^\]]*)\] )?\((\S+)')
|
||||||
|
|
||||||
|
#: The last lines of the upgrade's output /api/upgrade answers.
|
||||||
|
LOG_LINES = 200
|
||||||
|
|
||||||
|
#: Written by packages that need a reboot, with the packages in REBOOT_REQUIRED.pkgs.
|
||||||
|
REBOOT_REQUIRED = '/var/run/reboot-required'
|
||||||
|
|
||||||
|
#: Seconds between the answer of /api/reboot and the reboot.
|
||||||
|
REBOOT_DELAY = 5
|
||||||
|
|
||||||
|
SCHEMA = ('CREATE TABLE IF NOT EXISTS sessions (id_hash text PRIMARY KEY, name text NOT NULL, access_token text NOT NULL, '
|
||||||
|
'access_expires timestamptz NOT NULL, refresh_token text NOT NULL, expires timestamptz NOT NULL)')
|
||||||
|
|
||||||
|
#: Seconds between two discoveries of where the service should listen.
|
||||||
|
DISCOVERY_SECONDS = 30
|
||||||
|
|
||||||
|
#: Seconds a call to the router may take; adding a user creates an RSA key.
|
||||||
|
ROUTER_TIMEOUT = 130
|
||||||
|
|
||||||
|
#: The largest request body passed to the router, in bytes; a CSR or a certificate chain in PEM is a few kilobytes.
|
||||||
|
BODY_LIMIT = 65536
|
||||||
|
|
||||||
|
#: The largest body of a save of the Shell page's editor, /api/shell/editor/...: a file of up to 1 MiB, as the router
|
||||||
|
#: takes, escaped in JSON.
|
||||||
|
EDITOR_BODY_LIMIT = 2 * 1024 * 1024 + BODY_LIMIT
|
||||||
|
|
||||||
|
#: The paths of a web site's files, whose GET, HEAD and PUT pass streamed (see Handler.transfer()).
|
||||||
|
SITE_FILES = re.compile(r'^/api/sites/[^/]+/files(/.*)?$')
|
||||||
|
|
||||||
|
|
||||||
|
def settings():
|
||||||
|
return vpnrouter_manager.settings(CONFIG_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def package_version():
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['dpkg-query', '-W', '-f=${Version}', 'vpn-router'],
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return None
|
||||||
|
return result.stdout.strip() if result.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def updates():
|
||||||
|
"""The installed and the candidate version of each of PACKAGES, from apt-cache policy, and whether an update is available.
|
||||||
|
|
||||||
|
The candidate is the version apt would install from the sources it is configured with, as of
|
||||||
|
its last update of the package lists, whose time checked gives (APT_UPDATED, None when it is
|
||||||
|
not known); a version apt does not know is None. checking is whether start_check's update of
|
||||||
|
the package lists runs. Where apt cannot be asked, available is False and the packages are empty.
|
||||||
|
"""
|
||||||
|
checking = unit_active(CHECK_UNIT)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['apt-cache', 'policy', *PACKAGES], capture_output=True, text=True, timeout=30)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return {'available': False, 'checked': None, 'checking': checking, 'packages': []}
|
||||||
|
try:
|
||||||
|
checked = time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime(os.path.getmtime(APT_UPDATED)))
|
||||||
|
except OSError:
|
||||||
|
checked = None
|
||||||
|
found = {}
|
||||||
|
package = None
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if line and not line[0].isspace() and line.endswith(':'):
|
||||||
|
package = found.setdefault(line[:-1], {'name': line[:-1], 'installed': None, 'candidate': None})
|
||||||
|
elif package is not None and line.strip().startswith(('Installed:', 'Candidate:')):
|
||||||
|
key, _, value = line.strip().partition(': ')
|
||||||
|
package[key.lower()] = None if value == '(none)' else value
|
||||||
|
packages = [dict(found.get(name, {'name': name, 'installed': None, 'candidate': None})) for name in PACKAGES]
|
||||||
|
for package in packages:
|
||||||
|
package['update'] = package['installed'] is not None and package['candidate'] not in (None, package['installed'])
|
||||||
|
return {'available': True, 'checked': checked, 'checking': checking, 'packages': packages}
|
||||||
|
|
||||||
|
|
||||||
|
def system_updates():
|
||||||
|
"""The packages of the system apt-get upgrade would upgrade, from its simulation (apt-get -s), as updates() answers
|
||||||
|
its packages, each with update true, sorted by name; the other keys are those of updates().
|
||||||
|
|
||||||
|
The simulation takes no lock and changes nothing; it answers what the package lists of apt's last update give.
|
||||||
|
"""
|
||||||
|
checking = unit_active(CHECK_UNIT)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['apt-get', '-s', '-q', 'upgrade'], capture_output=True, text=True, timeout=60,
|
||||||
|
env=dict(os.environ, LC_ALL='C'))
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return {'available': False, 'checked': None, 'checking': checking, 'packages': []}
|
||||||
|
if result.returncode != 0:
|
||||||
|
return {'available': False, 'checked': None, 'checking': checking, 'packages': []}
|
||||||
|
try:
|
||||||
|
checked = time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime(os.path.getmtime(APT_UPDATED)))
|
||||||
|
except OSError:
|
||||||
|
checked = None
|
||||||
|
found = {}
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
match = SIMULATED.match(line)
|
||||||
|
if match:
|
||||||
|
found[match[1]] = {'name': match[1], 'installed': match[2], 'candidate': match[3], 'update': True}
|
||||||
|
return {'available': True, 'checked': checked, 'checking': checking, 'packages': [found[name] for name in sorted(found)]}
|
||||||
|
|
||||||
|
|
||||||
|
def unit_active(unit):
|
||||||
|
"""Whether a unit of systemd is active or activating; False where systemctl cannot be asked."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['systemctl', 'is-active', f'{unit}.service', f'{unit}.timer'],
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
return any(line.strip() in ('active', 'activating') for line in result.stdout.splitlines())
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade_state(system=False):
|
||||||
|
"""The state of the upgrade of the router's package, as GET /api/upgrade answers it, or with system of the system's
|
||||||
|
packages, as GET /api/system-upgrade answers it.
|
||||||
|
|
||||||
|
state is idle before the first upgrade, running, succeeded or failed as the helper recorded it, and interrupted when
|
||||||
|
it recorded running but its unit is not active, as after a reboot during the upgrade; the other keys of the helper's
|
||||||
|
state follow, and log holds the last LOG_LINES lines of its output. reboot_required and reboot_packages tell whether
|
||||||
|
a package asks for a reboot, and rebooting whether one is about to happen.
|
||||||
|
"""
|
||||||
|
state_file, log_file = (SYSTEM_UPGRADE_STATE, SYSTEM_UPGRADE_LOG) if system else (UPGRADE_STATE, UPGRADE_LOG)
|
||||||
|
try:
|
||||||
|
with open(state_file, encoding='utf-8') as handle:
|
||||||
|
recorded = json.load(handle)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
recorded = {}
|
||||||
|
if not isinstance(recorded, dict):
|
||||||
|
recorded = {}
|
||||||
|
running = unit_active(SYSTEM_UPGRADE_UNIT if system else UPGRADE_UNIT)
|
||||||
|
state = recorded.get('state', 'idle')
|
||||||
|
if running:
|
||||||
|
state = 'running'
|
||||||
|
elif state == 'running':
|
||||||
|
state = 'interrupted'
|
||||||
|
try:
|
||||||
|
with open(log_file, encoding='utf-8', errors='replace') as handle:
|
||||||
|
log = handle.read().splitlines()[-LOG_LINES:]
|
||||||
|
except OSError:
|
||||||
|
log = []
|
||||||
|
try:
|
||||||
|
with open(f'{REBOOT_REQUIRED}.pkgs', encoding='utf-8') as handle:
|
||||||
|
reboot_packages = sorted({line.strip() for line in handle if line.strip()})
|
||||||
|
except OSError:
|
||||||
|
reboot_packages = []
|
||||||
|
return {
|
||||||
|
'state': state,
|
||||||
|
'user': recorded.get('user'),
|
||||||
|
'started': recorded.get('started'),
|
||||||
|
'finished': recorded.get('finished'),
|
||||||
|
'exit_status': recorded.get('exit_status'),
|
||||||
|
'before': recorded.get('before'),
|
||||||
|
'after': recorded.get('after'),
|
||||||
|
'log': log,
|
||||||
|
'reboot_required': os.path.exists(REBOOT_REQUIRED),
|
||||||
|
'reboot_packages': reboot_packages,
|
||||||
|
'rebooting': unit_active(REBOOT_UNIT),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def transient(*args):
|
||||||
|
"""Start a transient unit with systemd-run and the arguments; the HTTP status and the message of a refusal, or None."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['systemd-run', '--quiet', '--collect', *args], capture_output=True, text=True, timeout=15)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||||
|
return 503, f'systemd-run cannot be run here: {exc}'
|
||||||
|
if result.returncode != 0:
|
||||||
|
message = result.stderr.strip() or f'systemd-run ended with status {result.returncode}'
|
||||||
|
return (409 if 'already' in message.lower() else 500), message
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def busy():
|
||||||
|
"""Whether an upgrade, of the router's package or of the system's, or a reboot runs."""
|
||||||
|
return unit_active(UPGRADE_UNIT) or unit_active(SYSTEM_UPGRADE_UNIT) or unit_active(REBOOT_UNIT)
|
||||||
|
|
||||||
|
|
||||||
|
def start_upgrade(name, system=False):
|
||||||
|
"""Start the upgrade of the router's package in the transient unit UPGRADE_UNIT, or with system of the system's
|
||||||
|
packages in SYSTEM_UPGRADE_UNIT, outside this service, so the restarts it makes do not end it.
|
||||||
|
|
||||||
|
The HTTP status and the JSON document: 202 when it started, 409 while an upgrade or a reboot runs, 503 where apt
|
||||||
|
cannot be used.
|
||||||
|
"""
|
||||||
|
if busy():
|
||||||
|
return 409, {'error': 'an upgrade or a reboot is already running'}
|
||||||
|
if not os.path.exists('/usr/bin/apt-get'):
|
||||||
|
return 503, {'error': 'apt is not available here'}
|
||||||
|
if system:
|
||||||
|
refused = transient(f'--unit={SYSTEM_UPGRADE_UNIT}', '--description=VPN Router system upgrade',
|
||||||
|
'--property=Type=exec', '--no-block', '/usr/bin/python3', UPGRADE, '--system', name)
|
||||||
|
else:
|
||||||
|
refused = transient(f'--unit={UPGRADE_UNIT}', '--description=VPN Router package upgrade', '--property=Type=exec',
|
||||||
|
'--no-block', '/usr/bin/python3', UPGRADE, name)
|
||||||
|
if refused:
|
||||||
|
return refused[0], {'error': refused[1]}
|
||||||
|
return 202, {'started': True}
|
||||||
|
|
||||||
|
|
||||||
|
def start_check():
|
||||||
|
"""Update apt's package lists in the transient unit CHECK_UNIT, as root outside this service's sandbox, and touch
|
||||||
|
APT_UPDATED when it succeeds, so updates() answers the new candidates and the time of the check.
|
||||||
|
|
||||||
|
The HTTP status and the JSON document: 202 when it started, 409 while an update of the lists, an upgrade or a
|
||||||
|
reboot runs, 503 where apt cannot be used.
|
||||||
|
"""
|
||||||
|
if unit_active(CHECK_UNIT) or busy():
|
||||||
|
return 409, {'error': 'a check for updates, an upgrade or a reboot is already running'}
|
||||||
|
if not os.path.exists('/usr/bin/apt-get'):
|
||||||
|
return 503, {'error': 'apt is not available here'}
|
||||||
|
refused = transient(f'--unit={CHECK_UNIT}', '--description=VPN Router package lists update', '--property=Type=oneshot',
|
||||||
|
f'--property=ExecStartPost=/usr/bin/touch {APT_UPDATED}', '--no-block',
|
||||||
|
'/usr/bin/apt-get', '-q', '-o', f'DPkg::Lock::Timeout={CHECK_LOCK_SECONDS}', 'update')
|
||||||
|
if refused:
|
||||||
|
return refused[0], {'error': refused[1]}
|
||||||
|
return 202, {'checking': True}
|
||||||
|
|
||||||
|
|
||||||
|
def start_reboot():
|
||||||
|
"""Reboot the system REBOOT_DELAY seconds from now, so the answer reaches the browser first.
|
||||||
|
|
||||||
|
The HTTP status and the JSON document: 202 when the reboot is scheduled, 409 while an upgrade or a reboot runs.
|
||||||
|
"""
|
||||||
|
if busy():
|
||||||
|
return 409, {'error': 'an upgrade or a reboot is already running'}
|
||||||
|
refused = transient(f'--unit={REBOOT_UNIT}', '--description=VPN Router reboot', f'--on-active={REBOOT_DELAY}',
|
||||||
|
'/usr/bin/systemctl', 'reboot')
|
||||||
|
if refused:
|
||||||
|
return refused[0], {'error': refused[1]}
|
||||||
|
return 202, {'rebooting': True}
|
||||||
|
|
||||||
|
|
||||||
|
def admin(current, access, client):
|
||||||
|
"""Whether the signed-in user is a member of the router's admins, as the router's /account answers."""
|
||||||
|
response = router_call(current, 'GET', '/account', None, {'Authorization': f'Bearer {access}', 'X-Forwarded-For': client})
|
||||||
|
try:
|
||||||
|
return response.ok and response.json().get('admin') is True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def error(code, message):
|
||||||
|
return code, 'application/json', json.dumps({'error': message}).encode() + b'\n'
|
||||||
|
|
||||||
|
|
||||||
|
class Unavailable(Exception):
|
||||||
|
"""A setting the API needs is missing, or the router or the database cannot be reached; the HTTP status and message."""
|
||||||
|
|
||||||
|
def __init__(self, code, message):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
def router_call(current, method, path, body=None, headers=None):
|
||||||
|
"""The router's answer to a call; Unavailable when it cannot be reached."""
|
||||||
|
if not current['router_url']:
|
||||||
|
raise Unavailable(503, f'no router is configured; set router_url in {CONFIG_FILE}')
|
||||||
|
headers = dict(headers or {}, Accept='application/json')
|
||||||
|
if body is not None:
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
try:
|
||||||
|
return requests.request(method, current['router_url'] + path, data=body, headers=headers,
|
||||||
|
timeout=ROUTER_TIMEOUT, allow_redirects=False)
|
||||||
|
except requests.RequestException as failure:
|
||||||
|
raise Unavailable(502, f'the router at {current["router_url"]} cannot be reached: {failure}')
|
||||||
|
|
||||||
|
|
||||||
|
def answer_of(response):
|
||||||
|
"""The HTTP status, the content type and the body to pass on for an answer of the router."""
|
||||||
|
kind = response.headers.get('Content-Type', '')
|
||||||
|
if response.ok or kind.startswith('application/json'):
|
||||||
|
return response.status_code, kind or 'application/json', response.content
|
||||||
|
return error(response.status_code, f'the router answered {response.status_code}: {response.text.strip()}')
|
||||||
|
|
||||||
|
|
||||||
|
def database(current):
|
||||||
|
"""A connection to the sessions database, with its table; Unavailable when it is not configured or fails."""
|
||||||
|
if not current['database']:
|
||||||
|
raise Unavailable(503, f'no session database is configured; set database in {CONFIG_FILE}')
|
||||||
|
try:
|
||||||
|
connection = psycopg.connect(current['database'], autocommit=True)
|
||||||
|
connection.execute(SCHEMA)
|
||||||
|
except psycopg.Error as failure:
|
||||||
|
raise Unavailable(503, f'the session database cannot be used: {failure}')
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
def psql(sql):
|
||||||
|
"""The output of an SQL statement, read from standard input, run by psql as postgres with DATABASE as the variable v1."""
|
||||||
|
result = subprocess.run(['runuser', '-u', 'postgres', '--', 'psql', '-X', '-q', '-t', '-A', '-v', 'ON_ERROR_STOP=1',
|
||||||
|
'-v', f'v1={DATABASE}'], input=sql, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise OSError(f'psql failed: {result.stderr.strip()}')
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def provision():
|
||||||
|
"""Create the role, its password file and the database on the router, each when missing.
|
||||||
|
|
||||||
|
The password, of URL-safe characters, reaches psql on standard input, not on its command line.
|
||||||
|
"""
|
||||||
|
if not psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'"):
|
||||||
|
if os.path.exists(DATABASE_SECRET):
|
||||||
|
os.unlink(DATABASE_SECRET)
|
||||||
|
psql('CREATE ROLE :"v1" LOGIN')
|
||||||
|
if not os.path.exists(DATABASE_SECRET):
|
||||||
|
password = secrets.token_urlsafe(32)
|
||||||
|
psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'")
|
||||||
|
os.makedirs(os.path.dirname(DATABASE_SECRET), exist_ok=True)
|
||||||
|
descriptor = os.open(DATABASE_SECRET, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(descriptor, 'w', encoding='utf-8') as handle:
|
||||||
|
handle.write(password + '\n')
|
||||||
|
if not psql("SELECT 1 FROM pg_database WHERE datname = :'v1'"):
|
||||||
|
psql('CREATE DATABASE :"v1" OWNER :"v1"')
|
||||||
|
|
||||||
|
|
||||||
|
def initialize():
|
||||||
|
"""Create the sessions database on the router when it is missing and connect to it, retrying every INIT_RETRY_SECONDS."""
|
||||||
|
reported = None
|
||||||
|
while True:
|
||||||
|
phase = 'reading the settings'
|
||||||
|
try:
|
||||||
|
configured = vpnrouter_manager.text_setting(vpnrouter_manager.read_json(CONFIG_FILE), 'database')
|
||||||
|
if not configured and os.path.exists(ROUTER_CONFIG_FILE):
|
||||||
|
phase = 'creating the sessions database'
|
||||||
|
provision()
|
||||||
|
phase = 'connecting to the sessions database'
|
||||||
|
database(settings()).close()
|
||||||
|
except (OSError, Unavailable) as exc:
|
||||||
|
failure = f'{phase} failed: {exc}'
|
||||||
|
if failure != reported:
|
||||||
|
log(f'{failure}; retrying every {INIT_RETRY_SECONDS} seconds')
|
||||||
|
reported = failure
|
||||||
|
time.sleep(INIT_RETRY_SECONDS)
|
||||||
|
continue
|
||||||
|
log('ready')
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def digest(session_id):
|
||||||
|
return hashlib.sha256(session_id.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def tokens_of(response):
|
||||||
|
"""The tokens of a 200 answer of /auth or /auth/refresh, or None when it holds none."""
|
||||||
|
try:
|
||||||
|
document = response.json()
|
||||||
|
values = (document['access_token'], int(document['expires_in']), document['refresh_token'],
|
||||||
|
int(document['refresh_expires_in']))
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
return None
|
||||||
|
return values if response.status_code == 200 and all(values) else None
|
||||||
|
|
||||||
|
|
||||||
|
def sign_in(current, path, body, client):
|
||||||
|
"""Sign in through the router's path, /auth or /auth/passkey/complete: the HTTP status, the content type, the body
|
||||||
|
and the session id, or None."""
|
||||||
|
response = router_call(current, 'POST', path, body, {'X-Forwarded-For': client})
|
||||||
|
tokens = tokens_of(response)
|
||||||
|
if tokens is None:
|
||||||
|
return (*answer_of(response), None)
|
||||||
|
try:
|
||||||
|
name = response.json()['name']
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
return (*error(502, 'the router named no user'), None)
|
||||||
|
session_id = secrets.token_urlsafe(32)
|
||||||
|
access, access_seconds, refresh, refresh_seconds = tokens
|
||||||
|
with database(current) as connection:
|
||||||
|
connection.execute('DELETE FROM sessions WHERE expires < now()')
|
||||||
|
connection.execute('INSERT INTO sessions VALUES (%s, %s, %s, now() + make_interval(secs => %s), %s, '
|
||||||
|
'now() + make_interval(secs => %s))',
|
||||||
|
(digest(session_id), name, access, access_seconds, refresh, refresh_seconds))
|
||||||
|
return 200, 'application/json', json.dumps({'name': name}).encode() + b'\n', session_id
|
||||||
|
|
||||||
|
|
||||||
|
def session(current, session_id, client):
|
||||||
|
"""The user name and a current access token of the session, renewed when it expires soon, or None."""
|
||||||
|
if not session_id:
|
||||||
|
return None
|
||||||
|
with database(current) as connection:
|
||||||
|
with connection.transaction():
|
||||||
|
row = connection.execute('SELECT name, access_token, access_expires < now() + make_interval(secs => %s), '
|
||||||
|
'refresh_token FROM sessions WHERE id_hash = %s AND expires > now() FOR UPDATE',
|
||||||
|
(REFRESH_MARGIN, digest(session_id))).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
name, access, stale, refresh = row
|
||||||
|
if not stale:
|
||||||
|
return name, access
|
||||||
|
response = router_call(current, 'POST', '/auth/refresh', json.dumps({'refresh_token': refresh}).encode(),
|
||||||
|
{'X-Forwarded-For': client})
|
||||||
|
tokens = tokens_of(response)
|
||||||
|
if tokens is None:
|
||||||
|
connection.execute('DELETE FROM sessions WHERE id_hash = %s', (digest(session_id),))
|
||||||
|
return None
|
||||||
|
access, access_seconds, refresh, refresh_seconds = tokens
|
||||||
|
connection.execute('UPDATE sessions SET access_token = %s, access_expires = now() + make_interval(secs => %s), '
|
||||||
|
'refresh_token = %s, expires = now() + make_interval(secs => %s) WHERE id_hash = %s',
|
||||||
|
(access, access_seconds, refresh, refresh_seconds, digest(session_id)))
|
||||||
|
return name, access
|
||||||
|
|
||||||
|
|
||||||
|
def sign_out(current, session_id):
|
||||||
|
"""End the session and revoke its refresh token at the router."""
|
||||||
|
if not session_id:
|
||||||
|
return
|
||||||
|
with database(current) as connection:
|
||||||
|
row = connection.execute('DELETE FROM sessions WHERE id_hash = %s RETURNING refresh_token',
|
||||||
|
(digest(session_id),)).fetchone()
|
||||||
|
if row:
|
||||||
|
router_call(current, 'POST', '/auth/logout', json.dumps({'refresh_token': row[0]}).encode())
|
||||||
|
|
||||||
|
|
||||||
|
def static(path):
|
||||||
|
"""A file of the web application; a path that names no file gives index.html, so the application routes it."""
|
||||||
|
root = os.path.realpath(WEB_DIR)
|
||||||
|
name = os.path.realpath(os.path.join(root, path.lstrip('/')))
|
||||||
|
if not name.startswith(root + os.sep) or not os.path.isfile(name):
|
||||||
|
name = os.path.join(root, 'index.html')
|
||||||
|
try:
|
||||||
|
with open(name, 'rb') as handle:
|
||||||
|
data = handle.read()
|
||||||
|
except OSError:
|
||||||
|
return 404, 'text/plain; charset=utf-8', b'Not found\n'
|
||||||
|
return 200, mimetypes.guess_type(name)[0] or 'application/octet-stream', data
|
||||||
|
|
||||||
|
|
||||||
|
def doc_file(path, root=DOCS_DIR):
|
||||||
|
"""The file of the help that a /docs/<file> path names and its content type, or None: a file inside root whose
|
||||||
|
extension DOC_TYPES lists."""
|
||||||
|
root = os.path.realpath(root)
|
||||||
|
name = os.path.realpath(os.path.join(root, urllib.parse.unquote(path).lstrip('/')))
|
||||||
|
kind = DOC_TYPES.get(os.path.splitext(name)[1].lower())
|
||||||
|
if kind is None or not name.startswith(root + os.sep) or not os.path.isfile(name):
|
||||||
|
return None
|
||||||
|
return name, kind
|
||||||
|
|
||||||
|
|
||||||
|
class Stream:
|
||||||
|
"""A request body of length bytes read from a stream as requests sends it, which takes its length from len()."""
|
||||||
|
|
||||||
|
def __init__(self, stream, length):
|
||||||
|
self.stream = stream
|
||||||
|
self.length = length
|
||||||
|
self.left = length
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return self.length
|
||||||
|
|
||||||
|
def read(self, size=-1):
|
||||||
|
size = self.left if size is None or size < 0 else min(size, self.left)
|
||||||
|
data = self.stream.read(size) if size else b''
|
||||||
|
self.left -= len(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = 'HTTP/1.1'
|
||||||
|
|
||||||
|
def session_id(self):
|
||||||
|
"""The session id of the request's cookie, or ''."""
|
||||||
|
cookies = http.cookies.SimpleCookie()
|
||||||
|
try:
|
||||||
|
cookies.load(self.headers.get('Cookie', ''))
|
||||||
|
except http.cookies.CookieError:
|
||||||
|
return ''
|
||||||
|
return cookies[COOKIE].value if COOKIE in cookies else ''
|
||||||
|
|
||||||
|
def proxied(self):
|
||||||
|
"""True for a call from a proxy on this host, such as the router's HTTPS proxy, which names the client."""
|
||||||
|
return self.client_address[0] == LOOPBACK and 'X-Forwarded-For' in self.headers
|
||||||
|
|
||||||
|
def client(self):
|
||||||
|
"""The browser's address: the last X-Forwarded-For entry of a proxied call, else the peer."""
|
||||||
|
if self.proxied():
|
||||||
|
return self.headers['X-Forwarded-For'].split(',')[-1].strip()
|
||||||
|
return self.client_address[0]
|
||||||
|
|
||||||
|
def api(self, path, route, payload):
|
||||||
|
"""Answer an /api/ request: the HTTP status, the content type, the body and the Set-Cookie value or None."""
|
||||||
|
current = settings()
|
||||||
|
client = self.client()
|
||||||
|
session_id = self.session_id()
|
||||||
|
if route == '/api/session/passkey' and self.command == 'POST':
|
||||||
|
return (*answer_of(router_call(current, 'POST', '/auth/passkey', b'{}', {'X-Forwarded-For': client})), None)
|
||||||
|
if route in ('/api/session', '/api/session/passkey/complete') and self.command == 'POST':
|
||||||
|
code, kind, body, created = sign_in(current, '/auth' if route == '/api/session' else '/auth/passkey/complete',
|
||||||
|
payload, client)
|
||||||
|
if created is None:
|
||||||
|
return code, kind, body, None
|
||||||
|
return code, kind, body, f'{COOKIE}={created}; HttpOnly; SameSite=Strict; Path=/'
|
||||||
|
if route == '/api/session' and self.command == 'DELETE':
|
||||||
|
sign_out(current, session_id)
|
||||||
|
return 200, 'application/json', b'{"signed_out": true}\n', f'{COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0'
|
||||||
|
found = session(current, session_id, client)
|
||||||
|
if found is None:
|
||||||
|
return (*error(401, 'not signed in'), None)
|
||||||
|
name, access = found
|
||||||
|
if route == '/api/session':
|
||||||
|
return 200, 'application/json', json.dumps({'name': name}).encode() + b'\n', None
|
||||||
|
if route == '/api/updates' and self.command in ('GET', 'HEAD'):
|
||||||
|
return 200, 'application/json', json.dumps(updates()).encode() + b'\n', None
|
||||||
|
if route == '/api/updates' and self.command == 'POST':
|
||||||
|
if not admin(current, access, client):
|
||||||
|
return (*error(403, f'user {name} is not a member of admins'), None)
|
||||||
|
code, document = start_check()
|
||||||
|
return code, 'application/json', json.dumps(document).encode() + b'\n', None
|
||||||
|
if route == '/api/system-updates' and self.command in ('GET', 'HEAD'):
|
||||||
|
return 200, 'application/json', json.dumps(system_updates()).encode() + b'\n', None
|
||||||
|
if route in ('/api/upgrade', '/api/system-upgrade') and self.command in ('GET', 'HEAD'):
|
||||||
|
return 200, 'application/json', json.dumps(upgrade_state(route == '/api/system-upgrade')).encode() + b'\n', None
|
||||||
|
if route in ('/api/upgrade', '/api/system-upgrade', '/api/reboot') and self.command == 'POST':
|
||||||
|
if not admin(current, access, client):
|
||||||
|
return (*error(403, f'user {name} is not a member of admins'), None)
|
||||||
|
code, document = start_reboot() if route == '/api/reboot' else start_upgrade(name, route == '/api/system-upgrade')
|
||||||
|
return code, 'application/json', json.dumps(document).encode() + b'\n', None
|
||||||
|
return (*answer_of(router_call(current, self.command, path[4:], payload,
|
||||||
|
{'Authorization': f'Bearer {access}', 'X-Forwarded-For': client})), None)
|
||||||
|
|
||||||
|
def relay(self, path):
|
||||||
|
"""Pass the WebSocket handshake of /api/shell to the router's /shell with the session's access token, then
|
||||||
|
relay the bytes both ways until either side closes; the HTTP status, the content type and the body of a
|
||||||
|
refusal, or None after a relayed WebSocket.
|
||||||
|
|
||||||
|
The handshake must come from a page of this host (Origin), and asks the router for no extension, so the
|
||||||
|
frames pass unchanged.
|
||||||
|
"""
|
||||||
|
current = settings()
|
||||||
|
client = self.client()
|
||||||
|
if urllib.parse.urlsplit(self.headers.get('Origin', '')).netloc != self.headers.get('Host', ''):
|
||||||
|
return error(403, 'the shell must be opened from a page of the Router Manager')
|
||||||
|
found = session(current, self.session_id(), client)
|
||||||
|
if found is None:
|
||||||
|
return error(401, 'not signed in')
|
||||||
|
if not current['router_url']:
|
||||||
|
raise Unavailable(503, f'no router is configured; set router_url in {CONFIG_FILE}')
|
||||||
|
url = urllib.parse.urlsplit(current['router_url'] + path[4:])
|
||||||
|
secure = url.scheme == 'https'
|
||||||
|
request = '\r\n'.join([
|
||||||
|
f'GET {url.path}?{url.query} HTTP/1.1', f'Host: {url.netloc}', 'Upgrade: websocket', 'Connection: Upgrade',
|
||||||
|
f'Sec-WebSocket-Key: {self.headers.get("Sec-WebSocket-Key", "")}',
|
||||||
|
f'Sec-WebSocket-Version: {self.headers.get("Sec-WebSocket-Version", "")}',
|
||||||
|
f'Authorization: Bearer {found[1]}', f'X-Forwarded-For: {client}', '', ''])
|
||||||
|
try:
|
||||||
|
upstream = socket.create_connection((url.hostname, url.port or (443 if secure else 80)), timeout=ROUTER_TIMEOUT)
|
||||||
|
if secure:
|
||||||
|
upstream = ssl.create_default_context().wrap_socket(upstream, server_hostname=url.hostname)
|
||||||
|
upstream.sendall(request.encode())
|
||||||
|
reader = upstream.makefile('rb')
|
||||||
|
code = int(reader.readline().split(b' ', 2)[1])
|
||||||
|
headers = {}
|
||||||
|
while (line := reader.readline().strip()):
|
||||||
|
name, _, value = line.decode('latin-1').partition(':')
|
||||||
|
headers[name.strip().lower()] = value.strip()
|
||||||
|
if code != 101:
|
||||||
|
body = reader.read(int(headers.get('content-length', '0')))
|
||||||
|
upstream.close()
|
||||||
|
return code, headers.get('content-type', 'application/json'), body
|
||||||
|
upstream.settimeout(None)
|
||||||
|
except (OSError, ValueError, IndexError) as failure:
|
||||||
|
raise Unavailable(502, f'the router at {current["router_url"]} cannot open a shell: {failure}')
|
||||||
|
self.close_connection = True
|
||||||
|
try:
|
||||||
|
self.send_response(101, 'Switching Protocols')
|
||||||
|
self.send_header('Upgrade', 'websocket')
|
||||||
|
self.send_header('Connection', 'Upgrade')
|
||||||
|
self.send_header('Sec-WebSocket-Accept', headers.get('sec-websocket-accept', ''))
|
||||||
|
self.end_headers()
|
||||||
|
except ConnectionError:
|
||||||
|
# The client went away before the shell was relayed; the router ends the shell when upstream closes.
|
||||||
|
upstream.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def pump(source, target):
|
||||||
|
try:
|
||||||
|
while data := source.read1(65536):
|
||||||
|
target.sendall(data)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
for end in (upstream, self.connection):
|
||||||
|
try:
|
||||||
|
end.shutdown(socket.SHUT_RDWR)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
thread = threading.Thread(target=pump, args=(reader, self.connection), name='shell output', daemon=True)
|
||||||
|
thread.start()
|
||||||
|
pump(self.rfile, upstream)
|
||||||
|
thread.join()
|
||||||
|
upstream.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def transfer(self, path, send_body):
|
||||||
|
"""Pass a GET, HEAD or PUT of a web site's files to the router with the session's access token, streaming the
|
||||||
|
request body and the answer; the HTTP status, the content type and the body of a refusal, or None once the
|
||||||
|
router's answer was passed on.
|
||||||
|
|
||||||
|
The body of a PUT goes to the router as it arrives, with its Content-Type and Content-Length, so it is not held
|
||||||
|
to BODY_LIMIT; the answer passes with its Content-Type, Content-Length and Content-Disposition.
|
||||||
|
"""
|
||||||
|
current = settings()
|
||||||
|
client = self.client()
|
||||||
|
found = session(current, self.session_id(), client)
|
||||||
|
if found is None:
|
||||||
|
return error(401, 'not signed in')
|
||||||
|
if not current['router_url']:
|
||||||
|
raise Unavailable(503, f'no router is configured; set router_url in {CONFIG_FILE}')
|
||||||
|
headers = {'Authorization': f'Bearer {found[1]}', 'X-Forwarded-For': client}
|
||||||
|
body = None
|
||||||
|
if self.command == 'PUT':
|
||||||
|
length = self.headers.get('Content-Length', '')
|
||||||
|
if not length.isdigit():
|
||||||
|
return error(411, 'the body needs a Content-Length')
|
||||||
|
headers['Content-Type'] = self.headers.get('Content-Type') or 'application/octet-stream'
|
||||||
|
body = Stream(self.rfile, int(length))
|
||||||
|
try:
|
||||||
|
response = requests.request(self.command, current['router_url'] + path[4:], data=body, headers=headers,
|
||||||
|
timeout=ROUTER_TIMEOUT, allow_redirects=False, stream=True)
|
||||||
|
except requests.RequestException as failure:
|
||||||
|
raise Unavailable(502, f'the router at {current["router_url"]} cannot be reached: {failure}')
|
||||||
|
with response:
|
||||||
|
try:
|
||||||
|
self.send_response(response.status_code)
|
||||||
|
for name in ('Content-Type', 'Content-Length', 'Content-Disposition'):
|
||||||
|
if name in response.headers:
|
||||||
|
self.send_header(name, response.headers[name])
|
||||||
|
self.send_header('Cache-Control', 'no-store')
|
||||||
|
self.end_headers()
|
||||||
|
if send_body:
|
||||||
|
for chunk in response.raw.stream(65536, decode_content=False):
|
||||||
|
self.wfile.write(chunk)
|
||||||
|
except ConnectionError:
|
||||||
|
# The client went away before the answer was passed on.
|
||||||
|
self.close_connection = True
|
||||||
|
except urllib3.exceptions.HTTPError as failure:
|
||||||
|
# The router's answer broke off after its headers were passed on.
|
||||||
|
self.close_connection = True
|
||||||
|
log(f'the router\'s answer to {self.command} {path.split("?", 1)[0]} broke off: {failure}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
def doc(self, path):
|
||||||
|
"""Answer GET /docs/<path> with a file of the help (doc_file): the HTTP status, the content type and the body; 401
|
||||||
|
without a session, 404 for a path that names no such file."""
|
||||||
|
try:
|
||||||
|
if session(settings(), self.session_id(), self.client()) is None:
|
||||||
|
return error(401, 'not signed in')
|
||||||
|
except Unavailable as failure:
|
||||||
|
return error(failure.code, str(failure))
|
||||||
|
except psycopg.Error as failure:
|
||||||
|
return error(503, f'the session database failed: {failure}')
|
||||||
|
found = doc_file(path)
|
||||||
|
if found is None:
|
||||||
|
return 404, 'text/plain; charset=utf-8', b'Not found\n'
|
||||||
|
try:
|
||||||
|
with open(found[0], 'rb') as handle:
|
||||||
|
return 200, found[1], handle.read()
|
||||||
|
except OSError:
|
||||||
|
return 404, 'text/plain; charset=utf-8', b'Not found\n'
|
||||||
|
|
||||||
|
def answer(self, send_body):
|
||||||
|
path = self.path.split('#', 1)[0]
|
||||||
|
route = path.split('?', 1)[0]
|
||||||
|
cookie = None
|
||||||
|
# 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
|
||||||
|
if route == '/api/info':
|
||||||
|
https = self.proxied() and self.headers.get('X-Forwarded-Proto') == 'https'
|
||||||
|
status = vpnrouter_manager.status_url(CONFIG_FILE, self.headers.get('Host', ''), self.server.server_address[0], https)
|
||||||
|
body = json.dumps({'version': package_version(), 'status_url': status or None}).encode()
|
||||||
|
code, kind = 200, 'application/json'
|
||||||
|
elif route == '/api/shell' and self.command == 'GET' and self.headers.get('Upgrade', '').lower() == 'websocket':
|
||||||
|
try:
|
||||||
|
refused = self.relay(path)
|
||||||
|
except Unavailable as failure:
|
||||||
|
refused = error(failure.code, str(failure))
|
||||||
|
except psycopg.Error as failure:
|
||||||
|
refused = error(503, f'the session database failed: {failure}')
|
||||||
|
if refused is None:
|
||||||
|
return
|
||||||
|
code, kind, body = refused
|
||||||
|
elif SITE_FILES.match(route) and self.command in ('GET', 'HEAD', 'PUT'):
|
||||||
|
try:
|
||||||
|
refused = self.transfer(path, send_body)
|
||||||
|
except Unavailable as failure:
|
||||||
|
refused = error(failure.code, str(failure))
|
||||||
|
except psycopg.Error as failure:
|
||||||
|
refused = error(503, f'the session database failed: {failure}')
|
||||||
|
if refused is None:
|
||||||
|
return
|
||||||
|
code, kind, body = refused
|
||||||
|
elif route.startswith('/api/'):
|
||||||
|
limit = EDITOR_BODY_LIMIT if route.startswith('/api/shell/editor/') else BODY_LIMIT
|
||||||
|
payload = self.request_body(limit)
|
||||||
|
if payload is False:
|
||||||
|
code, kind, body = error(413, f'the body is larger than {limit} bytes')
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
code, kind, body, cookie = self.api(path, route, payload)
|
||||||
|
except Unavailable as failure:
|
||||||
|
code, kind, body = error(failure.code, str(failure))
|
||||||
|
except psycopg.Error as failure:
|
||||||
|
code, kind, body = error(503, f'the session database failed: {failure}')
|
||||||
|
elif route.startswith('/docs/') and self.command in ('GET', 'HEAD'):
|
||||||
|
code, kind, body = self.doc(route[len('/docs/'):])
|
||||||
|
elif self.command in ('GET', 'HEAD'):
|
||||||
|
code, kind, body = static(route)
|
||||||
|
else:
|
||||||
|
code, kind, body = 405, 'text/plain; charset=utf-8', b'Method not allowed\n'
|
||||||
|
try:
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header('Content-Type', kind)
|
||||||
|
self.send_header('Content-Length', str(len(body)))
|
||||||
|
self.send_header('Cache-Control', 'no-store' if route.startswith('/api/') or not route.startswith('/assets/') else 'max-age=31536000, immutable')
|
||||||
|
if cookie:
|
||||||
|
self.send_header('Set-Cookie', cookie)
|
||||||
|
for name, value in DOC_HEADERS if route.startswith('/docs/') else ():
|
||||||
|
self.send_header(name, value)
|
||||||
|
self.end_headers()
|
||||||
|
if send_body:
|
||||||
|
self.wfile.write(body)
|
||||||
|
except ConnectionError:
|
||||||
|
# The client went away before the answer was written.
|
||||||
|
self.close_connection = True
|
||||||
|
|
||||||
|
def request_body(self, limit=BODY_LIMIT):
|
||||||
|
"""The request body, None when there is none, or False when it is larger than limit bytes."""
|
||||||
|
length = self.headers.get('Content-Length')
|
||||||
|
if not length:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
size = int(length)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if not 0 <= size <= limit:
|
||||||
|
return False
|
||||||
|
return self.rfile.read(size)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
self.answer(True)
|
||||||
|
|
||||||
|
def do_HEAD(self):
|
||||||
|
self.answer(False)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
self.answer(True)
|
||||||
|
|
||||||
|
def do_PUT(self):
|
||||||
|
self.answer(True)
|
||||||
|
|
||||||
|
def do_DELETE(self):
|
||||||
|
self.answer(True)
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
sys.stderr.write('%s - - [%s] %s\n' % (self.client(), self.log_date_time_string(), format % args))
|
||||||
|
|
||||||
|
|
||||||
|
def log(message):
|
||||||
|
print(f'vpn-router-manager: {message}', file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global CONFIG_FILE
|
||||||
|
parser = argparse.ArgumentParser(description='Serve the VPN Router Manager.')
|
||||||
|
parser.add_argument('--config', default=CONFIG_FILE, help=f'the settings file (default: {CONFIG_FILE})')
|
||||||
|
CONFIG_FILE = parser.parse_args().config
|
||||||
|
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
||||||
|
initialize()
|
||||||
|
try:
|
||||||
|
vpnrouter_manager.serve(lambda: vpnrouter_manager.listen(CONFIG_FILE), Handler, log, DISCOVERY_SECONDS)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
@@ -6,10 +6,10 @@ the live system: routes, DNS-derived identity, WireGuard keys, firewall rules
|
|||||||
and service reloads. Idempotent - a second run changes nothing.
|
and service reloads. Idempotent - a second run changes nothing.
|
||||||
|
|
||||||
Until a run has succeeded (SETUP_DONE), every run starts the bootstrap
|
Until a run has succeeded (SETUP_DONE), every run starts the bootstrap
|
||||||
services, BOOTSTRAP_UNITS, which show the progress and the failure recorded in
|
service, BOOTSTRAP_UNITS, which shows the progress and the failure recorded in
|
||||||
SETUP_STATE on vpnrouter.BOOTSTRAP_PORTS, and the firewall opens those ports on the
|
SETUP_STATE on vpnrouter.BOOTSTRAP_PORTS, and the firewall opens those ports on the
|
||||||
internal interface. The first successful run writes SETUP_DONE, closes the
|
internal interface. The first successful run writes SETUP_DONE, closes the
|
||||||
ports and stops the bootstrap services; a failed run leaves them running.
|
ports and stops the bootstrap service; a failed run leaves it running.
|
||||||
|
|
||||||
Interface names come from the configuration and are never guessed. The
|
Interface names come from the configuration and are never guessed. The
|
||||||
firewall and NAT rules are written against them, so choosing the wrong one
|
firewall and NAT rules are written against them, so choosing the wrong one
|
||||||
@@ -46,18 +46,18 @@ UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
|
|||||||
WG_DIR = pathlib.Path('/etc/wireguard')
|
WG_DIR = pathlib.Path('/etc/wireguard')
|
||||||
WEB_STATE = vpnrouter.WEB_STATE
|
WEB_STATE = vpnrouter.WEB_STATE
|
||||||
|
|
||||||
#: The state, the current step and the failure of the last run, which the bootstrap services show.
|
#: The state, the current step and the failure of the last run, which the bootstrap service shows.
|
||||||
SETUP_STATE = pathlib.Path('/run/vpn-router/setup.json')
|
SETUP_STATE = pathlib.Path('/run/vpn-router/setup.json')
|
||||||
|
|
||||||
#: Written once a run has succeeded; until then setup runs the bootstrap services and opens their ports.
|
#: Written once a run has succeeded; until then setup runs the bootstrap service and opens its ports.
|
||||||
SETUP_DONE = pathlib.Path('/var/lib/vpn-router/setup.done')
|
SETUP_DONE = pathlib.Path('/var/lib/vpn-router/setup.done')
|
||||||
|
|
||||||
#: The units of the bootstrap services, whose ports are vpnrouter.BOOTSTRAP_PORTS.
|
#: The unit of the bootstrap service, whose ports are vpnrouter.BOOTSTRAP_PORTS.
|
||||||
BOOTSTRAP_UNITS = ('vpn-router-bootstrap.service', 'vpn-router-manager-bootstrap.service')
|
BOOTSTRAP_UNITS = ('vpn-router-bootstrap.service',)
|
||||||
|
|
||||||
#: The default admin of the Router Manager, {"username", "password"}, which its package writes from
|
#: The default admin of the Router Manager, {"username", "password"}, which the package's postinst writes from
|
||||||
#: its debconf answers; setup creates the account and removes the file.
|
#: its debconf answers; setup creates the account and removes the file.
|
||||||
MANAGER_ADMIN = pathlib.Path('/etc/vpn-router-manager/admin.json')
|
MANAGER_ADMIN = pathlib.Path('/etc/vpn-router/manager-admin.json')
|
||||||
|
|
||||||
#: The password of the metrics service account, which setup makes and keeps (root only).
|
#: The password of the metrics service account, which setup makes and keeps (root only).
|
||||||
METRICS_SECRET = pathlib.Path('/etc/vpn-router/metrics.secret')
|
METRICS_SECRET = pathlib.Path('/etc/vpn-router/metrics.secret')
|
||||||
@@ -275,7 +275,7 @@ def filter_block(ctx):
|
|||||||
'# BOOTSTRAP RULES END',
|
'# BOOTSTRAP RULES END',
|
||||||
]
|
]
|
||||||
|
|
||||||
if vpnrouter.MANAGER.exists() and interfaces:
|
if interfaces:
|
||||||
port = vpnrouter.manager_port()
|
port = vpnrouter.manager_port()
|
||||||
lines += [
|
lines += [
|
||||||
'',
|
'',
|
||||||
@@ -298,7 +298,7 @@ def filter_block(ctx):
|
|||||||
publish = ctx['pki_publish']
|
publish = ctx['pki_publish']
|
||||||
ports = [port for wanted, port in ((ctx['web_enabled'] or 'https' in publish or ctx['pki_acme'],
|
ports = [port for wanted, port in ((ctx['web_enabled'] or 'https' in publish or ctx['pki_acme'],
|
||||||
ctx['https_web_port']),
|
ctx['https_web_port']),
|
||||||
(vpnrouter.MANAGER.exists(), ctx['https_manager_port']),
|
(True, ctx['https_manager_port']),
|
||||||
(vpnrouter.metrics_ready(ctx), ctx['metrics_https_port']),
|
(vpnrouter.metrics_ready(ctx), ctx['metrics_https_port']),
|
||||||
(vpnrouter.webdav_ready(ctx), ctx['webdav_port']),
|
(vpnrouter.webdav_ready(ctx), ctx['webdav_port']),
|
||||||
('http-80' in publish, vpnrouter.ACME_HTTP_PORT),
|
('http-80' in publish, vpnrouter.ACME_HTTP_PORT),
|
||||||
@@ -697,8 +697,8 @@ def manage_caddy(cfg, ctx):
|
|||||||
"""Run Caddy, the HTTPS proxy of the router's services and the server of the web sites, while either is wanted, and
|
"""Run Caddy, the HTTPS proxy of the router's services and the server of the web sites, while either is wanted, and
|
||||||
stop it when not.
|
stop it when not.
|
||||||
|
|
||||||
While HTTPS is ready, CADDYFILE serves the web service on https.web_port, while the manager is installed the
|
While HTTPS is ready, CADDYFILE serves the web service on https.web_port, the manager on
|
||||||
manager on https.manager_port, while metrics are enabled the metrics service on metrics.https_port, and while
|
https.manager_port, while metrics are enabled the metrics service on metrics.https_port, and while
|
||||||
WebDAV is ready WebDAV on sites.webdav_port, all for wan.local_fqdn with a certificate from Let's Encrypt, and all
|
WebDAV is ready WebDAV on sites.webdav_port, all for wan.local_fqdn with a certificate from Let's Encrypt, and all
|
||||||
calling the service on its loopback address. It also serves the CA files of vpnrouter_pki.PUBLISH_DIR at /pki/ on
|
calling the service on its loopback address. It also serves the CA files of vpnrouter_pki.PUBLISH_DIR at /pki/ on
|
||||||
the endpoints of pki.publish that are Caddy's, and an ACME server at /acme/<name>/ on https.web_port for each
|
the endpoints of pki.publish that are Caddy's, and an ACME server at /acme/<name>/ on https.web_port for each
|
||||||
@@ -778,7 +778,7 @@ def manage_caddy(cfg, ctx):
|
|||||||
os.chown(folder / file, 0, group)
|
os.chown(folder / file, 0, group)
|
||||||
vpnrouter_sites.ensure_folders(site['name'] for site in sites)
|
vpnrouter_sites.ensure_folders(site['name'] for site in sites)
|
||||||
names = vpnrouter.issuing_cas(cfg)
|
names = vpnrouter.issuing_cas(cfg)
|
||||||
caddy_ctx = dict(ctx, https=https, manager_port=vpnrouter.manager_port() if vpnrouter.MANAGER.exists() else 0,
|
caddy_ctx = dict(ctx, https=https, manager_port=vpnrouter.manager_port(),
|
||||||
pki_publish_dir=str(vpnrouter_pki.PUBLISH_DIR),
|
pki_publish_dir=str(vpnrouter_pki.PUBLISH_DIR),
|
||||||
pki_cas=[{'id': name, 'name': names[name]['name'].strip(),
|
pki_cas=[{'id': name, 'name': names[name]['name'].strip(),
|
||||||
'cert': str(CADDY_PKI_DIR / name / 'cert.pem'),
|
'cert': str(CADDY_PKI_DIR / name / 'cert.pem'),
|
||||||
@@ -820,13 +820,33 @@ def manage_webdav(ctx):
|
|||||||
run(['systemctl', 'enable', '--now', WEBDAV_UNIT], check=False)
|
run(['systemctl', 'enable', '--now', WEBDAV_UNIT], check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def site_addresses(cfg, ctx):
|
||||||
|
"""The pairs of a host name and the router's address of dns_address of each enabled site with dns router: public,
|
||||||
|
wan.public_address; internal, the internal interface's; external, the external interface's first. A site whose
|
||||||
|
address is not known gets none, with a warning."""
|
||||||
|
addresses = {'public': cfg['wan']['public_address'].strip(), 'internal': ctx['int_addr'],
|
||||||
|
'external': next(iter(ctx['local_addr_list']), '')}
|
||||||
|
pairs = []
|
||||||
|
for site in ctx['sites']:
|
||||||
|
if site['dns'] != 'router':
|
||||||
|
continue
|
||||||
|
address = addresses[site['dns_address']]
|
||||||
|
if not address:
|
||||||
|
warn(f'site {site["name"]}: the router has no {site["dns_address"]} address, so its host names get no '
|
||||||
|
'records')
|
||||||
|
continue
|
||||||
|
pairs += [(host, address) for host in site['hosts']]
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
def manage_dns(cfg, ctx):
|
def manage_dns(cfg, ctx):
|
||||||
"""Run PowerDNS, the primary name server of the domains of vpnrouter_dns, on the addresses of dns.listen while DNS
|
"""Run PowerDNS, the primary name server of the domains of vpnrouter_dns, on the addresses of dns.listen while DNS
|
||||||
is enabled, and stop it when not; its packages are installed when they are missing.
|
is enabled, and stop it when not; its packages are installed when they are missing.
|
||||||
|
|
||||||
The settings, vpnrouter_dns.PDNS_CONF, are written before the packages are installed, so the server their
|
The settings, vpnrouter_dns.PDNS_CONF, are written before the packages are installed, so the server their
|
||||||
installation starts answers on those addresses only, beside systemd-resolved's stub on 127.0.0.53. The domains
|
installation starts answers on those addresses only, beside systemd-resolved's stub on 127.0.0.53. The host names
|
||||||
with site records get the host names of the enabled sites, and keep those they have while the sites cannot be read.
|
of the enabled sites with dns router get records of the router's address they choose (site_addresses), and the
|
||||||
|
domains keep those they have while the sites cannot be read.
|
||||||
"""
|
"""
|
||||||
if not vpnrouter.dns_ready(ctx):
|
if not vpnrouter.dns_ready(ctx):
|
||||||
if unit_is_active(vpnrouter_dns.UNIT):
|
if unit_is_active(vpnrouter_dns.UNIT):
|
||||||
@@ -854,7 +874,7 @@ def manage_dns(cfg, ctx):
|
|||||||
os.chown(vpnrouter_dns.PDNS_CONF, 0, grp.getgrnam(vpnrouter_dns.GROUP).gr_gid)
|
os.chown(vpnrouter_dns.PDNS_CONF, 0, grp.getgrnam(vpnrouter_dns.GROUP).gr_gid)
|
||||||
vpnrouter_dns.provision()
|
vpnrouter_dns.provision()
|
||||||
if ctx['sites'] is not None:
|
if ctx['sites'] is not None:
|
||||||
vpnrouter_dns.apply(cfg, [host for site in ctx['sites'] for host in site['hosts']])
|
vpnrouter_dns.apply(cfg, site_addresses(cfg, ctx))
|
||||||
except (OSError, KeyError, vpnrouter_dns.DatabaseError) as exc:
|
except (OSError, KeyError, vpnrouter_dns.DatabaseError) as exc:
|
||||||
warn(f'the name server cannot be set up: {exc}')
|
warn(f'the name server cannot be set up: {exc}')
|
||||||
return
|
return
|
||||||
@@ -978,14 +998,14 @@ def progress(state, step, error=None):
|
|||||||
|
|
||||||
|
|
||||||
def start_bootstrap():
|
def start_bootstrap():
|
||||||
"""Start the bootstrap services while no run has succeeded; a unit that is not installed is skipped."""
|
"""Start the bootstrap service while no run has succeeded."""
|
||||||
if not SETUP_DONE.exists():
|
if not SETUP_DONE.exists():
|
||||||
for unit in BOOTSTRAP_UNITS:
|
for unit in BOOTSTRAP_UNITS:
|
||||||
run(['systemctl', 'start', '--no-block', unit], check=False)
|
run(['systemctl', 'start', '--no-block', unit], check=False)
|
||||||
|
|
||||||
|
|
||||||
def end_bootstrap(ctx):
|
def end_bootstrap(ctx):
|
||||||
"""After the first successful run: write SETUP_DONE, close the bootstrap ports and stop the bootstrap services."""
|
"""After the first successful run: write SETUP_DONE, close the bootstrap ports and stop the bootstrap service."""
|
||||||
if SETUP_DONE.exists():
|
if SETUP_DONE.exists():
|
||||||
return
|
return
|
||||||
SETUP_DONE.parent.mkdir(parents=True, exist_ok=True)
|
SETUP_DONE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
"""Upgrade the router's package, vpn-router, or with --system every package of the system, recording the state and
|
||||||
|
the output.
|
||||||
|
|
||||||
|
Started by the Router Manager as the transient unit vpn-router-manager-upgrade, or with --system
|
||||||
|
vpn-router-manager-system-upgrade (systemd-run), so it runs outside the manager's unit: the
|
||||||
|
restarts of the manager and of the router's web service that the package's scripts make do not
|
||||||
|
stop it. Usage: upgrade [--system] <user>, the user who started it.
|
||||||
|
|
||||||
|
The state file, STATE_FILE or with --system SYSTEM_STATE_FILE, holds the state, running while it
|
||||||
|
runs, then succeeded or failed, with the user, the start and the end, the exit status of apt-get
|
||||||
|
and the installed versions of PACKAGES before and after; it is replaced by a rename, so a reader
|
||||||
|
never sees half of it. The log file, LOG_FILE or SYSTEM_LOG_FILE, holds the output of dpkg and
|
||||||
|
apt-get, from the start of the run. A dpkg run that was interrupted is finished first
|
||||||
|
(dpkg --configure -a). Then apt-get install --only-upgrade PACKAGES, or with --system apt-get
|
||||||
|
upgrade, which upgrades the installed packages without installing or removing any. apt-get waits
|
||||||
|
up to LOCK_SECONDS for the dpkg lock, which Ubuntu's apt-daily or unattended-upgrades may hold,
|
||||||
|
takes the installed version of a changed configuration file and asks nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
STATE_DIR = '/var/lib/vpn-router'
|
||||||
|
STATE_FILE = os.path.join(STATE_DIR, 'upgrade.json')
|
||||||
|
LOG_FILE = os.path.join(STATE_DIR, 'upgrade.log')
|
||||||
|
SYSTEM_STATE_FILE = os.path.join(STATE_DIR, 'system-upgrade.json')
|
||||||
|
SYSTEM_LOG_FILE = os.path.join(STATE_DIR, 'system-upgrade.log')
|
||||||
|
|
||||||
|
PACKAGES = ('vpn-router',)
|
||||||
|
|
||||||
|
#: Seconds apt-get waits for the dpkg lock.
|
||||||
|
LOCK_SECONDS = 300
|
||||||
|
|
||||||
|
#: The environment of dpkg and apt-get: no questions, the installed configuration files kept, and needrestart left out,
|
||||||
|
#: as the package's scripts restart their own services.
|
||||||
|
ENVIRONMENT = {
|
||||||
|
'DEBIAN_FRONTEND': 'noninteractive',
|
||||||
|
'UCF_FORCE_CONFFOLD': '1',
|
||||||
|
'APT_LISTCHANGES_FRONTEND': 'none',
|
||||||
|
'NEEDRESTART_SUSPEND': '1',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def now():
|
||||||
|
return time.strftime('%Y-%m-%dT%H:%M:%S%z')
|
||||||
|
|
||||||
|
|
||||||
|
def versions():
|
||||||
|
"""The installed version of each of PACKAGES, None for one that is not installed."""
|
||||||
|
found = {}
|
||||||
|
for package in PACKAGES:
|
||||||
|
result = subprocess.run(['dpkg-query', '-W', '-f=${Version}', package], capture_output=True, text=True)
|
||||||
|
found[package] = (result.stdout.strip() or None) if result.returncode == 0 else None
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def save(state, path):
|
||||||
|
"""Write the state to the file of path by a rename."""
|
||||||
|
temporary = path + '.new'
|
||||||
|
with open(temporary, 'w', encoding='utf-8') as handle:
|
||||||
|
json.dump(state, handle)
|
||||||
|
handle.write('\n')
|
||||||
|
os.chmod(temporary, 0o644)
|
||||||
|
os.replace(temporary, path)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
system = argv[:1] == ['--system']
|
||||||
|
if system:
|
||||||
|
argv = argv[1:]
|
||||||
|
if len(argv) != 1:
|
||||||
|
print('usage: upgrade [--system] <user>', file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
state_file, log_file = (SYSTEM_STATE_FILE, SYSTEM_LOG_FILE) if system else (STATE_FILE, LOG_FILE)
|
||||||
|
os.makedirs(STATE_DIR, exist_ok=True)
|
||||||
|
state = {'state': 'running', 'user': argv[0], 'started': now(), 'finished': None, 'exit_status': None,
|
||||||
|
'before': versions(), 'after': None}
|
||||||
|
save(state, state_file)
|
||||||
|
environment = dict(os.environ, **ENVIRONMENT)
|
||||||
|
with open(log_file, 'w', encoding='utf-8') as log:
|
||||||
|
os.chmod(log_file, 0o644)
|
||||||
|
status = 1
|
||||||
|
for command in (
|
||||||
|
['dpkg', '--configure', '-a'],
|
||||||
|
['apt-get', '-y', '-q', '-o', f'DPkg::Lock::Timeout={LOCK_SECONDS}', '-o', 'Dpkg::Options::=--force-confdef',
|
||||||
|
'-o', 'Dpkg::Options::=--force-confold', *(['upgrade'] if system else ['install', '--only-upgrade', *PACKAGES])],
|
||||||
|
):
|
||||||
|
log.write(f'$ {" ".join(command)}\n')
|
||||||
|
log.flush()
|
||||||
|
try:
|
||||||
|
status = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, env=environment).returncode
|
||||||
|
except OSError as exc:
|
||||||
|
log.write(f'{exc}\n')
|
||||||
|
status = 1
|
||||||
|
if status != 0:
|
||||||
|
break
|
||||||
|
log.write(f'exit status {status}\n')
|
||||||
|
state.update(state='succeeded' if status == 0 else 'failed', finished=now(), exit_status=status, after=versions())
|
||||||
|
save(state, state_file)
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
@@ -55,7 +55,7 @@ OPENVPN_LDAP = pathlib.Path('/etc/openvpn/server/vpn-router-ldap.conf')
|
|||||||
SCHEMA = {
|
SCHEMA = {
|
||||||
'general': {'platform': 'none', 'mode': 'manual'},
|
'general': {'platform': 'none', 'mode': 'manual'},
|
||||||
'interfaces': {'external': '', 'internal': ''},
|
'interfaces': {'external': '', 'internal': ''},
|
||||||
'wan': {'local_fqdn': ''},
|
'wan': {'local_fqdn': '', 'public_address': ''},
|
||||||
'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''},
|
'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''},
|
||||||
'site_to_site': {},
|
'site_to_site': {},
|
||||||
'p2s': {'enabled': False, 'address_pool': '', 'dns_listen': 'internal', 'dns_domains': []},
|
'p2s': {'enabled': False, 'address_pool': '', 'dns_listen': 'internal', 'dns_domains': []},
|
||||||
@@ -70,7 +70,7 @@ SCHEMA = {
|
|||||||
'acme_staging': False},
|
'acme_staging': False},
|
||||||
'oidc': {'enabled': False},
|
'oidc': {'enabled': False},
|
||||||
'sites': {'enabled': False, 'upload_max_mb': 512, 'webdav': False, 'webdav_port': 7445},
|
'sites': {'enabled': False, 'upload_max_mb': 512, 'webdav': False, 'webdav_port': 7445},
|
||||||
'dns': {'enabled': False, 'nameserver': '', 'addresses': [], 'listen': 'external', 'ttl': 300},
|
'dns': {'enabled': False, 'secondary': '', 'listen': 'external', 'ttl': 300},
|
||||||
'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',
|
||||||
'crl_days': 7, 'root_crl_days': 180, 'publish': ['http'], 'p2s_name': 'VPN Router P2S CA',
|
'crl_days': 7, 'root_crl_days': 180, 'publish': ['http'], 'p2s_name': 'VPN Router P2S CA',
|
||||||
's2s_name': 'VPN Router Site-to-Site CA', 'issuing_cas': {}, 'templates': {},
|
's2s_name': 'VPN Router Site-to-Site CA', 'issuing_cas': {}, 'templates': {},
|
||||||
@@ -144,22 +144,25 @@ WEBDAV_LOOPBACK_PORT = 7446
|
|||||||
#: The largest sites.upload_max_mb, 10 GiB.
|
#: The largest sites.upload_max_mb, 10 GiB.
|
||||||
UPLOAD_MAX_MB = 10240
|
UPLOAD_MAX_MB = 10240
|
||||||
|
|
||||||
|
#: Settings that no longer exist, section -> options, left out when the file is read so that a file of an earlier
|
||||||
|
#: version loads: the name server is wan.local_fqdn and a site chooses the address of its records.
|
||||||
|
RETIRED = {'dns': ('nameserver', 'addresses')}
|
||||||
|
|
||||||
#: The shortest and the longest dns.ttl, in seconds.
|
#: The shortest and the longest dns.ttl, in seconds.
|
||||||
DNS_TTL_RANGE = (60, 86400)
|
DNS_TTL_RANGE = (60, 86400)
|
||||||
|
|
||||||
#: A DNS name without a wildcard, in lowercase, of dns.nameserver and the domains of the name server.
|
#: A DNS name without a wildcard, in lowercase, of the name servers and the domains of the name server.
|
||||||
ZONE_NAME = re.compile(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$')
|
ZONE_NAME = re.compile(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$')
|
||||||
|
|
||||||
#: A domain of p2s.dns_domains, one label or more, and the most domains.
|
#: A domain of p2s.dns_domains, one label or more, and the most domains.
|
||||||
DNS_DOMAIN = re.compile(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$')
|
DNS_DOMAIN = re.compile(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$')
|
||||||
DNS_DOMAINS_MAX = 16
|
DNS_DOMAINS_MAX = 16
|
||||||
|
|
||||||
#: The ports of the bootstrap services: the Router Manager's and the router's.
|
#: The ports of the bootstrap service.
|
||||||
BOOTSTRAP_PORTS = (17080, 17081)
|
BOOTSTRAP_PORTS = (17081,)
|
||||||
|
|
||||||
#: The Router Manager, vpn-router-manager, when it is installed, its settings and its port when they set none.
|
#: The settings of the Router Manager, and its port when they set none.
|
||||||
MANAGER = pathlib.Path('/usr/lib/vpn-router-manager/manager')
|
MANAGER_CONFIG = pathlib.Path('/etc/vpn-router/manager.json')
|
||||||
MANAGER_CONFIG = pathlib.Path('/etc/vpn-router-manager/manager.json')
|
|
||||||
MANAGER_PORT = 7080
|
MANAGER_PORT = 7080
|
||||||
|
|
||||||
#: Where vpn-router-setup installs Caddy from, https.caddy_source: the Ubuntu archive or Caddy's own repository.
|
#: Where vpn-router-setup installs Caddy from, https.caddy_source: the Ubuntu archive or Caddy's own repository.
|
||||||
@@ -300,6 +303,9 @@ def read_settings(path=None):
|
|||||||
for section, options in data.items():
|
for section, options in data.items():
|
||||||
if not isinstance(options, dict):
|
if not isinstance(options, dict):
|
||||||
raise ConfigError(f'cannot parse {path}: {section} is not an object')
|
raise ConfigError(f'cannot parse {path}: {section} is not an object')
|
||||||
|
for section, options in RETIRED.items():
|
||||||
|
for option in options:
|
||||||
|
data.get(section, {}).pop(option, None)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -581,14 +587,17 @@ def validate(cfg):
|
|||||||
raise ConfigError('sites.webdav: WebDAV is served by Caddy at https://<wan.local_fqdn>:<sites.webdav_port>, '
|
raise ConfigError('sites.webdav: WebDAV is served by Caddy at https://<wan.local_fqdn>:<sites.webdav_port>, '
|
||||||
'which needs https.enabled and wan.local_fqdn')
|
'which needs https.enabled and wan.local_fqdn')
|
||||||
|
|
||||||
|
_check_address(_get(cfg, 'wan', 'public_address'), 'wan.public_address')
|
||||||
dns = cfg['dns']
|
dns = cfg['dns']
|
||||||
if dns['enabled']:
|
if dns['enabled'] and not (ZONE_NAME.match(_get(cfg, 'wan', 'local_fqdn'))
|
||||||
if not ZONE_NAME.match(dns['nameserver'].strip()):
|
and not re.search(r'\.[0-9]+$', _get(cfg, 'wan', 'local_fqdn'))):
|
||||||
raise ConfigError(f'dns.nameserver: {dns["nameserver"]!r} is not a DNS name in lowercase')
|
raise ConfigError('dns.enabled needs wan.local_fqdn, a DNS name in lowercase: the primary name server of the '
|
||||||
if not _get_list(cfg, 'dns', 'addresses'):
|
'domains')
|
||||||
raise ConfigError('dns.enabled needs dns.addresses, the public addresses of the name server and the sites')
|
secondary = _get(cfg, 'dns', 'secondary')
|
||||||
for item in _get_list(cfg, 'dns', 'addresses'):
|
if secondary and not ZONE_NAME.match(secondary):
|
||||||
_check_address(item, 'dns.addresses')
|
raise ConfigError(f'dns.secondary: {secondary!r} is not a DNS name in lowercase')
|
||||||
|
if secondary and secondary == _get(cfg, 'wan', 'local_fqdn'):
|
||||||
|
raise ConfigError('dns.secondary: the secondary name server is not wan.local_fqdn, the primary')
|
||||||
if dns['listen'] not in LISTEN_CHOICES:
|
if dns['listen'] not in LISTEN_CHOICES:
|
||||||
raise ConfigError(f'dns.listen: {dns["listen"]!r} is not one of {", ".join(LISTEN_CHOICES)}')
|
raise ConfigError(f'dns.listen: {dns["listen"]!r} is not one of {", ".join(LISTEN_CHOICES)}')
|
||||||
if not DNS_TTL_RANGE[0] <= dns['ttl'] <= DNS_TTL_RANGE[1]:
|
if not DNS_TTL_RANGE[0] <= dns['ttl'] <= DNS_TTL_RANGE[1]:
|
||||||
|
|||||||
@@ -52,8 +52,7 @@ UNIT = 'vpn-router-backup.service'
|
|||||||
TIMER = 'vpn-router-backup.timer'
|
TIMER = 'vpn-router-backup.timer'
|
||||||
|
|
||||||
#: The state a backup copies: directories and files, each where it is on the system.
|
#: The state a backup copies: directories and files, each where it is on the system.
|
||||||
TREES = ('/etc/vpn-router', '/etc/wireguard', '/etc/ufw', '/etc/default/ufw', '/etc/vpn-router-manager',
|
TREES = ('/etc/vpn-router', '/etc/wireguard', '/etc/ufw', '/etc/default/ufw', str(vpnrouter_sites.SITES_DIR))
|
||||||
str(vpnrouter_sites.SITES_DIR))
|
|
||||||
|
|
||||||
#: The databases a backup always dumps, each while it exists: the dump -> the database and its password file.
|
#: The databases a backup always dumps, each while it exists: the dump -> the database and its password file.
|
||||||
DUMPS = {'postgresql/vpn-router-sites.dump': (vpnrouter_sites.DATABASE, vpnrouter_sites.DATABASE_SECRET),
|
DUMPS = {'postgresql/vpn-router-sites.dump': (vpnrouter_sites.DATABASE, vpnrouter_sites.DATABASE_SECRET),
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
"""The router's authoritative name server, PowerDNS with its gpgsql backend, for vpn-router-setup and the web service.
|
"""The router's authoritative name server, PowerDNS with its gpgsql backend, for vpn-router-setup and the web service.
|
||||||
|
|
||||||
The name server is the primary of its domains and answers on the addresses of the interfaces of dns.listen. The
|
The name server is the primary of its domains and answers on the addresses of the interfaces of dns.listen. The
|
||||||
domains, their secondary name servers and the records an administrator added (manual records) are in the
|
domains and the records an administrator added (manual records) are in the vpn-router-dns database, which
|
||||||
vpn-router-dns database, which provision() creates with its role and the password in DATABASE_SECRET, and fills with
|
provision() creates with its role and the password in DATABASE_SECRET, and fills with the schema of
|
||||||
the schema of pdns-backend-pgsql, PDNS_SCHEMA; DNS_SQL defines the router's own tables and procedures, and the first
|
pdns-backend-pgsql, PDNS_SCHEMA; DNS_SQL defines the router's own tables and procedures, and the first connection of a
|
||||||
connection of a process loads it.
|
process loads it.
|
||||||
|
|
||||||
apply() writes every domain as a primary zone: its SOA with dns.nameserver, its NS, dns.nameserver and the name
|
apply() writes every domain as a primary zone: its SOA with wan.local_fqdn as the primary name server, its NS,
|
||||||
servers of its secondaries, the NS of the domains directly below it, A and AAAA records of dns.addresses for its apex,
|
wan.local_fqdn and dns.secondary, the NS of the domains directly below it, an A or AAAA record for every host name of
|
||||||
for those name servers inside it that are the router, and, when the domain has site records, for every host name of an
|
an enabled site with dns router whose closest domain it is, with the router's address the site chooses, and the manual
|
||||||
enabled site whose closest domain it is, and the manual records, but those of a name and type the records before them
|
records, but those of a name and type the records before them have. wan.local_fqdn gets no record in the zones: its
|
||||||
have. A secondary's address is notified of every change (ALSO-NOTIFY) and may transfer the zone (ALLOW-AXFR-FROM); the
|
record is kept where its own domain is. The addresses dns.secondary resolves to are notified of every change
|
||||||
serial of the SOA grows only when the records change.
|
(ALSO-NOTIFY) and may transfer the zones (ALLOW-AXFR-FROM); the serial of the SOA grows only when the records change.
|
||||||
|
|
||||||
Every failure of the database raises DatabaseError; a request that cannot be done raises Failure.
|
Every failure of the database raises DatabaseError; a request that cannot be done raises Failure.
|
||||||
"""
|
"""
|
||||||
@@ -20,6 +20,7 @@ import contextlib
|
|||||||
import ipaddress
|
import ipaddress
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
|
import socket
|
||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
@@ -44,9 +45,8 @@ GROUP = 'pdns'
|
|||||||
#: The types of the manual records.
|
#: The types of the manual records.
|
||||||
TYPES = ('A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'CAA')
|
TYPES = ('A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'CAA')
|
||||||
|
|
||||||
#: The most domains, secondaries of a domain, and manual records of a domain.
|
#: The most domains, and manual records of a domain.
|
||||||
DOMAINS_MAX = 100
|
DOMAINS_MAX = 100
|
||||||
SECONDARIES_MAX = 8
|
|
||||||
MANUAL_MAX = 1000
|
MANUAL_MAX = 1000
|
||||||
|
|
||||||
#: The refresh, retry and expire times of the SOA, in seconds.
|
#: The refresh, retry and expire times of the SOA, in seconds.
|
||||||
@@ -73,8 +73,7 @@ class DatabaseError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class Failure(Exception):
|
class Failure(Exception):
|
||||||
"""A request that cannot be done; status is 2 invalid, 3 no such domain, secondary or record, 4 the domain
|
"""A request that cannot be done; status is 2 invalid, 3 no such domain or record, 4 the domain exists."""
|
||||||
exists."""
|
|
||||||
|
|
||||||
def __init__(self, message, status=2):
|
def __init__(self, message, status=2):
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
@@ -211,13 +210,10 @@ def domain(connection, name):
|
|||||||
|
|
||||||
|
|
||||||
def listing():
|
def listing():
|
||||||
"""Every domain with its secondaries, its manual records and the records the name server answers for it."""
|
"""Every domain with its manual records and the records the name server answers for it."""
|
||||||
with database() as connection:
|
with database() as connection:
|
||||||
secondaries = connection.execute('SELECT * FROM dns_secondaries_list()').fetchall()
|
|
||||||
manual = connection.execute('SELECT * FROM dns_records_list()').fetchall()
|
manual = connection.execute('SELECT * FROM dns_records_list()').fetchall()
|
||||||
return [{**row,
|
return [{**row,
|
||||||
'secondaries': [{key: item[key] for key in ('nameserver', 'address')}
|
|
||||||
for item in secondaries if item['domain'] == row['name']],
|
|
||||||
'manual': [{key: item[key] for key in ('name', 'type', 'content', 'ttl')}
|
'manual': [{key: item[key] for key in ('name', 'type', 'content', 'ttl')}
|
||||||
for item in manual if item['domain'] == row['name']],
|
for item in manual if item['domain'] == row['name']],
|
||||||
'records': connection.execute('SELECT * FROM dns_served(%s)', (row['name'],)).fetchall()}
|
'records': connection.execute('SELECT * FROM dns_served(%s)', (row['name'],)).fetchall()}
|
||||||
@@ -225,71 +221,52 @@ def listing():
|
|||||||
|
|
||||||
|
|
||||||
def domain_add(body):
|
def domain_add(body):
|
||||||
"""Add the domain {name, sites, ttl?}; the domain."""
|
"""Add the domain {name, ttl?}; the domain."""
|
||||||
if not isinstance(body, dict) or not isinstance(body.get('sites', False), bool):
|
if not isinstance(body, dict) or not set(body) <= {'name', 'ttl'}:
|
||||||
raise Failure('a domain is an object of name, sites and ttl')
|
raise Failure('a domain is an object of name and ttl')
|
||||||
name, sites, ttl = check_domain(body.get('name')), body.get('sites', False), check_ttl(body.get('ttl'))
|
name, ttl = check_domain(body.get('name')), check_ttl(body.get('ttl'))
|
||||||
with database() as connection:
|
with database() as connection:
|
||||||
names = [row['name'] for row in connection.execute('SELECT * FROM dns_domains_list()').fetchall()]
|
names = [row['name'] for row in connection.execute('SELECT * FROM dns_domains_list()').fetchall()]
|
||||||
if name in names:
|
if name in names:
|
||||||
raise Failure(f'the domain {name} exists', 4)
|
raise Failure(f'the domain {name} exists', 4)
|
||||||
if len(names) >= DOMAINS_MAX:
|
if len(names) >= DOMAINS_MAX:
|
||||||
raise Failure(f'the name server has {DOMAINS_MAX} domains')
|
raise Failure(f'the name server has {DOMAINS_MAX} domains')
|
||||||
connection.execute('CALL dns_domain_put(%s, %s, %s)', (name, sites, ttl))
|
connection.execute('CALL dns_domain_put(%s, %s)', (name, ttl))
|
||||||
return {'name': name, 'sites': sites, 'ttl': ttl}
|
return {'name': name, 'ttl': ttl}
|
||||||
|
|
||||||
|
|
||||||
def domain_change(name, body):
|
def domain_change(name, body):
|
||||||
"""Change the sites and ttl of the domain; the domain."""
|
"""Change the ttl of the domain; the domain."""
|
||||||
if not isinstance(body, dict) or not set(body) <= {'sites', 'ttl'} or \
|
if not isinstance(body, dict) or not set(body) <= {'ttl'}:
|
||||||
not isinstance(body.get('sites', False), bool):
|
raise Failure('a change of a domain is an object of ttl')
|
||||||
raise Failure('a change of a domain is an object of sites and ttl')
|
|
||||||
with database() as connection:
|
with database() as connection:
|
||||||
row = domain(connection, name)
|
row = domain(connection, name)
|
||||||
sites, ttl = body.get('sites', row['sites']), check_ttl(body.get('ttl', row['ttl']))
|
ttl = check_ttl(body.get('ttl', row['ttl']))
|
||||||
connection.execute('CALL dns_domain_put(%s, %s, %s)', (name, sites, ttl))
|
connection.execute('CALL dns_domain_put(%s, %s)', (name, ttl))
|
||||||
return {'name': name, 'sites': sites, 'ttl': ttl}
|
return {'name': name, 'ttl': ttl}
|
||||||
|
|
||||||
|
|
||||||
def domain_remove(name):
|
def domain_remove(name):
|
||||||
"""Remove the domain with its secondaries and manual records."""
|
"""Remove the domain with its manual records."""
|
||||||
with database() as connection:
|
with database() as connection:
|
||||||
domain(connection, name)
|
domain(connection, name)
|
||||||
connection.execute('CALL dns_domain_remove(%s)', (name,))
|
connection.execute('CALL dns_domain_remove(%s)', (name,))
|
||||||
return {'name': name}
|
return {'name': name}
|
||||||
|
|
||||||
|
|
||||||
def secondary_add(name, body):
|
def secondary_addresses(cfg):
|
||||||
"""Add the secondary name server {nameserver, address} of the domain, or set its address; the secondary."""
|
"""The addresses dns.secondary resolves to, which are notified of the zones' changes and may transfer them; none,
|
||||||
if not isinstance(body, dict):
|
with a warning, while it is not set or does not resolve."""
|
||||||
raise Failure('a secondary is an object of nameserver and address')
|
secondary = cfg['dns']['secondary'].strip()
|
||||||
nameserver = body.get('nameserver')
|
if not secondary:
|
||||||
if not isinstance(nameserver, str) or not vpnrouter.ZONE_NAME.match(nameserver.strip().lower().rstrip('.')):
|
vpnrouter.warn('dns.secondary is not set: the domains have no secondary name server')
|
||||||
raise Failure(f'{nameserver!r} is not a name server name')
|
return []
|
||||||
nameserver = nameserver.strip().lower().rstrip('.')
|
|
||||||
try:
|
try:
|
||||||
address = str(ipaddress.ip_address(str(body.get('address', '')).strip()))
|
found = socket.getaddrinfo(secondary, 53, proto=socket.IPPROTO_UDP)
|
||||||
except ValueError as exc:
|
except OSError as exc:
|
||||||
raise Failure(f'{body.get("address")!r} is not an IP address') from exc
|
vpnrouter.warn(f'dns.secondary {secondary} does not resolve, so it may not transfer the domains: {exc}')
|
||||||
with database() as connection:
|
return []
|
||||||
domain(connection, name)
|
return list(dict.fromkeys(str(ipaddress.ip_address(item[4][0])) for item in found))
|
||||||
others = [row for row in connection.execute('SELECT * FROM dns_secondaries_list()').fetchall()
|
|
||||||
if row['domain'] == name and row['nameserver'] != nameserver]
|
|
||||||
if len(others) >= SECONDARIES_MAX:
|
|
||||||
raise Failure(f'the domain {name} has {SECONDARIES_MAX} secondaries')
|
|
||||||
connection.execute('CALL dns_secondary_put(%s, %s, %s)', (name, nameserver, address))
|
|
||||||
return {'domain': name, 'nameserver': nameserver, 'address': address}
|
|
||||||
|
|
||||||
|
|
||||||
def secondary_remove(name, nameserver):
|
|
||||||
"""Remove the secondary name server of the domain."""
|
|
||||||
with database() as connection:
|
|
||||||
domain(connection, name)
|
|
||||||
if not any(row['domain'] == name and row['nameserver'] == nameserver
|
|
||||||
for row in connection.execute('SELECT * FROM dns_secondaries_list()').fetchall()):
|
|
||||||
raise Failure(f'no secondary {nameserver} of {name}', 3)
|
|
||||||
connection.execute('CALL dns_secondary_remove(%s, %s)', (name, nameserver))
|
|
||||||
return {'domain': name, 'nameserver': nameserver}
|
|
||||||
|
|
||||||
|
|
||||||
def add(cfg, name, record):
|
def add(cfg, name, record):
|
||||||
@@ -332,25 +309,20 @@ def remove(name, record_name, kind, content):
|
|||||||
return {'name': full, 'type': kind, 'removed': len(found)}
|
return {'name': full, 'type': kind, 'removed': len(found)}
|
||||||
|
|
||||||
|
|
||||||
def records(cfg, row, zones, secondaries, hosts, manual_records):
|
def records(cfg, row, zones, hosts, manual_records):
|
||||||
"""The records of the domain row besides its SOA, as (name, type, content, ttl): the NS of the domain and of the
|
"""The records of the domain row besides its SOA, as (name, type, content, ttl): the NS of the domain and of the
|
||||||
domains directly below it, the A and AAAA records of dns.addresses for its apex, dns.nameserver when it is inside
|
domains directly below it, wan.local_fqdn and dns.secondary, the A or AAAA record of each host name of hosts, pairs
|
||||||
and, with site records, each host name whose closest domain it is, the glue of the secondaries inside it, and its
|
of a host name and an address, whose closest domain it is, and its manual records."""
|
||||||
manual records. secondaries maps a domain to its secondaries."""
|
|
||||||
dns = cfg['dns']
|
dns = cfg['dns']
|
||||||
zone, nameserver, ttl = row['name'], dns['nameserver'].strip(), row['ttl'] or dns['ttl']
|
zone, ttl = row['name'], row['ttl'] or dns['ttl']
|
||||||
addresses = [ipaddress.ip_address(item.strip()) for item in dns['addresses'] if item.strip()]
|
servers = [cfg['wan']['local_fqdn'].strip(), *([dns['secondary'].strip()] if dns['secondary'].strip() else [])]
|
||||||
children = [name for name in zones
|
children = [name for name in zones
|
||||||
if name != zone and closest([item for item in zones if item != name], name) == zone]
|
if name != zone and closest([item for item in zones if item != name], name) == zone]
|
||||||
found = [(name, 'NS', server, ttl) for name in [zone, *children]
|
found = [(name, 'NS', server, ttl) for name in [zone, *children] for server in servers]
|
||||||
for server in [nameserver, *(item['nameserver'] for item in secondaries.get(name, []))]]
|
for host, address in hosts:
|
||||||
for name in dict.fromkeys([zone, *([nameserver] if in_zone(zone, nameserver) else []),
|
if closest(zones, host) == zone:
|
||||||
*(host for host in hosts if row['sites'] and closest(zones, host) == zone)]):
|
address = ipaddress.ip_address(address)
|
||||||
found += [(name, 'A' if address.version == 4 else 'AAAA', str(address), ttl) for address in addresses]
|
found.append((host, 'A' if address.version == 4 else 'AAAA', str(address), ttl))
|
||||||
for item in (item for name in [zone, *children] for item in secondaries.get(name, [])):
|
|
||||||
if in_zone(zone, item['nameserver']) and item['nameserver'] != nameserver:
|
|
||||||
address = ipaddress.ip_address(item['address'])
|
|
||||||
found.append((item['nameserver'], 'A' if address.version == 4 else 'AAAA', str(address), ttl))
|
|
||||||
taken = {(name, kind) for name, kind, _, _ in found}
|
taken = {(name, kind) for name, kind, _, _ in found}
|
||||||
found += [(item['name'], item['type'], item['content'], item['ttl']) for item in manual_records
|
found += [(item['name'], item['type'], item['content'], item['ttl']) for item in manual_records
|
||||||
if (item['name'], item['type']) not in taken]
|
if (item['name'], item['type']) not in taken]
|
||||||
@@ -358,7 +330,8 @@ def records(cfg, row, zones, secondaries, hosts, manual_records):
|
|||||||
|
|
||||||
|
|
||||||
def apply(cfg, hosts):
|
def apply(cfg, hosts):
|
||||||
"""Make the name server the primary of every domain with records(), and of none while dns.enabled is false."""
|
"""Make the name server the primary of every domain with records(), hosts being the pairs of a host name and an
|
||||||
|
address of the sites with dns router, and of none while dns.enabled is false."""
|
||||||
with database() as connection:
|
with database() as connection:
|
||||||
if not cfg['dns']['enabled']:
|
if not cfg['dns']['enabled']:
|
||||||
connection.execute('CALL dns_retain(%s::text[])', ([],))
|
connection.execute('CALL dns_retain(%s::text[])', ([],))
|
||||||
@@ -366,20 +339,21 @@ def apply(cfg, hosts):
|
|||||||
dns = cfg['dns']
|
dns = cfg['dns']
|
||||||
domains = connection.execute('SELECT * FROM dns_domains_list()').fetchall()
|
domains = connection.execute('SELECT * FROM dns_domains_list()').fetchall()
|
||||||
zones = [row['name'] for row in domains]
|
zones = [row['name'] for row in domains]
|
||||||
secondaries, manual = {}, {}
|
for host, _ in hosts:
|
||||||
for item in connection.execute('SELECT * FROM dns_secondaries_list()').fetchall():
|
if closest(zones, host) is None:
|
||||||
secondaries.setdefault(item['domain'], []).append(item)
|
vpnrouter.warn(f'{host} is in none of the domains, so the name server has no record of it')
|
||||||
|
manual = {}
|
||||||
for item in connection.execute('SELECT * FROM dns_records_list()').fetchall():
|
for item in connection.execute('SELECT * FROM dns_records_list()').fetchall():
|
||||||
manual.setdefault(item['domain'], []).append(item)
|
manual.setdefault(item['domain'], []).append(item)
|
||||||
|
transfers = secondary_addresses(cfg) if domains else []
|
||||||
for row in domains:
|
for row in domains:
|
||||||
zone, ttl = row['name'], row['ttl'] or dns['ttl']
|
zone, ttl = row['name'], row['ttl'] or dns['ttl']
|
||||||
rows = records(cfg, row, zones, secondaries, hosts, manual.get(zone, []))
|
rows = records(cfg, row, zones, hosts, manual.get(zone, []))
|
||||||
soa = f'{dns["nameserver"].strip()} hostmaster.{zone} {{serial}} {SOA_TIMES} {ttl}'
|
soa = f'{cfg["wan"]["local_fqdn"].strip()} hostmaster.{zone} {{serial}} {SOA_TIMES} {ttl}'
|
||||||
connection.execute('CALL dns_apply(%s, %s, %s, %s::text[], %s::text[], %s::text[], %s::integer[], '
|
connection.execute('CALL dns_apply(%s, %s, %s, %s::text[], %s::text[], %s::text[], %s::integer[], '
|
||||||
'%s::text[])',
|
'%s::text[])',
|
||||||
(zone, soa, ttl, [item[0] for item in rows], [item[1] for item in rows],
|
(zone, soa, ttl, [item[0] for item in rows], [item[1] for item in rows],
|
||||||
[item[2] for item in rows], [item[3] for item in rows],
|
[item[2] for item in rows], [item[3] for item in rows], transfers))
|
||||||
[item['address'] for item in secondaries.get(zone, [])]))
|
|
||||||
connection.execute('CALL dns_retain(%s::text[])', (zones,))
|
connection.execute('CALL dns_retain(%s::text[])', (zones,))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""The settings of the Router Manager and its listening loop.
|
||||||
|
|
||||||
|
The settings file (CONFIG_FILE unless another is given) sets, all optional: router_url, database,
|
||||||
|
listen, bind, port, terraform_dir and iac_tool. bind is the one address to listen on. Without
|
||||||
|
router_url, terraform_dir names the Terraform root module whose output router_web_url gives the
|
||||||
|
URL of the router's web service, read with iac_tool, terraform (the default) or tofu; dev.sh uses
|
||||||
|
it to run the manager on a workstation. A setting that is not given comes from the router's
|
||||||
|
configuration, ROUTER_CONFIG_FILE: without bind the manager listens on the addresses of the
|
||||||
|
interfaces listen names, internal, external or both (the default), and on the loopback address,
|
||||||
|
where the router's HTTPS proxy calls it, router_url is the first address and web.port of the
|
||||||
|
router's web service, and database is the DATABASE database on this host, with the password in
|
||||||
|
DATABASE_SECRET.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
|
|
||||||
|
import vpnrouter_net
|
||||||
|
|
||||||
|
CONFIG_FILE = '/etc/vpn-router/manager.json'
|
||||||
|
ROUTER_CONFIG_FILE = '/etc/vpn-router/vpn-router.json'
|
||||||
|
DATABASE_SECRET = '/etc/vpn-router/manager-database.secret'
|
||||||
|
|
||||||
|
#: The role and the database the manager creates on the router.
|
||||||
|
DATABASE = 'vpn-router-manager'
|
||||||
|
|
||||||
|
#: The port when port is not set.
|
||||||
|
DEFAULT_PORT = 7080
|
||||||
|
|
||||||
|
#: The port of the router's web service when web.port is not set.
|
||||||
|
ROUTER_PORT = 7081
|
||||||
|
|
||||||
|
#: The HTTPS port of the router's web service, on its HTTPS proxy, when https.web_port is not set.
|
||||||
|
ROUTER_HTTPS_PORT = 7444
|
||||||
|
|
||||||
|
#: Seconds the Terraform outputs are kept; terraform output is not cheap.
|
||||||
|
OUTPUTS_SECONDS = 30
|
||||||
|
|
||||||
|
_outputs = {}
|
||||||
|
_outputs_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 text_setting(configuration, name):
|
||||||
|
"""A string setting, or '' when it is missing or not a string."""
|
||||||
|
value = configuration.get(name)
|
||||||
|
return value.strip() if isinstance(value, str) else ''
|
||||||
|
|
||||||
|
|
||||||
|
def port_setting(value, default):
|
||||||
|
"""A port number, or the default when the value is not one."""
|
||||||
|
return value if isinstance(value, int) and not isinstance(value, bool) and 1 <= value <= 65535 else default
|
||||||
|
|
||||||
|
|
||||||
|
def terraform_outputs(directory, tool):
|
||||||
|
"""The outputs of the Terraform root directory read with the tool, terraform or tofu, kept for OUTPUTS_SECONDS, or
|
||||||
|
{} when they cannot be read."""
|
||||||
|
with _outputs_lock:
|
||||||
|
cached = _outputs.get(directory)
|
||||||
|
if cached and time.monotonic() - cached[0] < OUTPUTS_SECONDS:
|
||||||
|
return cached[1]
|
||||||
|
try:
|
||||||
|
result = subprocess.run([tool, f'-chdir={directory}', 'output', '-json', '-no-color'],
|
||||||
|
capture_output=True, text=True, timeout=60,
|
||||||
|
env=dict(os.environ, TF_IN_AUTOMATION='1'))
|
||||||
|
outputs = json.loads(result.stdout) if result.returncode == 0 else {}
|
||||||
|
except (OSError, subprocess.TimeoutExpired, ValueError):
|
||||||
|
outputs = {}
|
||||||
|
if not isinstance(outputs, dict):
|
||||||
|
outputs = {}
|
||||||
|
_outputs[directory] = (time.monotonic(), outputs)
|
||||||
|
return outputs
|
||||||
|
|
||||||
|
|
||||||
|
def output_value(outputs, name):
|
||||||
|
"""The value of a Terraform output, or None when it is missing."""
|
||||||
|
output = outputs.get(name)
|
||||||
|
return output.get('value') if isinstance(output, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def listen(config_file):
|
||||||
|
"""Where the manager listens, read now: ([(address, port), ...], ''), or ([], the reason) while no address is known."""
|
||||||
|
configuration = read_json(config_file)
|
||||||
|
bind = text_setting(configuration, 'bind')
|
||||||
|
port = port_setting(configuration.get('port'), DEFAULT_PORT)
|
||||||
|
if bind:
|
||||||
|
return [(bind, port)], ''
|
||||||
|
addresses = vpnrouter_net.listen_addresses(read_json(ROUTER_CONFIG_FILE), text_setting(configuration, 'listen'))
|
||||||
|
if not addresses:
|
||||||
|
return [], "the router's addresses to listen on are not known yet"
|
||||||
|
return [(address, port) for address in [*addresses, vpnrouter_net.LOOPBACK]], ''
|
||||||
|
|
||||||
|
|
||||||
|
def settings(config_file):
|
||||||
|
"""The settings read now: router_url and database, with the router's defaults for those not set."""
|
||||||
|
configuration = read_json(config_file)
|
||||||
|
result = {name: text_setting(configuration, name) for name in ('router_url', 'database')}
|
||||||
|
directory = text_setting(configuration, 'terraform_dir')
|
||||||
|
if directory and not result['router_url']:
|
||||||
|
tool = 'tofu' if text_setting(configuration, 'iac_tool') == 'tofu' else 'terraform'
|
||||||
|
url = output_value(terraform_outputs(os.path.expanduser(directory), tool), 'router_web_url')
|
||||||
|
if isinstance(url, str):
|
||||||
|
result['router_url'] = url
|
||||||
|
if os.path.exists(ROUTER_CONFIG_FILE):
|
||||||
|
router = read_json(ROUTER_CONFIG_FILE)
|
||||||
|
if not result['database']:
|
||||||
|
try:
|
||||||
|
with open(DATABASE_SECRET, encoding='utf-8') as handle:
|
||||||
|
password = handle.read().strip()
|
||||||
|
except OSError:
|
||||||
|
password = ''
|
||||||
|
# The password, of URL-safe characters, is written unquoted.
|
||||||
|
if password:
|
||||||
|
result['database'] = f'host=localhost dbname={DATABASE} user={DATABASE} password={password}'
|
||||||
|
if not result['router_url']:
|
||||||
|
targets, _ = vpnrouter_net.web_listen(router, ROUTER_PORT)
|
||||||
|
if targets:
|
||||||
|
result['router_url'] = f'http://{targets[0][0]}:{targets[0][1]}'
|
||||||
|
result['router_url'] = result['router_url'].rstrip('/')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def status_url(config_file, host, address, https):
|
||||||
|
"""The URL of the router's status page for a browser that reached the manager as host on its address.
|
||||||
|
|
||||||
|
On the router, while neither router_url nor terraform_dir is set, it has host, without its
|
||||||
|
port, so the link works by whatever name or address the browser used: with https, a call
|
||||||
|
through the router's HTTPS proxy, https and https.web_port; else, while the router's web
|
||||||
|
service listens on address, http and web.port. Otherwise it is router_url, or '' without one.
|
||||||
|
"""
|
||||||
|
configuration = read_json(config_file)
|
||||||
|
try:
|
||||||
|
hostname = urllib.parse.urlsplit(f'//{host}').hostname
|
||||||
|
except ValueError:
|
||||||
|
hostname = None
|
||||||
|
if (hostname and os.path.exists(ROUTER_CONFIG_FILE)
|
||||||
|
and not text_setting(configuration, 'router_url') and not text_setting(configuration, 'terraform_dir')):
|
||||||
|
name = f'[{hostname}]' if ':' in hostname else hostname
|
||||||
|
router = read_json(ROUTER_CONFIG_FILE)
|
||||||
|
if https:
|
||||||
|
options = router.get('https') if isinstance(router.get('https'), dict) else {}
|
||||||
|
return f'https://{name}:{port_setting(options.get("web_port"), ROUTER_HTTPS_PORT)}'
|
||||||
|
targets, _ = vpnrouter_net.web_listen(router, ROUTER_PORT)
|
||||||
|
ports = [port for listened, port in targets if listened == address]
|
||||||
|
if ports:
|
||||||
|
return f'http://{name}:{ports[0]}'
|
||||||
|
return settings(config_file)['router_url']
|
||||||
|
|
||||||
|
|
||||||
|
def serve(wanted, handler, log, interval):
|
||||||
|
"""Work out where to listen every interval seconds with wanted, and run an HTTP server of handler at each place.
|
||||||
|
|
||||||
|
wanted answers ([(address, port), ...], '') or ([], the reason not to listen). A server whose
|
||||||
|
place is no longer wanted is stopped, and a new one listens at each new place; a place that
|
||||||
|
cannot be bound is tried again the next round. Every new message is logged with log. A request
|
||||||
|
whose client went away is not logged; any other exception a request, a thread or the process
|
||||||
|
does not handle is logged with log in one line, without a traceback. Does not return.
|
||||||
|
"""
|
||||||
|
def failed(request, client_address):
|
||||||
|
failure = sys.exc_info()[1]
|
||||||
|
if not isinstance(failure, ConnectionError):
|
||||||
|
log(f'a request from {client_address[0]} failed: {type(failure).__name__}: {failure}')
|
||||||
|
|
||||||
|
def thread_failed(hook):
|
||||||
|
if not issubclass(hook.exc_type, (SystemExit, ConnectionError)):
|
||||||
|
log(f'{hook.thread.name if hook.thread else "a thread"} failed: {hook.exc_type.__name__}: {hook.exc_value}')
|
||||||
|
|
||||||
|
threading.excepthook = thread_failed
|
||||||
|
sys.excepthook = lambda kind, value, _: log(f'failed: {kind.__name__}: {value}')
|
||||||
|
servers, reported = {}, None
|
||||||
|
while True:
|
||||||
|
targets, reason = wanted()
|
||||||
|
for target in [target for target in servers if target not in targets]:
|
||||||
|
server, thread = servers.pop(target)
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join()
|
||||||
|
messages = [reason] if reason else []
|
||||||
|
for target in targets:
|
||||||
|
if target in servers:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
server = ThreadingHTTPServer(target, handler)
|
||||||
|
except OSError as exc:
|
||||||
|
messages.append(f'cannot listen on {target[0]}:{target[1]}: {exc}')
|
||||||
|
continue
|
||||||
|
server.handle_error = failed
|
||||||
|
thread = threading.Thread(target=server.serve_forever, name=f'server {target[0]}', daemon=True)
|
||||||
|
thread.start()
|
||||||
|
servers[target] = (server, thread)
|
||||||
|
if servers:
|
||||||
|
messages.append('serving on ' + ', '.join(f'http://{address}:{port}' for address, port in servers))
|
||||||
|
message = '; '.join(messages)
|
||||||
|
if message and message != reported:
|
||||||
|
log(message)
|
||||||
|
reported = message
|
||||||
|
time.sleep(interval)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Google Cloud Platform.
|
"""Google Cloud.
|
||||||
|
|
||||||
No platform-specific configuration is currently required.
|
No platform-specific configuration is currently required.
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ Caddy answers on its port (hosts, the names TLS clients send as SNI; none answer
|
|||||||
firewall opens the port (listen), and TLS: none (plain HTTP), acme (a certificate of Let's Encrypt), ca (certificates
|
firewall opens the port (listen), and TLS: none (plain HTTP), acme (a certificate of Let's Encrypt), ca (certificates
|
||||||
Caddy issues with one of pki.issuing_cas) or file (a certificate and key given with the site). Caddy serves the files of
|
Caddy issues with one of pki.issuing_cas) or file (a certificate and key given with the site). Caddy serves the files of
|
||||||
the folder, index_file for a folder, and with spa index_file of the site for any path without a file. editors names
|
the folder, index_file for a folder, and with spa index_file of the site for any path without a file. editors names
|
||||||
the group whose members change the site's files besides the admins, through the web service and WebDAV.
|
the group whose members change the site's files besides the admins, through the web service and WebDAV. dns says who
|
||||||
|
serves the records of its host names: user, a DNS provider outside the router, or router, the router's name server,
|
||||||
|
which gives each of them in the closest of its domains an A or AAAA record of the router's address of dns_address:
|
||||||
|
public (wan.public_address), internal or external. A site may use wan.local_fqdn, on a port of its own; its record
|
||||||
|
stays where the domain of wan.local_fqdn is.
|
||||||
|
|
||||||
The sites are served while sites.enabled is true; vpn-router-setup writes Caddy's configuration and opens the ports. A
|
The sites are served while sites.enabled is true; vpn-router-setup writes Caddy's configuration and opens the ports. A
|
||||||
site is changed only through this module, which checks it (check), and its callers then restart vpn-router-setup.
|
site is changed only through this module, which checks it (check), and its callers then restart vpn-router-setup.
|
||||||
@@ -55,6 +59,11 @@ INDEX = re.compile(r'^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$')
|
|||||||
LISTEN = vpnrouter.LISTEN_CHOICES
|
LISTEN = vpnrouter.LISTEN_CHOICES
|
||||||
TLS = ('none', 'acme', 'ca', 'file')
|
TLS = ('none', 'acme', 'ca', 'file')
|
||||||
|
|
||||||
|
#: Who serves the records of a site's host names: user, elsewhere, or router, the router's name server, with the
|
||||||
|
#: router's address of DNS_ADDRESSES.
|
||||||
|
DNS = ('user', 'router')
|
||||||
|
DNS_ADDRESSES = ('public', 'internal', 'external')
|
||||||
|
|
||||||
#: The most host names of one site.
|
#: The most host names of one site.
|
||||||
HOSTS_MAX = 16
|
HOSTS_MAX = 16
|
||||||
|
|
||||||
@@ -66,7 +75,8 @@ CHUNK = 65536
|
|||||||
|
|
||||||
#: The settings of a site and their defaults; the type of a default is the required type.
|
#: The settings of a site and their defaults; the type of a default is the required type.
|
||||||
SETTINGS = {'enabled': True, 'hosts': [], 'port': 443, 'listen': 'external', 'tls': 'acme', 'ca': '', 'certificate': '',
|
SETTINGS = {'enabled': True, 'hosts': [], 'port': 443, 'listen': 'external', 'tls': 'acme', 'ca': '', 'certificate': '',
|
||||||
'private_key': '', 'index_file': 'index.html', 'spa': False, 'editors': ''}
|
'private_key': '', 'index_file': 'index.html', 'spa': False, 'editors': '', 'dns': 'user',
|
||||||
|
'dns_address': 'public'}
|
||||||
|
|
||||||
#: Whether this process has loaded SITES_SQL.
|
#: Whether this process has loaded SITES_SQL.
|
||||||
_loaded = False
|
_loaded = False
|
||||||
@@ -169,7 +179,7 @@ def taken_ports(cfg):
|
|||||||
cfg['sites']['webdav_port']: 'sites.webdav_port', vpnrouter.WEBDAV_LOOPBACK_PORT: 'rclone\'s WebDAV',
|
cfg['sites']['webdav_port']: 'sites.webdav_port', vpnrouter.WEBDAV_LOOPBACK_PORT: 'rclone\'s WebDAV',
|
||||||
53: 'DNS'}
|
53: 'DNS'}
|
||||||
for port in vpnrouter.BOOTSTRAP_PORTS:
|
for port in vpnrouter.BOOTSTRAP_PORTS:
|
||||||
taken[port] = 'the bootstrap services'
|
taken[port] = 'the bootstrap service'
|
||||||
if cfg['openvpn']['protocol'] == 'tcp':
|
if cfg['openvpn']['protocol'] == 'tcp':
|
||||||
taken[cfg['openvpn']['port']] = 'openvpn.port'
|
taken[cfg['openvpn']['port']] = 'openvpn.port'
|
||||||
for item in cfg['docker']['exposed_ports']:
|
for item in cfg['docker']['exposed_ports']:
|
||||||
@@ -215,8 +225,17 @@ def check(cfg, sites, site):
|
|||||||
for host in hosts:
|
for host in hosts:
|
||||||
if not (vpnrouter_pki.DNS_NAME.match(host) or vpnrouter_pki.is_address(host)):
|
if not (vpnrouter_pki.DNS_NAME.match(host) or vpnrouter_pki.is_address(host)):
|
||||||
raise Failure(f'hosts: {host!r} is not a host name in lowercase or an IP address', 2)
|
raise Failure(f'hosts: {host!r} is not a host name in lowercase or an IP address', 2)
|
||||||
if host == cfg['wan']['local_fqdn'].strip():
|
if site['dns'] not in DNS:
|
||||||
raise Failure(f'hosts: {host} is wan.local_fqdn, the name of the router\'s own services', 2)
|
raise Failure(f'dns: {site["dns"]!r} is not one of {", ".join(DNS)}', 2)
|
||||||
|
if site['dns_address'] not in DNS_ADDRESSES:
|
||||||
|
raise Failure(f'dns_address: {site["dns_address"]!r} is not one of {", ".join(DNS_ADDRESSES)}', 2)
|
||||||
|
if site['dns'] == 'router':
|
||||||
|
local_fqdn = cfg['wan']['local_fqdn'].strip()
|
||||||
|
if any(vpnrouter_pki.is_address(host) or host.startswith('*.') or host == local_fqdn for host in hosts):
|
||||||
|
raise Failure('dns router takes host names only, without wildcards and other than wan.local_fqdn, whose '
|
||||||
|
'record is kept where its domain is', 2)
|
||||||
|
if site['dns_address'] == 'public' and not cfg['wan']['public_address'].strip():
|
||||||
|
raise Failure('dns_address public needs wan.public_address', 2)
|
||||||
port = site['port']
|
port = site['port']
|
||||||
if not 1 <= port <= 65535:
|
if not 1 <= port <= 65535:
|
||||||
raise Failure(f'port: {port} is not a port', 2)
|
raise Failure(f'port: {port} is not a port', 2)
|
||||||
@@ -285,7 +304,7 @@ def save(cfg, name, values, create):
|
|||||||
if site['tls'] != 'ca':
|
if site['tls'] != 'ca':
|
||||||
site['ca'] = ''
|
site['ca'] = ''
|
||||||
check(cfg, rows, site)
|
check(cfg, rows, site)
|
||||||
connection.execute('CALL site_put(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
connection.execute(f'CALL site_put({", ".join(["%s"] * (len(SETTINGS) + 1))})',
|
||||||
tuple(site[key] for key in ('name', *SETTINGS)))
|
tuple(site[key] for key in ('name', *SETTINGS)))
|
||||||
ensure_folders([name])
|
ensure_folders([name])
|
||||||
return shown(get(connection, name))
|
return shown(get(connection, name))
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ Pages:
|
|||||||
and the files of each: list, download, upload, extract a zip archive, create a folder, rename and
|
and the files of each: list, download, upload, extract a zip archive, create a folder, rename and
|
||||||
remove (see Handler.site_files()); a member of a site's editors group may use the site's files
|
remove (see Handler.site_files()); a member of a site's editors group may use the site's files
|
||||||
/dns the primary name server of the router's domains: its settings, and the domains: add, change and
|
/dns the primary name server of the router's domains: its settings, and the domains: add, change and
|
||||||
remove, each with its secondaries, the records it serves, and its manual records (see dns())
|
remove, each with the records it serves and its manual records (see dns())
|
||||||
/postgresql
|
/postgresql
|
||||||
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
|
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
|
||||||
postgresql_databases()); /config has the clusters' settings
|
postgresql_databases()); /config has the clusters' settings
|
||||||
@@ -78,8 +78,7 @@ default the address the call came in on, is the last entry of its X-Forwarded-Fo
|
|||||||
|
|
||||||
/ and /status show only the status, as much as web.status_content says (see shown): basic, the
|
/ 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
|
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
|
below); standard also the package version and the number of active and other services; full
|
||||||
(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, how many of the site-to-site connections are established
|
whether each service runs, whether Docker is installed, how many of the site-to-site connections are established
|
||||||
(site_to_site: connections and established, null when swanctl cannot be asked), and IKEv2, OpenVPN
|
(site_to_site: connections and established, null when swanctl cannot be asked), and IKEv2, OpenVPN
|
||||||
and WireGuard. No configuration data, key or connection name is shown, and no address but the client's.
|
and WireGuard. No configuration data, key or connection name is shown, and no address but the client's.
|
||||||
|
|||||||
@@ -10,22 +10,20 @@ from .common import section_change, unit_state
|
|||||||
def dns(method, path, query, body):
|
def dns(method, path, query, body):
|
||||||
"""Answer a /dns request with vpnrouter_dns: the HTTP status and the JSON document.
|
"""Answer a /dns request with vpnrouter_dns: the HTTP status and the JSON document.
|
||||||
|
|
||||||
GET /dns {"settings", "installed", "running", "domains", "problem"}: the dns section of the
|
GET /dns {"settings", "nameserver", "public_address", "installed", "running", "domains",
|
||||||
configuration, whether PowerDNS is installed and runs, the domains, each {"name", "sites",
|
"problem"}: the dns section of the configuration, wan.local_fqdn, the primary name server
|
||||||
"ttl", "secondaries", "manual", "records"} with its secondaries {"nameserver", "address"},
|
of the domains, wan.public_address, the address of the records of the sites with
|
||||||
its manual records and the records the name server serves, and why the domains cannot be
|
dns_address public, whether PowerDNS is installed and runs, the domains, each {"name",
|
||||||
read, null when they can
|
"ttl", "manual", "records"} with its manual records and the records the name server
|
||||||
PUT /dns change any of the settings of the dns section
|
serves, and why the domains cannot be read, null when they can
|
||||||
POST /dns/domains add a domain: {"name", "sites"?, "ttl"?}; sites gives the host names of the enabled sites
|
PUT /dns change any of the settings of the dns section, and with "public_address"
|
||||||
in it records; ttl is dns.ttl when null or absent; an existing name is 409
|
wan.public_address; {"settings", "public_address"}
|
||||||
|
POST /dns/domains add a domain: {"name", "ttl"?}; ttl is dns.ttl when null or absent; an existing name is
|
||||||
|
409
|
||||||
PUT /dns/domains/<domain>
|
PUT /dns/domains/<domain>
|
||||||
change "sites" or "ttl" of the domain
|
change "ttl" of the domain
|
||||||
DELETE /dns/domains/<domain>
|
DELETE /dns/domains/<domain>
|
||||||
remove the domain with its secondaries and manual records
|
remove the domain with its manual records
|
||||||
POST /dns/domains/<domain>/secondaries
|
|
||||||
add a secondary name server of the domain, or change its address: {"nameserver", "address"}
|
|
||||||
DELETE /dns/domains/<domain>/secondaries/<nameserver>
|
|
||||||
remove the secondary name server
|
|
||||||
POST /dns/domains/<domain>/records
|
POST /dns/domains/<domain>/records
|
||||||
add a manual record: {"name", "type", "content", "ttl"?}; name is relative to the domain,
|
add a manual record: {"name", "type", "content", "ttl"?}; name is relative to the domain,
|
||||||
@ for the domain itself, or absolute with a final dot; ttl is the domain's without it
|
@ for the domain itself, or absolute with a final dot; ttl is the domain's without it
|
||||||
@@ -47,22 +45,27 @@ def dns(method, path, query, body):
|
|||||||
domains = vpnrouter_dns.listing()
|
domains = vpnrouter_dns.listing()
|
||||||
except vpnrouter_dns.DatabaseError as exc:
|
except vpnrouter_dns.DatabaseError as exc:
|
||||||
problem = str(exc)
|
problem = str(exc)
|
||||||
return 200, {'settings': cfg['dns'], 'installed': vpnrouter_dns.PDNS_SERVER.exists(),
|
return 200, {'settings': cfg['dns'], 'nameserver': cfg['wan']['local_fqdn'].strip(),
|
||||||
|
'public_address': cfg['wan']['public_address'].strip(),
|
||||||
|
'installed': vpnrouter_dns.PDNS_SERVER.exists(),
|
||||||
'running': unit_state(vpnrouter_dns.UNIT) == 'active', 'domains': domains,
|
'running': unit_state(vpnrouter_dns.UNIT) == 'active', 'domains': domains,
|
||||||
'problem': problem}
|
'problem': problem}
|
||||||
if route == ('PUT', 'dns'):
|
if route == ('PUT', 'dns'):
|
||||||
|
wan = cfg['wan']
|
||||||
|
if 'public_address' in body:
|
||||||
|
wan = section_change('wan', {'public_address': body.pop('public_address')})
|
||||||
|
if isinstance(wan, tuple):
|
||||||
|
return wan
|
||||||
changed = section_change('dns', body)
|
changed = section_change('dns', body)
|
||||||
return changed if isinstance(changed, tuple) else (200, {'settings': changed})
|
if isinstance(changed, tuple):
|
||||||
|
return changed
|
||||||
|
return 200, {'settings': changed, 'public_address': wan['public_address'].strip()}
|
||||||
if route == ('POST', 'dns', 'domains') and len(parts) == 2:
|
if route == ('POST', 'dns', 'domains') and len(parts) == 2:
|
||||||
document, code = vpnrouter_dns.domain_add(body), 201
|
document, code = vpnrouter_dns.domain_add(body), 201
|
||||||
elif route == ('PUT', 'dns', 'domains') and len(parts) == 3:
|
elif route == ('PUT', 'dns', 'domains') and len(parts) == 3:
|
||||||
document, code = vpnrouter_dns.domain_change(parts[2], body), 200
|
document, code = vpnrouter_dns.domain_change(parts[2], body), 200
|
||||||
elif route == ('DELETE', 'dns', 'domains') and len(parts) == 3:
|
elif route == ('DELETE', 'dns', 'domains') and len(parts) == 3:
|
||||||
document, code = vpnrouter_dns.domain_remove(parts[2]), 200
|
document, code = vpnrouter_dns.domain_remove(parts[2]), 200
|
||||||
elif route == ('POST', 'dns', 'domains', 'secondaries') and len(parts) == 4:
|
|
||||||
document, code = vpnrouter_dns.secondary_add(parts[2], body), 201
|
|
||||||
elif route == ('DELETE', 'dns', 'domains', 'secondaries') and len(parts) == 5:
|
|
||||||
document, code = vpnrouter_dns.secondary_remove(parts[2], parts[4]), 200
|
|
||||||
elif route == ('POST', 'dns', 'domains', 'records') and len(parts) == 4:
|
elif route == ('POST', 'dns', 'domains', 'records') and len(parts) == 4:
|
||||||
document, code = vpnrouter_dns.add(cfg, parts[2], body), 201
|
document, code = vpnrouter_dns.add(cfg, parts[2], body), 201
|
||||||
elif route == ('DELETE', 'dns', 'domains', 'records') and len(parts) == 4:
|
elif route == ('DELETE', 'dns', 'domains', 'records') and len(parts) == 4:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from .connectivity import remote_access, site_to_site
|
|||||||
from .dns import dns
|
from .dns import dns
|
||||||
from .identities import account, groups, service_accounts, users
|
from .identities import account, groups, service_accounts, users
|
||||||
from .logs import logs
|
from .logs import logs
|
||||||
|
from .https import https_settings
|
||||||
from .metrics import metrics_history, metrics_settings
|
from .metrics import metrics_history, metrics_settings
|
||||||
from .pki import PUBLISHED, ca
|
from .pki import PUBLISHED, ca
|
||||||
from .shell import EDITOR_BODY_LIMIT, shell, shell_editor, shell_passkey, shell_ticket
|
from .shell import EDITOR_BODY_LIMIT, shell, shell_editor, shell_passkey, shell_ticket
|
||||||
@@ -66,7 +67,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup',
|
directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup',
|
||||||
'ca', 'shell', 'remote-access', 'logs', 'sites', 'dns')
|
'ca', 'shell', 'remote-access', 'logs', 'sites', 'dns')
|
||||||
if path == f'/{name}' or path.startswith(f'/{name}/')), None)
|
if path == f'/{name}' or path.startswith(f'/{name}/')), None)
|
||||||
protected = path in ('/status/full', '/config', '/metrics/history', '/metrics/settings', '/postgresql') or \
|
protected = path in ('/status/full', '/config', '/metrics/history', '/metrics/settings', '/https/settings',
|
||||||
|
'/postgresql') or \
|
||||||
directory is not None
|
directory is not None
|
||||||
signing = path in ('/auth', '/auth/passkey', '/auth/passkey/complete', '/auth/refresh', '/auth/logout')
|
signing = path in ('/auth', '/auth/passkey', '/auth/passkey/complete', '/auth/refresh', '/auth/logout')
|
||||||
oidc = (path == '/.well-known/openid-configuration' or path.startswith('/oidc/')) and \
|
oidc = (path == '/.well-known/openid-configuration' or path.startswith('/oidc/')) and \
|
||||||
@@ -113,6 +115,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
elif path == '/metrics/settings':
|
elif path == '/metrics/settings':
|
||||||
code, document = metrics_settings(self.command, payload)
|
code, document = metrics_settings(self.command, payload)
|
||||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||||
|
elif path == '/https/settings':
|
||||||
|
code, document = https_settings(self.command, payload)
|
||||||
|
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||||
elif directory == 'shell' and (path, self.command) == ('/shell', 'GET'):
|
elif directory == 'shell' and (path, self.command) == ('/shell', 'GET'):
|
||||||
return shell(self, claims.get('sub'))
|
return shell(self, claims.get('sub'))
|
||||||
elif directory == 'shell' and path.startswith('/shell/editor/'):
|
elif directory == 'shell' and path.startswith('/shell/editor/'):
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""/https/settings: the settings of HTTPS, of which the API changes the CA of Caddy's certificates."""
|
||||||
|
|
||||||
|
import vpnrouter
|
||||||
|
|
||||||
|
from .common import section_change
|
||||||
|
|
||||||
|
#: The settings of the https section PUT /https/settings changes.
|
||||||
|
CHANGEABLE = ('acme_staging',)
|
||||||
|
|
||||||
|
|
||||||
|
def https_settings(method, body):
|
||||||
|
"""Answer a /https/settings request: the HTTP status and the JSON document.
|
||||||
|
|
||||||
|
GET /https/settings {"settings"}: the https section
|
||||||
|
PUT /https/settings change acme_staging; the other settings of the section are changed on the router with
|
||||||
|
vpn-config-edit, and a body naming one is 400
|
||||||
|
|
||||||
|
A change is applied by restarting vpn-router-setup without waiting for it.
|
||||||
|
"""
|
||||||
|
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||||
|
return 400, {'error': 'the body must be a JSON object'}
|
||||||
|
if method in ('GET', 'HEAD'):
|
||||||
|
try:
|
||||||
|
return 200, {'settings': vpnrouter.load_config()['https']}
|
||||||
|
except SystemExit as exc:
|
||||||
|
return 500, {'error': str(exc.code)}
|
||||||
|
if method != 'PUT':
|
||||||
|
return 404, {'error': f'no route {method} /https/settings'}
|
||||||
|
others = sorted(set(body or {}) - set(CHANGEABLE))
|
||||||
|
if others:
|
||||||
|
return 400, {'error': f'https.{", https.".join(others)}: changed on the router with vpn-config-edit only'}
|
||||||
|
changed = section_change('https', body or {})
|
||||||
|
return changed if isinstance(changed, tuple) else (200, {'settings': changed})
|
||||||
@@ -25,7 +25,8 @@ def sites(method, path, query, body):
|
|||||||
settings but private_key, with private_key_set, modified and its folder
|
settings but private_key, with private_key_set, modified and its folder
|
||||||
PUT /sites change any of the settings of the sites section
|
PUT /sites change any of the settings of the sites section
|
||||||
POST /sites add a site: {"name", and any of "enabled", "hosts", "port", "listen", "tls", "ca",
|
POST /sites add a site: {"name", and any of "enabled", "hosts", "port", "listen", "tls", "ca",
|
||||||
"certificate", "private_key", "index_file", "spa", "editors"}; an existing name is 409
|
"certificate", "private_key", "index_file", "spa", "editors", "dns",
|
||||||
|
"dns_address"}; an existing name is 409
|
||||||
GET /sites/<name> the site
|
GET /sites/<name> the site
|
||||||
PUT /sites/<name> change any of the settings of POST but name; an empty or absent private_key keeps the key
|
PUT /sites/<name> change any of the settings of POST but name; an empty or absent private_key keeps the key
|
||||||
DELETE /sites/<name> remove the site, with content=true also its folder
|
DELETE /sites/<name> remove the site, with content=true also its folder
|
||||||
|
|||||||
@@ -59,10 +59,7 @@ def postgresql_clusters():
|
|||||||
def collect(state):
|
def collect(state):
|
||||||
"""The status document. state says which features the router has."""
|
"""The status document. state says which features the router has."""
|
||||||
units = {'vpn-router-setup': unit_state('vpn-router-setup.service'), 'slapd': unit_state('slapd.service'),
|
units = {'vpn-router-setup': unit_state('vpn-router-setup.service'), 'slapd': unit_state('slapd.service'),
|
||||||
**postgresql_clusters()}
|
'vpn-router-manager': unit_state('vpn-router-manager.service'), **postgresql_clusters()}
|
||||||
manager_version = package_version('vpn-router-manager')
|
|
||||||
if manager_version:
|
|
||||||
units['vpn-router-manager'] = unit_state('vpn-router-manager.service')
|
|
||||||
if state.get('site_to_site') or state.get('p2s'):
|
if state.get('site_to_site') or state.get('p2s'):
|
||||||
units['strongswan'] = unit_state('strongswan.service')
|
units['strongswan'] = unit_state('strongswan.service')
|
||||||
if state.get('wireguard'):
|
if state.get('wireguard'):
|
||||||
@@ -73,7 +70,6 @@ def collect(state):
|
|||||||
status = {
|
status = {
|
||||||
'status': 'ok' if all(value == 'active' for value in units.values()) else 'degraded',
|
'status': 'ok' if all(value == 'active' for value in units.values()) else 'degraded',
|
||||||
'version': package_version(),
|
'version': package_version(),
|
||||||
'manager_version': manager_version,
|
|
||||||
'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
|
'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
|
||||||
'units': units,
|
'units': units,
|
||||||
'docker': docker_status(),
|
'docker': docker_status(),
|
||||||
@@ -96,7 +92,7 @@ def shown(status, content, client):
|
|||||||
"""The status document as web.status_content shows it to the client at its address.
|
"""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
|
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
|
more; standard adds 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
|
other state; full adds the whole status document of collect. Content that is not one of
|
||||||
vpnrouter.STATUS_CONTENTS, or not set, is basic.
|
vpnrouter.STATUS_CONTENTS, or not set, is basic.
|
||||||
"""
|
"""
|
||||||
@@ -107,7 +103,7 @@ def shown(status, content, client):
|
|||||||
document = {'status': status['status'], 'content': content, 'time': status['time'], 'client_ip': client}
|
document = {'status': status['status'], 'content': content, 'time': status['time'], 'client_ip': client}
|
||||||
if content == 'standard':
|
if content == 'standard':
|
||||||
active = sum(1 for value in status['units'].values() if value == 'active')
|
active = sum(1 for value in status['units'].values() if value == 'active')
|
||||||
document.update(version=status['version'], manager_version=status['manager_version'],
|
document.update(version=status['version'],
|
||||||
services={'active': active, 'failed': len(status['units']) - active})
|
services={'active': active, 'failed': len(status['units']) - active})
|
||||||
return document
|
return document
|
||||||
|
|
||||||
@@ -242,8 +238,6 @@ def text_of(status):
|
|||||||
if 'version' in status:
|
if 'version' in status:
|
||||||
lines.append(f'Version: {status["version"] or "unknown"}')
|
lines.append(f'Version: {status["version"] or "unknown"}')
|
||||||
lines += ['', f'Client IP: {status["client_ip"]}']
|
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:
|
if 'services' in status:
|
||||||
lines.append(f'Services: {status["services"]["active"]} active, {status["services"]["failed"]} failed')
|
lines.append(f'Services: {status["services"]["active"]} active, {status["services"]["failed"]} failed')
|
||||||
lines += [f'{name}: {value}' for name, value in status.get('units', {}).items()]
|
lines += [f'{name}: {value}' for name, value in status.get('units', {}).items()]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Wait until libpq is in the dynamic linker cache, where psycopg looks it up.
|
# Wait until libpq is in the dynamic linker cache, where psycopg looks it up.
|
||||||
# ExecStartPre= of vpn-router-setup.service, vpn-router-web.service and vpn-router-metrics.service;
|
# ExecStartPre= of vpn-router-setup.service, vpn-router-web.service, vpn-router-metrics.service and
|
||||||
# TimeoutStartSec= of the unit bounds the wait.
|
# vpn-router-manager.service; TimeoutStartSec= of the unit bounds the wait.
|
||||||
|
|
||||||
reported=
|
reported=
|
||||||
until ldconfig -p | grep -q '^[[:space:]]*libpq\.so\.5 '; do
|
until ldconfig -p | grep -q '^[[:space:]]*libpq\.so\.5 '; do
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
|
|
||||||
A site has a name, which also names its folder in /var/lib/vpn-router/sites, host names, a port, the interfaces whose
|
A site has a name, which also names its folder in /var/lib/vpn-router/sites, host names, a port, the interfaces whose
|
||||||
firewall opens it, TLS (none, acme, ca with --ca, or file with --certificate and --key), the file served for a folder,
|
firewall opens it, TLS (none, acme, ca with --ca, or file with --certificate and --key), the file served for a folder,
|
||||||
whether any path without a file is served that file of the site (--spa), and the group whose members change its files.
|
whether any path without a file is served that file of the site (--spa), the group whose members change its files, and
|
||||||
|
who serves the records of its host names (--dns user, elsewhere, or router, the router's name server, with the router's
|
||||||
|
address of --dns-address).
|
||||||
A change is applied by restarting vpn-router-setup without waiting for it. The answer is text for people on standard
|
A change is applied by restarting vpn-router-setup without waiting for it. The answer is text for people on standard
|
||||||
output, and a failure is a message on standard error. With --json the answer is JSON on standard output, and a failure
|
output, and a failure is a message on standard error. With --json the answer is JSON on standard output, and a failure
|
||||||
is {"error": ...} there too. The exit status says what failed: 2 the request is invalid, 3 the site does not exist, 4
|
is {"error": ...} there too. The exit status says what failed: 2 the request is invalid, 3 the site does not exist, 4
|
||||||
@@ -17,7 +19,8 @@ the site exists, 1 anything else. -h or --help shows the commands and their opti
|
|||||||
|
|
||||||
options: [--host H ...] [--no-hosts] [--port P] [--listen internal|external|both] [--tls none|acme|ca|file]
|
options: [--host H ...] [--no-hosts] [--port P] [--listen internal|external|both] [--tls none|acme|ca|file]
|
||||||
[--ca NAME] [--certificate FILE] [--key FILE] [--index FILE] [--spa | --no-spa]
|
[--ca NAME] [--certificate FILE] [--key FILE] [--index FILE] [--spa | --no-spa]
|
||||||
[--enabled | --no-enabled] [--editors GROUP]
|
[--enabled | --no-enabled] [--editors GROUP] [--dns user|router]
|
||||||
|
[--dns-address public|internal|external]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -73,6 +76,9 @@ def parse(argv):
|
|||||||
'path without a file')
|
'path without a file')
|
||||||
sub.add_argument('--enabled', action=argparse.BooleanOptionalAction, help='serve the site')
|
sub.add_argument('--enabled', action=argparse.BooleanOptionalAction, help='serve the site')
|
||||||
sub.add_argument('--editors', metavar='GROUP', help="the group whose members change the files; '' for none")
|
sub.add_argument('--editors', metavar='GROUP', help="the group whose members change the files; '' for none")
|
||||||
|
sub.add_argument('--dns', choices=vpnrouter_sites.DNS, help='who serves the records of the host names')
|
||||||
|
sub.add_argument('--dns-address', choices=vpnrouter_sites.DNS_ADDRESSES,
|
||||||
|
help="the router's address in the records of dns router")
|
||||||
if command == 'remove':
|
if command == 'remove':
|
||||||
sub.add_argument('--content', action='store_true', help='remove the site\'s folder too')
|
sub.add_argument('--content', action='store_true', help='remove the site\'s folder too')
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
@@ -82,7 +88,8 @@ def values_of(options):
|
|||||||
"""The settings the options give."""
|
"""The settings the options give."""
|
||||||
values = {key: getattr(options, option) for key, option in (('port', 'port'), ('listen', 'listen'), ('tls', 'tls'),
|
values = {key: getattr(options, option) for key, option in (('port', 'port'), ('listen', 'listen'), ('tls', 'tls'),
|
||||||
('ca', 'ca'), ('index_file', 'index'), ('spa', 'spa'),
|
('ca', 'ca'), ('index_file', 'index'), ('spa', 'spa'),
|
||||||
('enabled', 'enabled'), ('editors', 'editors'))
|
('enabled', 'enabled'), ('editors', 'editors'),
|
||||||
|
('dns', 'dns'), ('dns_address', 'dns_address'))
|
||||||
if getattr(options, option) is not None}
|
if getattr(options, option) is not None}
|
||||||
if options.no_hosts:
|
if options.no_hosts:
|
||||||
values['hosts'] = []
|
values['hosts'] = []
|
||||||
@@ -120,9 +127,10 @@ def text_of(answer):
|
|||||||
sites = answer['sites'] if 'sites' in answer else [answer]
|
sites = answer['sites'] if 'sites' in answer else [answer]
|
||||||
if not sites:
|
if not sites:
|
||||||
return 'no sites'
|
return 'no sites'
|
||||||
head = ('SITE', 'ENABLED', 'HOSTS', 'PORT', 'LISTEN', 'TLS', 'EDITORS')
|
head = ('SITE', 'ENABLED', 'HOSTS', 'PORT', 'LISTEN', 'TLS', 'EDITORS', 'DNS')
|
||||||
rows = [(site['name'], 'yes' if site['enabled'] else 'no', ', '.join(site['hosts']) or '*', str(site['port']),
|
rows = [(site['name'], 'yes' if site['enabled'] else 'no', ', '.join(site['hosts']) or '*', str(site['port']),
|
||||||
site['listen'], site['tls'] + (f' {site["ca"]}' if site['tls'] == 'ca' else ''), site['editors'] or '-')
|
site['listen'], site['tls'] + (f' {site["ca"]}' if site['tls'] == 'ca' else ''), site['editors'] or '-',
|
||||||
|
site['dns'] + (f' {site["dns_address"]}' if site['dns'] == 'router' else ''))
|
||||||
for site in sites]
|
for site in sites]
|
||||||
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
|
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
|
||||||
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
|
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
|
||||||
|
|||||||
@@ -64,6 +64,14 @@ _vpn_router_site()
|
|||||||
COMPREPLY=($(compgen -W "$(_vpn_router_site_groups)" -- "$cur"))
|
COMPREPLY=($(compgen -W "$(_vpn_router_site_groups)" -- "$cur"))
|
||||||
return
|
return
|
||||||
;;
|
;;
|
||||||
|
--dns)
|
||||||
|
COMPREPLY=($(compgen -W 'user router' -- "$cur"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
--dns-address)
|
||||||
|
COMPREPLY=($(compgen -W 'public internal external' -- "$cur"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
--certificate | --key)
|
--certificate | --key)
|
||||||
_filedir
|
_filedir
|
||||||
return
|
return
|
||||||
@@ -78,7 +86,7 @@ _vpn_router_site()
|
|||||||
local options='--help'
|
local options='--help'
|
||||||
case $command in
|
case $command in
|
||||||
add | update) options+=' --host --no-hosts --port --listen --tls --ca --certificate --key --index --spa
|
add | update) options+=' --host --no-hosts --port --listen --tls --ca --certificate --key --index --spa
|
||||||
--no-spa --enabled --no-enabled --editors' ;;
|
--no-spa --enabled --no-enabled --editors --dns --dns-address' ;;
|
||||||
remove) options+=' --content' ;;
|
remove) options+=' --content' ;;
|
||||||
esac
|
esac
|
||||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"router_url": "http://10.0.0.4:7081",
|
||||||
|
"database": "host=localhost dbname=vpn-router-manager user=vpn-router-manager password=change-me",
|
||||||
|
"listen": "both",
|
||||||
|
"port": 7080
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
"internal": "eth1"
|
"internal": "eth1"
|
||||||
},
|
},
|
||||||
"wan": {
|
"wan": {
|
||||||
"local_fqdn": "router.example.com"
|
"local_fqdn": "router.example.com",
|
||||||
|
"public_address": ""
|
||||||
},
|
},
|
||||||
"local": {
|
"local": {
|
||||||
"cidrs": ["10.0.0.0/24"],
|
"cidrs": ["10.0.0.0/24"],
|
||||||
@@ -89,8 +90,7 @@
|
|||||||
},
|
},
|
||||||
"dns": {
|
"dns": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"nameserver": "",
|
"secondary": "",
|
||||||
"addresses": [],
|
|
||||||
"listen": "external",
|
"listen": "external",
|
||||||
"ttl": 300
|
"ttl": 300
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,21 +6,21 @@ SELECT pg_advisory_xact_lock(hashtext('vpn-router dns schema'));
|
|||||||
|
|
||||||
DROP TABLE IF EXISTS dns_manual_records;
|
DROP TABLE IF EXISTS dns_manual_records;
|
||||||
|
|
||||||
-- A domain the router serves as its primary name server; sites says whether the host names of the enabled sites in
|
-- The secondaries of each domain and the domain's switch of site records: the secondary is dns.secondary, the same
|
||||||
-- it get records, ttl is the TTL of its records, null for dns.ttl.
|
-- for every domain, and each site says whether the router serves its records.
|
||||||
|
DROP PROCEDURE IF EXISTS dns_secondary_put(text, text, inet);
|
||||||
|
DROP PROCEDURE IF EXISTS dns_secondary_remove(text, text);
|
||||||
|
DROP FUNCTION IF EXISTS dns_secondaries_list();
|
||||||
|
DROP TABLE IF EXISTS dns_secondaries;
|
||||||
|
DROP FUNCTION IF EXISTS dns_domains_list();
|
||||||
|
DROP PROCEDURE IF EXISTS dns_domain_put(text, boolean, integer);
|
||||||
|
|
||||||
|
-- A domain the router serves as its primary name server; ttl is the TTL of its records, null for dns.ttl.
|
||||||
CREATE TABLE IF NOT EXISTS dns_domains (
|
CREATE TABLE IF NOT EXISTS dns_domains (
|
||||||
name text PRIMARY KEY,
|
name text PRIMARY KEY,
|
||||||
sites boolean NOT NULL,
|
|
||||||
ttl integer
|
ttl integer
|
||||||
);
|
);
|
||||||
|
ALTER TABLE dns_domains DROP COLUMN IF EXISTS sites;
|
||||||
-- A secondary name server of a domain: its NS name and the address it transfers the domain from.
|
|
||||||
CREATE TABLE IF NOT EXISTS dns_secondaries (
|
|
||||||
domain text NOT NULL REFERENCES dns_domains (name) ON DELETE CASCADE,
|
|
||||||
nameserver text NOT NULL,
|
|
||||||
address inet NOT NULL,
|
|
||||||
PRIMARY KEY (domain, nameserver)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- A record an administrator added to a domain, besides those of the domain itself and of the sites.
|
-- A record an administrator added to a domain, besides those of the domain itself and of the sites.
|
||||||
CREATE TABLE IF NOT EXISTS dns_records (
|
CREATE TABLE IF NOT EXISTS dns_records (
|
||||||
@@ -40,42 +40,24 @@ CREATE TABLE IF NOT EXISTS dns_state (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION dns_domains_list()
|
CREATE OR REPLACE FUNCTION dns_domains_list()
|
||||||
RETURNS TABLE (name text, sites boolean, ttl integer)
|
RETURNS TABLE (name text, ttl integer)
|
||||||
LANGUAGE sql STABLE AS $$
|
LANGUAGE sql STABLE AS $$
|
||||||
SELECT name, sites, ttl FROM dns_domains ORDER BY name;
|
SELECT name, ttl FROM dns_domains ORDER BY name;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- Add the domain, or set its sites and TTL.
|
-- Add the domain, or set its TTL.
|
||||||
CREATE OR REPLACE PROCEDURE dns_domain_put(p_name text, p_sites boolean, p_ttl integer)
|
CREATE OR REPLACE PROCEDURE dns_domain_put(p_name text, p_ttl integer)
|
||||||
LANGUAGE sql AS $$
|
LANGUAGE sql AS $$
|
||||||
INSERT INTO dns_domains VALUES (p_name, p_sites, p_ttl)
|
INSERT INTO dns_domains (name, ttl) VALUES (p_name, p_ttl)
|
||||||
ON CONFLICT (name) DO UPDATE SET sites = p_sites, ttl = p_ttl;
|
ON CONFLICT (name) DO UPDATE SET ttl = p_ttl;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- Remove the domain with its secondaries and records.
|
-- Remove the domain with its records.
|
||||||
CREATE OR REPLACE PROCEDURE dns_domain_remove(p_name text)
|
CREATE OR REPLACE PROCEDURE dns_domain_remove(p_name text)
|
||||||
LANGUAGE sql AS $$
|
LANGUAGE sql AS $$
|
||||||
DELETE FROM dns_domains WHERE name = p_name;
|
DELETE FROM dns_domains WHERE name = p_name;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION dns_secondaries_list()
|
|
||||||
RETURNS TABLE (domain text, nameserver text, address text)
|
|
||||||
LANGUAGE sql STABLE AS $$
|
|
||||||
SELECT domain, nameserver, host(address) FROM dns_secondaries ORDER BY domain, nameserver;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- Add the secondary of the domain, or set its address.
|
|
||||||
CREATE OR REPLACE PROCEDURE dns_secondary_put(p_domain text, p_nameserver text, p_address inet)
|
|
||||||
LANGUAGE sql AS $$
|
|
||||||
INSERT INTO dns_secondaries VALUES (p_domain, p_nameserver, p_address)
|
|
||||||
ON CONFLICT (domain, nameserver) DO UPDATE SET address = p_address;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
CREATE OR REPLACE PROCEDURE dns_secondary_remove(p_domain text, p_nameserver text)
|
|
||||||
LANGUAGE sql AS $$
|
|
||||||
DELETE FROM dns_secondaries WHERE domain = p_domain AND nameserver = p_nameserver;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION dns_records_list()
|
CREATE OR REPLACE FUNCTION dns_records_list()
|
||||||
RETURNS TABLE (domain text, name text, type text, content text, ttl integer)
|
RETURNS TABLE (domain text, name text, type text, content text, ttl integer)
|
||||||
LANGUAGE sql STABLE AS $$
|
LANGUAGE sql STABLE AS $$
|
||||||
@@ -97,7 +79,7 @@ LANGUAGE sql AS $$
|
|||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- Serve the zone as its primary and give it the records, its SOA first, whose content has {serial} for the serial,
|
-- Serve the zone as its primary and give it the records, its SOA first, whose content has {serial} for the serial,
|
||||||
-- and the addresses of its secondaries, which are notified of its changes and may transfer it. The serial grows, to
|
-- and the addresses of its secondary, which are notified of its changes and may transfer it. The serial grows, to
|
||||||
-- at least the current Unix time, only when the records differ from those last written.
|
-- at least the current Unix time, only when the records differ from those last written.
|
||||||
CREATE OR REPLACE PROCEDURE dns_apply(p_zone text, p_soa text, p_ttl integer, p_names text[], p_types text[],
|
CREATE OR REPLACE PROCEDURE dns_apply(p_zone text, p_soa text, p_ttl integer, p_names text[], p_types text[],
|
||||||
p_contents text[], p_ttls integer[], p_secondaries text[])
|
p_contents text[], p_ttls integer[], p_secondaries text[])
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
|
|
||||||
SELECT pg_advisory_xact_lock(hashtext('vpn-router sites schema'));
|
SELECT pg_advisory_xact_lock(hashtext('vpn-router sites schema'));
|
||||||
|
|
||||||
-- A static web site: its host names, port, interfaces and TLS, and the group whose members change its files.
|
-- A static web site: its host names, port, interfaces and TLS, the group whose members change its files, and its
|
||||||
|
-- DNS: user, the router serves no records of its host names, or router, its name server serves A or AAAA records of
|
||||||
|
-- them with the router's public, internal or external address (dns_address).
|
||||||
CREATE TABLE IF NOT EXISTS sites (
|
CREATE TABLE IF NOT EXISTS sites (
|
||||||
name text PRIMARY KEY,
|
name text PRIMARY KEY,
|
||||||
enabled boolean NOT NULL,
|
enabled boolean NOT NULL,
|
||||||
@@ -19,44 +21,60 @@ CREATE TABLE IF NOT EXISTS sites (
|
|||||||
editors text NOT NULL,
|
editors text NOT NULL,
|
||||||
modified timestamptz NOT NULL
|
modified timestamptz NOT NULL
|
||||||
);
|
);
|
||||||
|
ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns text NOT NULL DEFAULT 'user' CHECK (dns IN ('user', 'router'));
|
||||||
|
ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_address text NOT NULL DEFAULT 'public'
|
||||||
|
CHECK (dns_address IN ('public', 'internal', 'external'));
|
||||||
|
|
||||||
|
-- The functions whose columns and the procedure whose arguments changed with dns and dns_address.
|
||||||
|
DROP FUNCTION IF EXISTS sites_list();
|
||||||
|
DROP FUNCTION IF EXISTS site_get(text);
|
||||||
|
DROP FUNCTION IF EXISTS sites_served();
|
||||||
|
DROP PROCEDURE IF EXISTS site_put(text, boolean, text[], integer, text, text, text, text, text, text, boolean, text);
|
||||||
|
|
||||||
-- Every site, by name; the private key is left out.
|
-- Every site, by name; the private key is left out.
|
||||||
CREATE OR REPLACE FUNCTION sites_list()
|
CREATE OR REPLACE FUNCTION sites_list()
|
||||||
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
|
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
|
||||||
certificate text, index_file text, spa boolean, editors text, modified timestamptz)
|
certificate text, index_file text, spa boolean, editors text, dns text, dns_address text,
|
||||||
|
modified timestamptz)
|
||||||
LANGUAGE sql STABLE AS $$
|
LANGUAGE sql STABLE AS $$
|
||||||
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, index_file, spa, editors, modified
|
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, index_file, spa, editors, dns, dns_address, modified
|
||||||
FROM sites ORDER BY name;
|
FROM sites ORDER BY name;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- The site with its private key.
|
-- The site with its private key.
|
||||||
CREATE OR REPLACE FUNCTION site_get(p_name text)
|
CREATE OR REPLACE FUNCTION site_get(p_name text)
|
||||||
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
|
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
|
||||||
certificate text, private_key text, index_file text, spa boolean, editors text, modified timestamptz)
|
certificate text, private_key text, index_file text, spa boolean, editors text, dns text,
|
||||||
|
dns_address text, modified timestamptz)
|
||||||
LANGUAGE sql STABLE AS $$
|
LANGUAGE sql STABLE AS $$
|
||||||
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, modified
|
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, dns,
|
||||||
|
dns_address, modified
|
||||||
FROM sites WHERE name = p_name;
|
FROM sites WHERE name = p_name;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- The enabled sites with their private keys, by name, for Caddy's configuration and certificate files.
|
-- The enabled sites with their private keys, by name, for Caddy's configuration and certificate files.
|
||||||
CREATE OR REPLACE FUNCTION sites_served()
|
CREATE OR REPLACE FUNCTION sites_served()
|
||||||
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
|
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
|
||||||
certificate text, private_key text, index_file text, spa boolean, editors text, modified timestamptz)
|
certificate text, private_key text, index_file text, spa boolean, editors text, dns text,
|
||||||
|
dns_address text, modified timestamptz)
|
||||||
LANGUAGE sql STABLE AS $$
|
LANGUAGE sql STABLE AS $$
|
||||||
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, modified
|
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, dns,
|
||||||
|
dns_address, modified
|
||||||
FROM sites WHERE enabled ORDER BY name;
|
FROM sites WHERE enabled ORDER BY name;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- Add the site, or replace all its settings.
|
-- Add the site, or replace all its settings.
|
||||||
CREATE OR REPLACE PROCEDURE site_put(p_name text, p_enabled boolean, p_hosts text[], p_port integer, p_listen text,
|
CREATE OR REPLACE PROCEDURE site_put(p_name text, p_enabled boolean, p_hosts text[], p_port integer, p_listen text,
|
||||||
p_tls text, p_ca text, p_certificate text, p_private_key text, p_index_file text,
|
p_tls text, p_ca text, p_certificate text, p_private_key text, p_index_file text,
|
||||||
p_spa boolean, p_editors text)
|
p_spa boolean, p_editors text, p_dns text, p_dns_address text)
|
||||||
LANGUAGE sql AS $$
|
LANGUAGE sql AS $$
|
||||||
INSERT INTO sites VALUES (p_name, p_enabled, p_hosts, p_port, p_listen, p_tls, p_ca, p_certificate, p_private_key,
|
INSERT INTO sites (name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors,
|
||||||
p_index_file, p_spa, p_editors, now())
|
dns, dns_address, modified)
|
||||||
|
VALUES (p_name, p_enabled, p_hosts, p_port, p_listen, p_tls, p_ca, p_certificate, p_private_key, p_index_file,
|
||||||
|
p_spa, p_editors, p_dns, p_dns_address, now())
|
||||||
ON CONFLICT (name) DO UPDATE SET enabled = p_enabled, hosts = p_hosts, port = p_port, listen = p_listen,
|
ON CONFLICT (name) DO UPDATE SET enabled = p_enabled, hosts = p_hosts, port = p_port, listen = p_listen,
|
||||||
tls = p_tls, ca = p_ca, certificate = p_certificate, private_key = p_private_key, index_file = p_index_file,
|
tls = p_tls, ca = p_ca, certificate = p_certificate, private_key = p_private_key, index_file = p_index_file,
|
||||||
spa = p_spa, editors = p_editors, modified = now();
|
spa = p_spa, editors = p_editors, dns = p_dns, dns_address = p_dns_address, modified = now();
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
CREATE OR REPLACE PROCEDURE site_remove(p_name text)
|
CREATE OR REPLACE PROCEDURE site_remove(p_name text)
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ http://{{ local_fqdn }} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{%- if https and manager_port %}
|
{%- if https %}
|
||||||
{{ local_fqdn }}:{{ https_manager_port }} {
|
{{ local_fqdn }}:{{ https_manager_port }} {
|
||||||
reverse_proxy {{ loopback }}:{{ manager_port }}
|
reverse_proxy {{ loopback }}:{{ manager_port }}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Run the manager and the Vite development server on this workstation.
|
||||||
|
# Usage: ./dev.sh <platform config.json> [PostgreSQL connection string]
|
||||||
|
# The router's address comes from the outputs of the platform's root module, module_root of that
|
||||||
|
# config.json, relative to its directory, read with its iac_tool; the sessions are kept in the database of the connection
|
||||||
|
# string, or without one in PGDATABASE (else the login name) with libpq's PG* variables for the
|
||||||
|
# rest. The manager reads the generated dev.json, whose bind is 127.0.0.1, and listens on 127.0.0.1:7080; its Python packages
|
||||||
|
# are installed in .venv. The application is served at http://localhost:5180 and passes /api to the
|
||||||
|
# manager.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ $# -lt 1 ] || [ $# -gt 2 ]; then
|
||||||
|
printf 'Usage: %s <platform config.json> [PostgreSQL connection string]\n' "$0" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
DATABASE="${2:-dbname=${PGDATABASE:-$(id -un)}}"
|
||||||
|
if [ ! -f "$1" ]; then
|
||||||
|
printf 'ERROR: %s does not exist.\n' "$1" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
SETTINGS="$ROOT/dev.json"
|
||||||
|
PLATFORM_CONFIG="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
|
||||||
|
|
||||||
|
MODULE_ROOT="$(python3 - "$PLATFORM_CONFIG" <<'EOF'
|
||||||
|
import json, os, sys
|
||||||
|
path = sys.argv[1]
|
||||||
|
root = json.load(open(path, encoding='utf-8')).get('module_root')
|
||||||
|
if not isinstance(root, str) or not root.strip() or os.path.isabs(root):
|
||||||
|
sys.exit(f'module_root in {path} must be a directory relative to {os.path.dirname(path)}')
|
||||||
|
print(os.path.normpath(os.path.join(os.path.dirname(path), root)))
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
# The tool that reads the outputs is the platform's, iac_tool of its config.json: tofu or terraform.
|
||||||
|
IAC_TOOL="$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1], encoding="utf-8")).get("iac_tool") or "terraform")' "$PLATFORM_CONFIG")"
|
||||||
|
if [ ! -d "$MODULE_ROOT" ]; then
|
||||||
|
printf 'ERROR: module_root %s is not a directory.\n' "$MODULE_ROOT" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
python3 - "$SETTINGS" "$MODULE_ROOT" "$DATABASE" "$IAC_TOOL" <<'EOF'
|
||||||
|
import json, sys
|
||||||
|
with open(sys.argv[1], 'w', encoding='utf-8') as handle:
|
||||||
|
json.dump({'terraform_dir': sys.argv[2], 'database': sys.argv[3], 'iac_tool': sys.argv[4], 'bind': '127.0.0.1'}, handle)
|
||||||
|
handle.write('\n')
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ ! -x "$ROOT/.venv/bin/python" ]; then
|
||||||
|
python3 -m venv "$ROOT/.venv"
|
||||||
|
fi
|
||||||
|
if ! "$ROOT/.venv/bin/python" -c 'import psycopg, requests' 2>/dev/null; then
|
||||||
|
"$ROOT/.venv/bin/pip" install --quiet 'psycopg[binary]' requests
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$ROOT/web"
|
||||||
|
if [ ! -d node_modules ] || [ package-lock.json -nt node_modules ]; then
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$ROOT/.venv/bin/python" "$ROOT/debian-package/src/usr/lib/vpn-router/manager" --config "$SETTINGS" &
|
||||||
|
MANAGER=$!
|
||||||
|
trap 'kill $MANAGER 2>/dev/null' EXIT INT TERM
|
||||||
|
|
||||||
|
echo "Platform module: $MODULE_ROOT"
|
||||||
|
VITE_MANAGER=http://127.0.0.1:7080 npm run dev
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Account
|
||||||
|
|
||||||
|
Your own account: your name, kind, names, e-mail address, whether you are an administrator and
|
||||||
|
whether you are enrolled for one-time passwords. The Account and the Passkeys panel are side by
|
||||||
|
side, with One-time passwords and, for VPN users, VPN profiles below them.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
Each change but removing a passkey asks for your current password and, when you are enrolled, a
|
||||||
|
code not already used to sign in.
|
||||||
|
|
||||||
|
- **Change password** (Account panel): opens a dialog for the current and the new password; the
|
||||||
|
change signs you out.
|
||||||
|
- **Enrol** or **replace** an authenticator app: scan the QR code, then enter a code of the app.
|
||||||
|
Removing the enrolment is left to an administrator.
|
||||||
|
- **Add a passkey:** the browser or your password manager creates a passkey for the router's name;
|
||||||
|
each passkey has a label and the time it was added, and a remove icon.
|
||||||
|
- **VPN profiles** (VPN users), each after your current password and, when enrolled, a code:
|
||||||
|
- **Download the OpenVPN profile:** a `.ovpn` file for an OpenVPN client, which signs in with
|
||||||
|
your name and password.
|
||||||
|
- **Download the Apple VPN profile (IKEv2):** a `.mobileconfig` that sets up the router's IKEv2
|
||||||
|
VPN on a Mac, iPhone or iPad. It also asks for a profile password, which the device asks for
|
||||||
|
when you install the profile in System Settings (Mac) or Settings (iPhone, iPad).
|
||||||
|
|
||||||
|
## Passkeys
|
||||||
|
|
||||||
|
**Sign in with a passkey** on the sign-in page uses a passkey the browser offers, without the
|
||||||
|
name, the password or the code. Passkeys work on the Manager's HTTPS address on the router's name
|
||||||
|
only. They sign in to the Manager and to the router's OpenID Provider, and open a shell on the
|
||||||
|
[Shell](shell.md) page in place of the password and code.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Backup
|
||||||
|
|
||||||
|
The router's backups: to a local folder, copied to the cloud with rclone, or to a Proxmox Backup
|
||||||
|
Server.
|
||||||
|
|
||||||
|
## Sections
|
||||||
|
|
||||||
|
- **Settings:** whether scheduled backups run, the schedule (hourly, daily or weekly), the target
|
||||||
|
and whether the metrics database is included. A local folder also shows the folder, the archives
|
||||||
|
kept and the cloud copy; a Proxmox Backup Server the repository, whether the token secret is
|
||||||
|
set, the fingerprint, the namespace and the encryption key file.
|
||||||
|
- **Last run:** its result, times, archive or snapshot, size, cloud copy and error, and the next
|
||||||
|
run.
|
||||||
|
- **Local archives:** with their times and sizes, searched by name and time, 10, 25, 50 or 100 at
|
||||||
|
a time.
|
||||||
|
- **Warnings:** the PBS client or rclone the settings need is not installed on the router.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
- **Edit:** changes the settings in one dialog. Only the retention settings that apply to the
|
||||||
|
schedule are offered. The rclone remote is one of the router's `/etc/vpn-router/rclone.conf`,
|
||||||
|
with a path and the mode, sync or copy. The token secret is kept when left empty.
|
||||||
|
- **Run now:** starts a backup; the tab reads every 3 seconds until it ends.
|
||||||
|
|
||||||
|
Restoring is done on the router as root with `vpn-router-backup restore`.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Certificates
|
||||||
|
|
||||||
|
The router's two-level CA: a root CA and issuing CAs that sign the certificates.
|
||||||
|
|
||||||
|
## Above the tabs
|
||||||
|
|
||||||
|
- **Issue:** a certificate from a CSR, saved with its chain, or with a key the router makes, saved
|
||||||
|
as a PFX file protected by a password; the router keeps no copy of the key.
|
||||||
|
- **Settings:** the names, lifetimes, key type, CRL validity and publishing endpoints.
|
||||||
|
- **Warnings:** what needs attention, such as a CRL about to expire.
|
||||||
|
|
||||||
|
## Tabs
|
||||||
|
|
||||||
|
- **Root:** the root CA's subject and expiry, whether its key is on the router (marked with a
|
||||||
|
warning sign) or offline, and its CRL. **Download root CA** gives its certificate; **Upload CRL**
|
||||||
|
takes a root CRL signed offline. Below, the certificates of the issuing CAs.
|
||||||
|
- **Templates:** key, lifetime, subject, usages, alternative names, certificate policies and the
|
||||||
|
CAs that issue each template. **Add template**, **Edit**, **Remove** for the router's own and
|
||||||
|
**Reset** for a changed built-in one.
|
||||||
|
- **Extended key usages:** the usages templates may give, with their OIDs; custom ones are added
|
||||||
|
with a name and an OID.
|
||||||
|
- **P2S Issuing CA** and **S2S Issuing CA:** the CAs of the VPN users and of site-to-site
|
||||||
|
connections. An active CA gives its CA bundle; a pending CA, waiting for the offline root, gives
|
||||||
|
its CSR and takes the certificate the root signed.
|
||||||
|
- **Issuing CAs:** general-purpose CAs, each with its templates and an optional ACME server.
|
||||||
|
**Add issuing CA**, **Edit** and **Remove**; a removed CA is retired and its certificate revoked.
|
||||||
|
- **Certificates:** each with its kind, template, names, expiry and state; one of another system
|
||||||
|
or of the router is revoked after a confirmation with a reason.
|
||||||
|
|
||||||
|
The root key is exported, removed and imported on the router with `vpn-router-ca`, never here.
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
# DNS
|
||||||
|
|
||||||
|
The router's name server (PowerDNS), the primary of the router's domains. Every domain has the
|
||||||
|
same two name servers: the router's FQDN and a secondary name server. The [web sites](sites.md)
|
||||||
|
whose DNS is Router managed get their records in the domain they are in.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
- **State:** disabled, running, or not running yet; the router installs PowerDNS when it is
|
||||||
|
enabled.
|
||||||
|
- **Primary name server:** the router's FQDN, the SOA and the first NS of every domain. It is the
|
||||||
|
router's name and is not edited here; its own A record is kept by the DNS of its domain,
|
||||||
|
wherever that is, not by the router's domains.
|
||||||
|
- **Secondary name server:** the second NS of every domain, required while the name server is
|
||||||
|
enabled. The router notifies the addresses it resolves to of every change and allows them to
|
||||||
|
transfer the domains.
|
||||||
|
- **Public IP address:** the router's public IP, which the records of the sites with Public IP
|
||||||
|
take; empty when the router has none or no site uses it.
|
||||||
|
- **Listen:** the interfaces port 53 is open on. While IKEv2 or OpenVPN remote access is enabled,
|
||||||
|
it must not share an interface with the clients' resolver, by default on the internal one.
|
||||||
|
- **TTL:** how long resolvers keep the answers of a domain without its own TTL.
|
||||||
|
|
||||||
|
**Settings** edits them.
|
||||||
|
|
||||||
|
## Domains
|
||||||
|
|
||||||
|
**Add domain** takes the domain and an optional TTL; the edit button changes the TTL, and the
|
||||||
|
remove button removes a domain with its manual records. A domain inside another domain of the
|
||||||
|
router is delegated from it.
|
||||||
|
|
||||||
|
A domain the router hosts, for example `example.com`, with the router's FQDN `router.other.net`,
|
||||||
|
the secondary `ns2.provider.net`, and a Router managed site of `www.example.com` and
|
||||||
|
`example.com` with Public IP `203.0.113.10`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
example.com. SOA router.other.net. hostmaster.example.com. <serial> 10800 3600 604800 300
|
||||||
|
example.com. NS router.other.net.
|
||||||
|
example.com. NS ns2.provider.net.
|
||||||
|
example.com. A 203.0.113.10
|
||||||
|
www.example.com. A 203.0.113.10
|
||||||
|
```
|
||||||
|
|
||||||
|
Selecting a domain shows:
|
||||||
|
|
||||||
|
- **Records served:** what the name server answers, including the SOA and NS records.
|
||||||
|
- **Manual records:** the records added with **Add record**: A, AAAA, CNAME, TXT, MX, SRV or CAA.
|
||||||
|
A name is relative to the domain, `@` for the domain itself. A record of a name and type the
|
||||||
|
domain already has from its name servers or its sites is not served.
|
||||||
|
|
||||||
|
Changes are served after the router applies them, a few seconds later; **Refresh** shows them.
|
||||||
|
|
||||||
|
## Delegation
|
||||||
|
|
||||||
|
The parent of each domain must delegate it: NS records naming the router's FQDN and the secondary
|
||||||
|
name server, and, for a name server inside the domain, its A record (a glue record), at the
|
||||||
|
registrar or DNS provider of the parent.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Docker
|
||||||
|
|
||||||
|
Whether Docker is installed on the router, the state of `docker.service` and of the router's
|
||||||
|
Docker rules, the ports exposed on the external interface, and the rules of the
|
||||||
|
`VPN-ROUTER-DOCKER` chain.
|
||||||
|
|
||||||
|
Exposed ports are set on the router as root, for example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vpn-config-edit set --restart docker.exposed_ports 443,8000:8100
|
||||||
|
```
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Groups
|
||||||
|
|
||||||
|
The groups of the router's directory, with their descriptions and members.
|
||||||
|
|
||||||
|
- **Add** and **Edit:** the name, the description and the members in one dialog. A group has at
|
||||||
|
least one member: a user or a service account, but not an internal one.
|
||||||
|
- **Remove:** removes the group; its members are kept, and it is taken out of the roles assigned
|
||||||
|
to it. A group that a client of the [OpenID Provider](service-accounts.md#openid-provider) allows
|
||||||
|
cannot be removed.
|
||||||
|
|
||||||
|
## Groups the router reads
|
||||||
|
|
||||||
|
- **admins:** its members use all pages of the Manager. Only root on the router changes it, with
|
||||||
|
`vpn-router-group` or a token of `vpn-router-token`.
|
||||||
|
- **Allowed groups** of an OpenID Connect client: only their members may sign in to that client.
|
||||||
|
Renaming a group renames it in those clients.
|
||||||
|
- **Roles** of a resource of the OpenID Provider assigned to a group are roles of its members, in
|
||||||
|
the tokens for that resource. Renaming a group renames it in those roles.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# VPN Router Manager Documentation
|
||||||
|
|
||||||
|
The VPN Router Manager shows the state of a router running the `vpn-router` package and manages
|
||||||
|
its users, connections, certificates and services. Members of the router's group `admins` see all
|
||||||
|
pages; any other user sees only their own account.
|
||||||
|
|
||||||
|
The help opens at the topic of the page you are on. Links between topics open in this panel;
|
||||||
|
Back returns to the previous topic and the house icon to this one.
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
| Page | Topics |
|
||||||
|
| --- | --- |
|
||||||
|
| Router | [Overview](router.md), [Metrics](metrics.md), [Logs](logs.md), [Backup](backup.md), [System](system.md) |
|
||||||
|
| Identities | [Users](users.md), [Groups](groups.md), [Service accounts](service-accounts.md) |
|
||||||
|
| Connectivity | [Site-to-site connections](site-to-site.md) |
|
||||||
|
| Remote Access | [IKEv2, OpenVPN and WireGuard](remote-access.md) |
|
||||||
|
| Certificates | [Certificates](certificates.md) |
|
||||||
|
| Web Sites | [Sites](sites.md), [Files](site-files.md), [DNS](dns.md) |
|
||||||
|
| Docker | [Docker](docker.md) |
|
||||||
|
| Header | [Shell](shell.md), [Account](account.md) |
|
||||||
|
|
||||||
|
## Header
|
||||||
|
|
||||||
|
- **Page menu** (right end): switches between the pages; its button names the page shown.
|
||||||
|
- **Help** (question mark, left of the page menu): opens and closes this panel; the page moves left
|
||||||
|
while it is open.
|
||||||
|
- **Shell** (terminal icon, administrators): a shell on the router; pressed on the Shell page, it
|
||||||
|
returns to the page it was opened from.
|
||||||
|
- **Your name**: opens your account; pressed there, it returns to the page it was opened from.
|
||||||
|
- **Sign out**, **Settings** (gear, but not on the account page) and **Theme** (light, dark or the system's).
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
The gear, hidden on the sign-in and the account page, sets how often the Overview and the Metrics tab read the router on their own: manually, or
|
||||||
|
every 5, 10 or 30 seconds, 1, 5 or 15 minutes. The browser keeps the choice; Defaults sets it back.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Logs
|
||||||
|
|
||||||
|
The last lines of the router's logs.
|
||||||
|
|
||||||
|
## Sub-tabs
|
||||||
|
|
||||||
|
- **Journal:** the systemd journal of one of the router's units, or of all units.
|
||||||
|
- **Files:** a log file the router lists in its setting `web.log_files`. Files are added on the
|
||||||
|
router as root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vpn-config-edit set web.log_files /var/log/syslog,/var/log/auth.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
- **Unit** or **File:** the log shown.
|
||||||
|
- **Lines:** 100, 200, 500 or 1000 of the last lines; the router answers at most 1000 at a time,
|
||||||
|
and reads at most the last MiB of a file.
|
||||||
|
- **Follow:** reads the log every 2 seconds and keeps the view at its last line, as `tail -f`
|
||||||
|
does. Refresh is off meanwhile.
|
||||||
|
- **Refresh:** reads the log again. A log is also read when the unit, the file or the number of
|
||||||
|
lines changes.
|
||||||
|
|
||||||
|
Older lines stay on the router: `journalctl` and the files themselves, over SSH or the
|
||||||
|
[Shell](shell.md).
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Metrics
|
||||||
|
|
||||||
|
Charts of the router over the last hour, day, month or year, read every minute by default (see
|
||||||
|
[Settings](index.md#settings)).
|
||||||
|
|
||||||
|
## Charts
|
||||||
|
|
||||||
|
- CPU, memory, free disk space in bytes and in percent, and the load averages.
|
||||||
|
- Bytes per second of each interface, and the VPN clients.
|
||||||
|
- Per site-to-site connection: **Site-to-site up**, **Site-to-site traffic** and
|
||||||
|
**Site-to-site packets**.
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
- **Hour, Day, Month, Year:** the period shown.
|
||||||
|
- **Average, Maximum:** how each point sums up its time slot. Site-to-site up is always the
|
||||||
|
percentage of the slot the connection was up: 5 minutes for an hour, 1 hour for a day, 1 day
|
||||||
|
for a month and 1 month for a year.
|
||||||
|
- **Legend:** a click on a line's legend item hides or shows the line, for example the 1 and 5
|
||||||
|
minute load averages to see the 15 minute one. The browser keeps the hidden lines.
|
||||||
|
- The charts fold, hide, move and change width as the [Router](router.md#layout) panels do, with
|
||||||
|
a layout of their own.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Remote Access
|
||||||
|
|
||||||
|
A tab each for IKEv2, OpenVPN and WireGuard access: the settings, whether the router
|
||||||
|
runs the system, why an enabled one does not run yet, and the connected clients.
|
||||||
|
|
||||||
|
## Edit
|
||||||
|
|
||||||
|
Enables or disables the system and sets its address pool (for WireGuard the address of `wg0`) and
|
||||||
|
its port, with OpenVPN's protocol. The router applies a change in the background.
|
||||||
|
|
||||||
|
IKEv2 and OpenVPN also need the router's `wan.local_fqdn` and its VPN server certificate. A cloud
|
||||||
|
firewall in front of the router, such as an Azure network security group, must allow the OpenVPN
|
||||||
|
and WireGuard ports itself.
|
||||||
|
|
||||||
|
## IKEv2
|
||||||
|
|
||||||
|
The IPsec security associations, child SAs, connections and connection children of IKEv2 access.
|
||||||
|
|
||||||
|
**DNS domains** (Edit, one per line): the domains, with their subdomains, the clients resolve with
|
||||||
|
the router's DNS, such as `internal.cloudapp.net`; other names stay with the clients' own DNS. The
|
||||||
|
router pushes them over IKEv2, and the Apple VPN profile names them, so a Mac, iPhone or iPad needs
|
||||||
|
a newly downloaded profile after a change.
|
||||||
|
|
||||||
|
## OpenVPN
|
||||||
|
|
||||||
|
Users connect with the client profile downloaded on the [Users](users.md) tab, and sign in with
|
||||||
|
their password and, when enrolled, the code.
|
||||||
|
|
||||||
|
## WireGuard
|
||||||
|
|
||||||
|
- **Connections:** each peer with its user, IPs, endpoint, handshake and traffic.
|
||||||
|
- **Peers:** the devices of the VPN users, each with its user, public key and IPs.
|
||||||
|
- **Add peer:** the user, the device's public key and the IPs, which start as the next free
|
||||||
|
address of the `wg0` network. The router checks that keys are unique and IPs do not overlap.
|
||||||
|
- The eye opens the device's configuration; the download gives `<user>-<n>.conf`, with a
|
||||||
|
placeholder for the device's private key, which the router never has.
|
||||||
|
- The remove button removes a peer after a confirmation.
|
||||||
|
- **Server:** the router's public key with a copy icon, the endpoint and the allowed IPs a device
|
||||||
|
needs; **Show config** opens a device configuration with placeholders.
|
||||||
|
- In the configuration dialogs a switch leaves out the `[Interface]` and `PrivateKey` lines, which
|
||||||
|
WireGuard for Windows fills in for a new empty tunnel.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Overview
|
||||||
|
|
||||||
|
The Overview tab shows the state of the router, read every 10 seconds by default (see
|
||||||
|
[Settings](index.md#settings)). The updates of the other packages of the system are on the
|
||||||
|
[System](system.md) tab.
|
||||||
|
|
||||||
|
## Panels
|
||||||
|
|
||||||
|
- **Status:** the overall status, ok or degraded, on its title bar; the installed version of
|
||||||
|
`vpn-router` and, when the repository has a newer one, that version;
|
||||||
|
IKEv2, OpenVPN and WireGuard; the status web service and its port; HTTPS and the name it is for,
|
||||||
|
with a chip while the certificates come from Let's Encrypt's staging CA and a settings button
|
||||||
|
(see HTTPS settings below); and Caddy, its HTTPS proxy, with its version, where it comes from (Ubuntu or upstream) and the
|
||||||
|
state of `caddy.service`.
|
||||||
|
- **Services:** the state of each of the router's systemd services and of Caddy; the chip on the
|
||||||
|
title bar tells whether the router's services are active.
|
||||||
|
- **Addresses:** links to the status page over HTTPS and over plain HTTP, and to the Router
|
||||||
|
Manager over HTTPS, each opening in a new tab; and the issuer and the discovery URL of the
|
||||||
|
router's OpenID Provider, each with a copy icon, or off while the provider is disabled (see
|
||||||
|
[Service accounts](service-accounts.md#openid-provider)).
|
||||||
|
- **Backup:** the result of the last run, the next run and Run now (see [Backup](backup.md)).
|
||||||
|
- **PostgreSQL:** each cluster with its main settings and its databases with their sizes.
|
||||||
|
- **Configuration:** the sections of the router's configuration the other pages do not show,
|
||||||
|
among them the status web service (`web`) and HTTPS (`https`).
|
||||||
|
|
||||||
|
Caddy is switched to upstream's packages on the router as root with
|
||||||
|
`vpn-router-caddy use-upstream`.
|
||||||
|
|
||||||
|
## HTTPS settings
|
||||||
|
|
||||||
|
The settings button of the HTTPS row switches **Let's Encrypt staging CA**. While it is on, Caddy
|
||||||
|
takes every certificate, the web sites' included, from Let's Encrypt's staging environment, whose
|
||||||
|
rate limits are far higher but whose certificates no browser trusts: it is for routers that are
|
||||||
|
rebuilt often, for tests only. While it is off, Caddy takes its certificates from Let's Encrypt,
|
||||||
|
and from ZeroSSL when Let's Encrypt refuses, such as for its limit of 5 certificates a week for the
|
||||||
|
same name. Saving runs the router's setup again, which applies the change. The other HTTPS
|
||||||
|
settings are changed on the router with `vpn-config-edit`.
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
- **Check update** asks the router's package repository for newer versions.
|
||||||
|
- **Upgrade** appears when one is found; it installs the new packages after a confirmation and
|
||||||
|
shows the upgrade's output. A reboot is offered when a package asks for one.
|
||||||
|
- Nothing is read while a check, an upgrade or a reboot runs.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- A panel folds by its title bar, and every panel but Status hides by its close icon; **Panels**
|
||||||
|
shows the hidden ones again.
|
||||||
|
- **Edit layout** moves panels by dragging and switches each between one column and both.
|
||||||
|
- The browser keeps the layout.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Service accounts
|
||||||
|
|
||||||
|
Accounts that other systems sign in with to use the router's services, such as an OpenTelemetry
|
||||||
|
Collector reading the router's metrics. They cannot sign in to the Manager.
|
||||||
|
|
||||||
|
## Columns
|
||||||
|
|
||||||
|
- **Name:** a GUID, which Add proposes, or a user name.
|
||||||
|
- **Kind:** a service account, or **Internal**: the router creates and manages an internal one for
|
||||||
|
its own services, so it has no actions here.
|
||||||
|
- **OpenID Provider:**
|
||||||
|
- as a client: the number of redirect URIs and the allowed groups, the grants, `public`, and the
|
||||||
|
scopes it asks for;
|
||||||
|
- as a resource: its scopes and roles;
|
||||||
|
- `-` for none.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
- **Add** and **Edit:** the display name, the description, an optional e-mail address and the
|
||||||
|
OpenID Provider settings, as a client and as a resource; Add also takes the password.
|
||||||
|
- **Password**, **Disable**, **Enable**, **Unlock** after failed logins, and **Remove**.
|
||||||
|
|
||||||
|
## OpenID Provider
|
||||||
|
|
||||||
|
The router can sign its users in to other systems with OpenID Connect. Its issuer and discovery
|
||||||
|
URL are on the Addresses panel of the [Overview](router.md), each with a copy icon, and while it is
|
||||||
|
enabled the title of the list has the number of its clients. It is turned on on the router as
|
||||||
|
root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vpn-config-edit set --restart oidc.enabled true
|
||||||
|
```
|
||||||
|
|
||||||
|
### As a client
|
||||||
|
|
||||||
|
A service account with a redirect URI or a grant is a client:
|
||||||
|
|
||||||
|
- **Client ID:** the account's name.
|
||||||
|
- **Client secret:** the account's password.
|
||||||
|
- **Redirect URIs:** where the provider sends the browser back after a sign-in; `https`, or
|
||||||
|
`http` for `localhost`, on any port. One per line. They give the client the authorization code
|
||||||
|
flow with PKCE (S256).
|
||||||
|
- **Post-logout redirect URIs:** where the browser may go after signing out. One per line.
|
||||||
|
- **Allowed groups:** only their members may sign in to the client; without any, every user may.
|
||||||
|
- **Client credentials:** the client gets a token as its own service account, with its secret and
|
||||||
|
without a user, for jobs and scripts.
|
||||||
|
- **Device authorization:** a program without a browser shows a code. The user opens the router's
|
||||||
|
device page, enters the code, signs in, and allows or denies the program.
|
||||||
|
- **Public client (no secret):** for programs that cannot keep a secret, such as a CLI; it cannot
|
||||||
|
use client credentials.
|
||||||
|
- **Client scopes:** the scopes of resources the client may ask for, as `<resource>:<scope>`.
|
||||||
|
|
||||||
|
Users sign in on the router's page with their password and code, or with a passkey.
|
||||||
|
|
||||||
|
### As a resource
|
||||||
|
|
||||||
|
Every service account is also a resource: an application that checks the router's tokens, such
|
||||||
|
as an S3 gateway. A client asks for a token for it by its name, or by asking for its scopes.
|
||||||
|
|
||||||
|
- **Scopes:** what clients may ask to do there, such as `read`, asked for as `<name>:read`. A
|
||||||
|
scope a client may ask for cannot be removed.
|
||||||
|
- **Roles:** names such as `Reader`, each assigned to groups, users and service accounts. A token
|
||||||
|
for the resource carries, in `roles`, the roles its user or service account holds, directly or
|
||||||
|
through a group.
|
||||||
|
|
||||||
|
The tokens are signed JWTs, which the resource checks with the provider's keys. The access token
|
||||||
|
names the resource as its audience, and carries the scopes granted and the roles. The ID token,
|
||||||
|
for the client, carries the user's roles of the client itself.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Shell
|
||||||
|
|
||||||
|
A login shell on the router, as your console account: your own POSIX account of the directory, or
|
||||||
|
a local account root chose.
|
||||||
|
|
||||||
|
- **Opening** asks for your password and code again, unless you used a shell within the router's
|
||||||
|
`web.shell_auth_timeout` minutes. **Open with a passkey** takes one of your passkeys instead,
|
||||||
|
unlocked with your device's PIN or biometrics; it shows when you have a passkey (see
|
||||||
|
[Account](account.md)).
|
||||||
|
- **Closing:** the router ends the shell after `web.shell_idle_minutes` without a keystroke; the
|
||||||
|
page shows the reason and keeps the terminal readable.
|
||||||
|
|
||||||
|
## Editor
|
||||||
|
|
||||||
|
`vre FILE...` (or `vpn-router-editor FILE...`) in the shell opens the files in the editor of this
|
||||||
|
page and returns at once:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vre /etc/hosts ~/notes.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Tabs:** each file has a tab with its name, and its path as the tooltip. A dot marks unsaved
|
||||||
|
changes, and the cross closes the tab, asking first when there are unsaved changes.
|
||||||
|
- **Editor icon** (left of the shell icon): shown while a file is open. It switches between the
|
||||||
|
terminal and the editor; the shell keeps running.
|
||||||
|
- **Minimap button** (left of Save): shows or hides the minimap at the right edge of the editor.
|
||||||
|
This browser remembers the choice.
|
||||||
|
- **Save** (or Ctrl+S): writes the file on the router, as your console account. When the file
|
||||||
|
changed on the router after it was read, **Reload** takes the router's version and **Overwrite**
|
||||||
|
writes yours.
|
||||||
|
- **Limits:** text files of up to 1 MiB, at most 20 at a time. A file that does not exist is
|
||||||
|
created when saved. A file your console account may not write cannot be saved; edit it in the
|
||||||
|
terminal.
|
||||||
|
- **When files close:** when the shell ends, every file closes and the icon goes away. `vre`
|
||||||
|
works only in this shell.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Membership of `admins`, enrolment for one-time passwords (see [Account](account.md)), also when
|
||||||
|
you open the shell with a passkey, as the router's login session and `sudo` need it, and console
|
||||||
|
login, which root switches on the router:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vpn-router-user console <name> --posix
|
||||||
|
```
|
||||||
|
|
||||||
|
- The router offers the shell only while its `web.shell` is `true`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vpn-config-edit set web.shell true
|
||||||
|
```
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Site files
|
||||||
|
|
||||||
|
The files of one web site, a folder at a time. **Site** chooses the site; the path above the list
|
||||||
|
leads back up to the site's folder, as does `..` in the list.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
- **Upload:** sends the chosen files into the folder shown. Files dropped on the list are
|
||||||
|
uploaded the same way.
|
||||||
|
- **Extract zip archives:** while it is on, an uploaded `.zip` file is extracted into the folder
|
||||||
|
shown instead of being stored.
|
||||||
|
- **New folder**, **Rename** and **Remove**; removing a folder removes everything in it.
|
||||||
|
- A file's name or **Download** saves it.
|
||||||
|
|
||||||
|
An upload shows its progress. A file larger than the largest upload of the
|
||||||
|
[Web sites](sites.md) settings is not sent. A zip archive with a path that leaves the folder, a
|
||||||
|
link, more than 10000 entries or more content than the largest upload is refused as a whole.
|
||||||
|
|
||||||
|
Caddy serves a folder's index file, `index.html` unless the site names another.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Site-to-site connections
|
||||||
|
|
||||||
|
The Connectivity page: the router's IPsec site-to-site connections and its network.
|
||||||
|
|
||||||
|
## Status tab
|
||||||
|
|
||||||
|
Each connection the router set up, with the local and the remote identity, the subnets it
|
||||||
|
resolved and how long it has been up, and their IPsec security associations, child SAs,
|
||||||
|
connections and connection children. IKEv2 access connections are on the IKEv2 tab of
|
||||||
|
[Remote Access](remote-access.md).
|
||||||
|
|
||||||
|
## Network tab
|
||||||
|
|
||||||
|
The external and the internal interface with their addresses, the internal gateway, the local
|
||||||
|
CIDRs and the IPv4 routes. The panels fold, hide, move and change width; the browser keeps their
|
||||||
|
layout.
|
||||||
|
|
||||||
|
## Connections tab
|
||||||
|
|
||||||
|
Each connection with its remote peer, remote identity, remote and local CIDRs, local identity mode,
|
||||||
|
authentication and whether the tunnel is established.
|
||||||
|
|
||||||
|
- **Add**, **Edit** and **Remove** (after a confirmation).
|
||||||
|
- **Pre-shared key:** typed as a password; required while the connection has none and kept when
|
||||||
|
left empty; never shown.
|
||||||
|
- **Certificates:** the PEM certificate of a remote CA is optional; without it the peer's
|
||||||
|
certificate must come from the router's site-to-site CA.
|
||||||
|
|
||||||
|
The router applies a change in the background by running its setup again. The connection the
|
||||||
|
router was installed with is `remote-site`.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Web sites
|
||||||
|
|
||||||
|
Static web sites that Caddy serves on the router, each from its own folder,
|
||||||
|
`/var/lib/vpn-router/sites/<name>`. The files of a site are on the [Files](site-files.md) tab, and
|
||||||
|
the name server of their domain on the [DNS](dns.md) tab.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
- **State:** whether the sites are served.
|
||||||
|
- **Largest upload:** the largest file or zip archive the Files tab and the router's API take.
|
||||||
|
- **WebDAV:** whether the folders are served over WebDAV, and its URL with a copy icon.
|
||||||
|
|
||||||
|
**Settings** edits them.
|
||||||
|
|
||||||
|
## Columns
|
||||||
|
|
||||||
|
- **URLs:** the addresses of the site, one per host name; a site without host names answers any
|
||||||
|
name on its port.
|
||||||
|
- **Port** and **Listen:** the TCP port, and the interfaces its firewall opens: internal,
|
||||||
|
external or both.
|
||||||
|
- **TLS:** None (plain HTTP), Let's Encrypt, an issuing CA of the Certificates page, or a
|
||||||
|
certificate and key given with the site.
|
||||||
|
- **Editors:** the group whose members may change the files besides the admins.
|
||||||
|
- **DNS:** who serves the records of the host names. **User managed:** a DNS provider outside the
|
||||||
|
router. **Router managed:** the router's name server gives each host name an A or AAAA record in
|
||||||
|
its domain on the [DNS](dns.md) tab, with the router's **Public IP** (the Public IP address of
|
||||||
|
the DNS settings), **Internal interface IP** or **External interface IP**. A host name in none of
|
||||||
|
the router's domains gets no record.
|
||||||
|
|
||||||
|
A host name may be the router's FQDN, on a port of the site's own, such as 443, which the router's
|
||||||
|
own services leave free. Such a site is User managed: the record of the router's FQDN stays with
|
||||||
|
the DNS of its domain.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
- **Add** and **Edit:** the settings of a site; the name is fixed once added.
|
||||||
|
- **Files** opens the site on the Files tab.
|
||||||
|
- **Disable**, **Enable**, and **Remove**, which keeps the folder unless asked to remove it.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- The router's own ports, such as 22, 7081, 7443, 7444 and 7445, cannot be taken by a site.
|
||||||
|
- Sites sharing a port need host names of their own, the same Listen and the same kind of TLS.
|
||||||
|
- Let's Encrypt needs host names that resolve to the router from the internet, and port 80.
|
||||||
|
- A certificate and key must belong together and name the host names.
|
||||||
|
- An issuing CA's certificates are trusted by browsers that trust the router's root CA.
|
||||||
|
|
||||||
|
## WebDAV
|
||||||
|
|
||||||
|
With WebDAV on, a WebDAV client signs in at the WebDAV URL with a user name and password of the
|
||||||
|
router, followed by the TOTP code for an enrolled user. Admins see a folder for every site, other
|
||||||
|
users the folders of the sites whose editors group they are members of. An upload reaches the site
|
||||||
|
a few seconds after it is written.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# System
|
||||||
|
|
||||||
|
The System tab manages the router's operating system.
|
||||||
|
|
||||||
|
## System updates
|
||||||
|
|
||||||
|
- The panel lists the packages `apt-get upgrade` would upgrade, each with its installed and its
|
||||||
|
new version, as of apt's last update of its package lists.
|
||||||
|
- **Check update** updates apt's package lists and lists the packages again.
|
||||||
|
- **Upgrade** appears when a package can be upgraded; after a confirmation it runs
|
||||||
|
`apt-get upgrade -y` on the router and shows its output. It upgrades the installed packages
|
||||||
|
only: none is installed or removed, and a package that needs a new one is kept back.
|
||||||
|
- Services restart during the upgrade; when `vpn-router` is upgraded, the page may not
|
||||||
|
answer for a while, and **Reload page** loads its new version.
|
||||||
|
- A reboot is offered when a package asks for one, as after a new kernel.
|
||||||
|
- Only one upgrade runs at a time: this one or the upgrade of `vpn-router` on the
|
||||||
|
[Overview](router.md) tab.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Users
|
||||||
|
|
||||||
|
The users of the router's directory, without the [service accounts](service-accounts.md).
|
||||||
|
|
||||||
|
## Kinds of users
|
||||||
|
|
||||||
|
- **VPN user:** a first name, a last name, an e-mail address and a certificate; signs in to
|
||||||
|
OpenVPN, IKEv2 and WireGuard, and to the Manager.
|
||||||
|
- **Manager-only account:** a name, a password and an optional e-mail address; no certificate and
|
||||||
|
no VPN access.
|
||||||
|
|
||||||
|
## Columns
|
||||||
|
|
||||||
|
- **OTP:** whether the user is enrolled for one-time passwords (TOTP).
|
||||||
|
- **Passkeys:** how many passkeys the user signs in to the Manager with.
|
||||||
|
- **Console:** for a member of `admins`, the console login: off, the user's own POSIX account with
|
||||||
|
its uid, or the local account it runs as; marked as refused while the user is not enrolled for
|
||||||
|
TOTP. Root switches it on the router with `vpn-router-user console`.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
- **Add:** a VPN user, or with Manager access only a Manager-only account.
|
||||||
|
- **Edit:** the names and the e-mail address of a VPN user, the e-mail address of a Manager-only
|
||||||
|
account.
|
||||||
|
- **Password**, **Disable**, **Enable**, **Unlock** after failed logins, and **Remove**.
|
||||||
|
- **Profile:** downloads the OpenVPN client profile, while the router runs OpenVPN.
|
||||||
|
- **Apple VPN profile** (Apple icon): downloads a `.mobileconfig` that sets up the router's IKEv2
|
||||||
|
VPN on a Mac, iPhone or iPad, while the router runs IKEv2. The password you type protects the
|
||||||
|
user's key in the profile; the device asks for it when the profile is installed.
|
||||||
|
- **OTP:** enrols an authenticator app with a QR code, stored once a code of the app is entered,
|
||||||
|
or removes the enrolment.
|
||||||
|
- **Remove passkeys:** removes all the user's passkeys.
|
||||||
|
|
||||||
|
Five wrong passwords lock a user for 15 minutes; Unlock ends that sooner.
|
||||||
@@ -140,7 +140,7 @@ variable "deploy_workload_vm" {
|
|||||||
variable "repo_url" {
|
variable "repo_url" {
|
||||||
description = "Base URL of the Debian package repository the router pulls vpn-router from."
|
description = "Base URL of the Debian package repository the router pulls vpn-router from."
|
||||||
type = string
|
type = string
|
||||||
default = "https://gitea.koszewscy.waw.pl/api/packages/slawek/debian"
|
default = "https://gitea.koszewscy.waw.pl/api/packages/koszewscy/debian"
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "ubuntu_codename" {
|
variable "ubuntu_codename" {
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ variable "deploy_workload_vm" {
|
|||||||
variable "repo_url" {
|
variable "repo_url" {
|
||||||
description = "Base URL of the Debian package repository the router pulls vpn-router from."
|
description = "Base URL of the Debian package repository the router pulls vpn-router from."
|
||||||
type = string
|
type = string
|
||||||
default = "https://gitea.koszewscy.waw.pl/api/packages/slawek/debian"
|
default = "https://gitea.koszewscy.waw.pl/api/packages/koszewscy/debian"
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "ubuntu_codename" {
|
variable "ubuntu_codename" {
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Apple container CLI on macOS, Docker CE on Linux.
|
# CONTAINER_RUNTIME from the environment, for example docker with DOCKER_CONTEXT naming a remote engine;
|
||||||
case "$(uname -s)" in
|
# otherwise Apple container CLI on macOS and Docker CE on Linux.
|
||||||
Darwin) CONTAINER_RUNTIME=container ;;
|
if [ -z "${CONTAINER_RUNTIME:-}" ]; then
|
||||||
Linux) CONTAINER_RUNTIME=docker ;;
|
case "$(uname -s)" in
|
||||||
*) printf 'ERROR: unsupported platform %s\n' "$(uname -s)" >&2; exit 1 ;;
|
Darwin) CONTAINER_RUNTIME=container ;;
|
||||||
esac
|
Linux) CONTAINER_RUNTIME=docker ;;
|
||||||
|
*) printf 'ERROR: unsupported platform %s\n' "$(uname -s)" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
OWNER="${OWNER:-slawek}"
|
OWNER="${OWNER:-koszewscy}"
|
||||||
SERVER_URL="${SERVER_URL:-https://gitea.koszewscy.waw.pl}"
|
SERVER_URL="${SERVER_URL:-https://gitea.koszewscy.waw.pl}"
|
||||||
PACKAGES_TOKEN="${PACKAGES_TOKEN:-$(cat ~/.gitea-packages-token)}"
|
PACKAGES_TOKEN="${PACKAGES_TOKEN:-$(cat ~/.gitea-packages-token)}"
|
||||||
# The Ubuntu releases whose distribution of the repository gets the package, in its component main.
|
# The Ubuntu releases whose distribution of the repository gets the package, in its component main.
|
||||||
+18
-11
@@ -32,19 +32,26 @@ def git(*args):
|
|||||||
|
|
||||||
|
|
||||||
def summary():
|
def summary():
|
||||||
"""The summary line of the first changelog entry, or None when it has none.
|
"""The summary of the first changelog entry joined into one line, or None when it has none.
|
||||||
|
|
||||||
The summary is the entry's first text line: indented by two spaces, not a "*" item,
|
The summary is the entry's first paragraph: lines indented by exactly two spaces, the
|
||||||
with a blank line between it and the header and a blank line after it.
|
first not a "*" item, with a blank line between it and the header and a blank line after it.
|
||||||
"""
|
"""
|
||||||
lines = CHANGELOG.read_text(encoding='utf-8').splitlines()
|
lines = CHANGELOG.read_text(encoding='utf-8').splitlines()
|
||||||
if len(lines) < 4 or lines[1].strip() or lines[3].strip():
|
if len(lines) < 4 or lines[1].strip():
|
||||||
return None
|
return None
|
||||||
line = lines[2]
|
paragraph = []
|
||||||
text = line.strip()
|
for line in lines[2:]:
|
||||||
if not line.startswith(' ') or line.startswith(' ') or not text or text.startswith('*') or line.startswith(' -- '):
|
if not line.strip():
|
||||||
|
break
|
||||||
|
if not line.startswith(' ') or line.startswith(' ') or line.startswith(' -- '):
|
||||||
|
return None
|
||||||
|
paragraph.append(line.strip())
|
||||||
|
else:
|
||||||
return None
|
return None
|
||||||
return text
|
if not paragraph or paragraph[0].startswith('*'):
|
||||||
|
return None
|
||||||
|
return ' '.join(paragraph)
|
||||||
|
|
||||||
|
|
||||||
def version():
|
def version():
|
||||||
@@ -61,7 +68,7 @@ def version():
|
|||||||
def main():
|
def main():
|
||||||
global DRY_RUN
|
global DRY_RUN
|
||||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
parser.add_argument('-m', '--message', help='commit message; default: the summary line of the newest changelog entry, a line without "*" between blank lines right after its header')
|
parser.add_argument('-m', '--message', help='commit message; default: the summary of the newest changelog entry, a paragraph not starting with "*" between blank lines right after its header')
|
||||||
parser.add_argument('-n', '--dry-run', action='store_true', help='print the git commands instead of running them')
|
parser.add_argument('-n', '--dry-run', action='store_true', help='print the git commands instead of running them')
|
||||||
parser.add_argument('--no-push', action='store_true', help='commit and tag, but do not push')
|
parser.add_argument('--no-push', action='store_true', help='commit and tag, but do not push')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -69,8 +76,8 @@ def main():
|
|||||||
|
|
||||||
message = args.message or summary()
|
message = args.message or summary()
|
||||||
if not message:
|
if not message:
|
||||||
sys.exit(f'no commit message: pass -m, or start the newest entry of {CHANGELOG} with a summary line '
|
sys.exit(f'no commit message: pass -m, or start the newest entry of {CHANGELOG} with a summary paragraph '
|
||||||
'without "*", indented by two spaces, with a blank line above and below it')
|
'not starting with "*", indented by two spaces, with a blank line above and below it')
|
||||||
tag = version()
|
tag = version()
|
||||||
if subprocess.run(['git', 'rev-parse', '-q', '--verify', f'refs/tags/{tag}'], cwd=ROOT,
|
if subprocess.run(['git', 'rev-parse', '-q', '--verify', f'refs/tags/{tag}'], cwd=ROOT,
|
||||||
capture_output=True).returncode == 0:
|
capture_output=True).returncode == 0:
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>VPN Router Manager</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<style>
|
||||||
|
html.light { color-scheme: light; }
|
||||||
|
html.dark { color-scheme: dark; background-color: #121212; }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
// Sets the stored MUI color scheme on <html> before the first paint.
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var mode = localStorage.getItem('mui-mode') || 'system';
|
||||||
|
var dark = mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
document.documentElement.classList.add(dark ? 'dark' : 'light');
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+3240
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "vpn-router-manager-web",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@fontsource/roboto": "^5.3.0",
|
||||||
|
"@mui/icons-material": "^9.5.0",
|
||||||
|
"@mui/material": "^9.5.0",
|
||||||
|
"@mui/x-charts": "^9.16.0",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
"dompurify": "^3.4.17",
|
||||||
|
"github-markdown-css": "^5.9.0",
|
||||||
|
"marked": "^18.1.1",
|
||||||
|
"monaco-editor": "^0.57.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
|
"react": "^19.3.1",
|
||||||
|
"react-dom": "^19.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.6.5",
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"@vitejs/plugin-react": "^6.1.2",
|
||||||
|
"oxlint": "^1.89.0",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^8.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="shield" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#e8c25a" />
|
||||||
|
<stop offset="1" stop-color="#8b4513" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path fill="url(#shield)" d="M32 4 L56 13 V30 C56 45 46 55 32 60 C18 55 8 45 8 30 V13 Z" />
|
||||||
|
<path fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" d="M20 26 H44 M38 20 L44 26 L38 32 M44 40 H20 M26 34 L20 40 L26 46" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 530 B |
@@ -0,0 +1,236 @@
|
|||||||
|
import Apple from '@mui/icons-material/Apple'
|
||||||
|
import Delete from '@mui/icons-material/Delete'
|
||||||
|
import Download from '@mui/icons-material/Download'
|
||||||
|
import Key from '@mui/icons-material/Key'
|
||||||
|
import Password from '@mui/icons-material/Password'
|
||||||
|
import {
|
||||||
|
Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Stack, Table, TableBody, TableCell,
|
||||||
|
TableHead, TableRow, TextField, Tooltip, Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import OtpDialog from './OtpDialog.tsx'
|
||||||
|
import PasskeyDialog from './PasskeyDialog.tsx'
|
||||||
|
import ProfileDialog from './ProfileDialog.tsx'
|
||||||
|
import { Section } from './RouterPanel.tsx'
|
||||||
|
import { ACCOUNT_SHAPE, CODE_PATTERN, USER_SHAPE, request, type Account, type User } from './api.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account of the signed-in user, from the router's /account, in two columns: the details with Change password,
|
||||||
|
* which opens the password dialog, and the passkeys in the first row, the enrolment for one-time passwords and the
|
||||||
|
* OpenVPN and the Apple VPN profile of a user with a certificate in the second. Each change but the removal of a
|
||||||
|
* passkey takes the current password and, for an enrolled user, a current code. A changed password ends the session,
|
||||||
|
* through onPasswordChanged.
|
||||||
|
*/
|
||||||
|
export default function AccountPanel(props: { onPasswordChanged: () => void }) {
|
||||||
|
const [account, setAccount] = useState<Account | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [enrolling, setEnrolling] = useState(false)
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [currentCode, setCurrentCode] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [again, setAgain] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [passwordError, setPasswordError] = useState<string | null>(null)
|
||||||
|
const [changingPassword, setChangingPassword] = useState(false)
|
||||||
|
const [addingPasskey, setAddingPasskey] = useState(false)
|
||||||
|
const [profile, setProfile] = useState<'openvpn' | 'apple' | null>(null)
|
||||||
|
const [passkeyError, setPasskeyError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<Account>('GET', '/account', ACCOUNT_SHAPE)
|
||||||
|
.then(setAccount)
|
||||||
|
.catch((caught: Error) => setError(caught.message))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (account === null) {
|
||||||
|
return error ? <Alert severity="error">{error}</Alert> : <CircularProgress />
|
||||||
|
}
|
||||||
|
|
||||||
|
const againProblem = again !== '' && again !== password ? 'The passwords differ.' : ''
|
||||||
|
const blocked = currentPassword === '' || (account.otp && !CODE_PATTERN.test(currentCode)) || password === '' || again !== password
|
||||||
|
|
||||||
|
async function changePassword(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (account === null || blocked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setPasswordError(null)
|
||||||
|
try {
|
||||||
|
const proof = account.otp ? { current_password: currentPassword, current_code: currentCode } : { current_password: currentPassword }
|
||||||
|
await request('PUT', '/account/password', USER_SHAPE, { ...proof, password })
|
||||||
|
props.onPasswordChanged()
|
||||||
|
} catch (caught) {
|
||||||
|
setPasswordError((caught as Error).message)
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the password dialog, unless a change is being saved, and empties its fields. */
|
||||||
|
function closePasswordDialog() {
|
||||||
|
if (saving) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setChangingPassword(false)
|
||||||
|
setCurrentPassword('')
|
||||||
|
setCurrentCode('')
|
||||||
|
setPassword('')
|
||||||
|
setAgain('')
|
||||||
|
setPasswordError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePasskey(id: string) {
|
||||||
|
if (account === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPasskeyError(null)
|
||||||
|
try {
|
||||||
|
setAccount({ ...(await request<User>('DELETE', `/account/passkeys/${encodeURIComponent(id)}`, USER_SHAPE)), admin: account.admin })
|
||||||
|
} catch (caught) {
|
||||||
|
setPasskeyError((caught as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: [string, string][] = [
|
||||||
|
['User name', account.name],
|
||||||
|
['Kind', account.manager_only ? 'Manager-only account' : 'VPN user'],
|
||||||
|
...(account.manager_only ? [] : [['First name', account.first_name ?? '-'], ['Last name', account.last_name ?? '-']] as [string, string][]),
|
||||||
|
['E-mail address', account.email ?? '-'],
|
||||||
|
['Router administrator', account.admin ? 'yes' : 'no'],
|
||||||
|
['One-time passwords', account.otp ? 'enrolled' : 'not enrolled'],
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'grid', gap: 2, gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, alignItems: 'start' }}>
|
||||||
|
<Section title="Account">
|
||||||
|
<Box component="dl" sx={{ display: 'grid', gridTemplateColumns: 'max-content 1fr', columnGap: 2, rowGap: 0.5, m: 0 }}>
|
||||||
|
{rows.map(([label, value]) => (
|
||||||
|
<Box key={label} sx={{ display: 'contents' }}>
|
||||||
|
<Typography component="dt" variant="body2" color="text.secondary">{label}</Typography>
|
||||||
|
<Typography component="dd" variant="body2" sx={{ m: 0 }}>{value}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Button variant="outlined" startIcon={<Password />} sx={{ mt: 2 }} onClick={() => setChangingPassword(true)}>Change password</Button>
|
||||||
|
</Section>
|
||||||
|
<Section title="Passkeys">
|
||||||
|
<Stack sx={{ gap: 2, alignItems: 'flex-start' }}>
|
||||||
|
{passkeyError && <Alert severity="error" sx={{ whiteSpace: 'pre-line', alignSelf: 'stretch' }}>{passkeyError}</Alert>}
|
||||||
|
{account.passkeys.length === 0
|
||||||
|
? <Typography variant="body2" color="text.secondary">No passkeys.</Typography>
|
||||||
|
: (
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Label</TableCell>
|
||||||
|
<TableCell>Added</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{account.passkeys.map((passkey) => (
|
||||||
|
<TableRow key={passkey.id}>
|
||||||
|
<TableCell>{passkey.label}</TableCell>
|
||||||
|
<TableCell sx={{ whiteSpace: 'nowrap' }}>{new Date(passkey.created).toLocaleString()}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Tooltip title="Remove">
|
||||||
|
<IconButton size="small" color="error" onClick={() => void removePasskey(passkey.id)}><Delete fontSize="small" /></IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
<Button variant="outlined" startIcon={<Key />} onClick={() => setAddingPasskey(true)}>Add a passkey</Button>
|
||||||
|
</Stack>
|
||||||
|
</Section>
|
||||||
|
<Section title="One-time passwords">
|
||||||
|
<Stack sx={{ gap: 2, alignItems: 'flex-start' }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{account.otp
|
||||||
|
? 'Signing in takes a code of the authenticator app. Enrolling again replaces the authenticator; an administrator removes the enrolment.'
|
||||||
|
: 'Enrol an authenticator app; signing in then takes a code of it.'}
|
||||||
|
</Typography>
|
||||||
|
<Button variant="outlined" onClick={() => setEnrolling(true)}>{account.otp ? 'Replace the authenticator' : 'Enrol'}</Button>
|
||||||
|
</Stack>
|
||||||
|
</Section>
|
||||||
|
{account.certificate && (
|
||||||
|
<Section title="VPN profiles">
|
||||||
|
<Stack sx={{ gap: 2, alignItems: 'flex-start' }}>
|
||||||
|
<Button variant="outlined" startIcon={<Download />} onClick={() => setProfile('openvpn')}>Download the OpenVPN profile</Button>
|
||||||
|
<Button variant="outlined" startIcon={<Apple />} onClick={() => setProfile('apple')}>Download the Apple VPN profile (IKEv2)</Button>
|
||||||
|
</Stack>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
<Dialog open={changingPassword} onClose={closePasswordDialog} fullWidth maxWidth="xs">
|
||||||
|
<form onSubmit={changePassword}>
|
||||||
|
<DialogTitle>Change password</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, pt: 1 }}>
|
||||||
|
{passwordError && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{passwordError}</Alert>}
|
||||||
|
<Typography variant="body2" color="text.secondary">Changing the password signs you out.</Typography>
|
||||||
|
<TextField label="Current password" type="password" value={currentPassword} autoComplete="current-password" autoFocus onChange={(event) => setCurrentPassword(event.target.value)} />
|
||||||
|
{account.otp && (
|
||||||
|
<TextField
|
||||||
|
label="Code"
|
||||||
|
value={currentCode}
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
helperText="A code not used to sign in."
|
||||||
|
slotProps={{ htmlInput: { inputMode: 'numeric', maxLength: 6 } }}
|
||||||
|
onChange={(event) => setCurrentCode(event.target.value.trim())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TextField label="New password" type="password" value={password} autoComplete="new-password" onChange={(event) => setPassword(event.target.value)} />
|
||||||
|
<TextField
|
||||||
|
label="New password again"
|
||||||
|
type="password"
|
||||||
|
value={again}
|
||||||
|
autoComplete="new-password"
|
||||||
|
error={againProblem !== ''}
|
||||||
|
helperText={againProblem}
|
||||||
|
onChange={(event) => setAgain(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={closePasswordDialog}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={saving || blocked}>Change password</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
{profile && (
|
||||||
|
<ProfileDialog
|
||||||
|
kind={profile}
|
||||||
|
name={account.name}
|
||||||
|
path={profile === 'apple' ? '/account/apple-profile' : '/account/profile'}
|
||||||
|
own
|
||||||
|
otp={account.otp}
|
||||||
|
onClose={() => setProfile(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{addingPasskey && (
|
||||||
|
<PasskeyDialog
|
||||||
|
otp={account.otp}
|
||||||
|
onCancel={() => setAddingPasskey(false)}
|
||||||
|
onSaved={(user) => {
|
||||||
|
setAccount({ ...user, admin: account.admin })
|
||||||
|
setAddingPasskey(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{enrolling && (
|
||||||
|
<OtpDialog
|
||||||
|
name={account.name}
|
||||||
|
path="/account/otp"
|
||||||
|
proof={{ otp: account.otp }}
|
||||||
|
onCancel={() => setEnrolling(false)}
|
||||||
|
onSave={async (secret, code, proof) => {
|
||||||
|
setAccount({ ...(await request<User>('PUT', '/account/otp', USER_SHAPE, { ...proof, secret, code })), admin: account.admin })
|
||||||
|
setEnrolling(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
+320
@@ -0,0 +1,320 @@
|
|||||||
|
import AccountCircle from '@mui/icons-material/AccountCircle'
|
||||||
|
import EditNote from '@mui/icons-material/EditNote'
|
||||||
|
import HelpOutline from '@mui/icons-material/HelpOutlineOutlined'
|
||||||
|
import Logout from '@mui/icons-material/Logout'
|
||||||
|
import Terminal from '@mui/icons-material/Terminal'
|
||||||
|
import { Box, Button, CircularProgress, Container, IconButton, Link, Stack, Tab, Tabs, Tooltip, Typography } from '@mui/material'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import AccountPanel from './AccountPanel.tsx'
|
||||||
|
import BackupPanel from './BackupPanel.tsx'
|
||||||
|
import CertificatesPanel from './CertificatesPanel.tsx'
|
||||||
|
import GroupsPanel from './GroupsPanel.tsx'
|
||||||
|
import HelpPanel, { HELP_HOME, HELP_WIDTH } from './HelpPanel.tsx'
|
||||||
|
import LogsPanel from './LogsPanel.tsx'
|
||||||
|
import MetricsPanel from './MetricsPanel.tsx'
|
||||||
|
import DockerPanel from './DockerPanel.tsx'
|
||||||
|
import DnsPanel from './DnsPanel.tsx'
|
||||||
|
import PageMenu, { CA_PATH, DOCKER_PATH, REMOTE_ACCESS_PATH, SHELL_PATH, SITES_PATH, SITE_TO_SITE_PATH, USERS_PATH, USER_PATH } from './PageMenu.tsx'
|
||||||
|
import NetworkPanel from './NetworkPanel.tsx'
|
||||||
|
import RemoteAccessPanel from './RemoteAccessPanel.tsx'
|
||||||
|
import RouterPanel from './RouterPanel.tsx'
|
||||||
|
import ServiceAccountsPanel from './ServiceAccountsPanel.tsx'
|
||||||
|
import SettingsButton from './SettingsButton.tsx'
|
||||||
|
import ShellPanel from './ShellPanel.tsx'
|
||||||
|
import SignIn from './SignIn.tsx'
|
||||||
|
import SystemPanel from './SystemPanel.tsx'
|
||||||
|
import SiteFiles from './SiteFiles.tsx'
|
||||||
|
import SitesPanel from './SitesPanel.tsx'
|
||||||
|
import SiteToSitePanel from './SiteToSitePanel.tsx'
|
||||||
|
import SiteToSiteStatusPanel from './SiteToSiteStatusPanel.tsx'
|
||||||
|
import ThemeMenu from './ThemeMenu.tsx'
|
||||||
|
import UsersPanel from './UsersPanel.tsx'
|
||||||
|
import {
|
||||||
|
ACCOUNT_SHAPE, SESSION_EXPIRED, SESSION_SHAPE, request, type Account, type Info, type RemoteAccessSystem, type Session,
|
||||||
|
} from './api.ts'
|
||||||
|
|
||||||
|
/** The tabs of the Router page, router being the Overview tab, of the Identities page, of the Connectivity page and of the Web Sites page. */
|
||||||
|
type View = 'router' | 'metrics' | 'logs' | 'backup' | 'system'
|
||||||
|
type UsersView = 'users' | 'groups' | 'service-accounts'
|
||||||
|
type SiteToSiteView = 'status' | 'network' | 'connections'
|
||||||
|
type SitesView = 'sites' | 'files' | 'dns'
|
||||||
|
|
||||||
|
/** The help topic of each tab of the Web Sites page. */
|
||||||
|
const SITES_TOPICS: Record<SitesView, string> = { sites: 'sites.md', files: 'site-files.md', dns: 'dns.md' }
|
||||||
|
|
||||||
|
/** The prefix of the sessionStorage keys of the pages the account and the shell page were opened from, by their paths. */
|
||||||
|
const RETURN_KEY = 'vpn-router-manager.return:'
|
||||||
|
|
||||||
|
/** The pages of an administrator besides Router, which every other path shows. */
|
||||||
|
const PAGE_PATHS = [USER_PATH, USERS_PATH, SITE_TO_SITE_PATH, REMOTE_ACCESS_PATH, CA_PATH, SITES_PATH, DOCKER_PATH, SHELL_PATH]
|
||||||
|
|
||||||
|
/** The help topic of each page, a document of /docs; the Router, the Identities and the Web Sites page have one per tab. */
|
||||||
|
const HELP_TOPICS: Record<string, string> = {
|
||||||
|
[USER_PATH]: 'account.md',
|
||||||
|
[SITE_TO_SITE_PATH]: 'site-to-site.md',
|
||||||
|
[REMOTE_ACCESS_PATH]: 'remote-access.md',
|
||||||
|
[CA_PATH]: 'certificates.md',
|
||||||
|
[DOCKER_PATH]: 'docker.md',
|
||||||
|
[SHELL_PATH]: 'shell.md',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The localStorage key of whether the help is open. */
|
||||||
|
const HELP_KEY = 'vpn-router-manager.help'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page asks for a sign-in until a session exists. At USER_PATH, and for a user who is not a member of admins at
|
||||||
|
* every path, it then shows the user's own account; at USERS_PATH the router's users, groups or service accounts; at
|
||||||
|
* SITE_TO_SITE_PATH its site-to-site connections, their state and its network with its routes; at REMOTE_ACCESS_PATH its IKEv2, OpenVPN and
|
||||||
|
* WireGuard access; at CA_PATH its certificates; at SITES_PATH its web
|
||||||
|
* sites, the files of a site, opened from the sites too, and its name server; at DOCKER_PATH the state of Docker; at SHELL_PATH a shell on the router; otherwise the state of the router, with the update information of its packages in
|
||||||
|
* the Status panel, its metrics, its logs, its backups or the updates of its system's packages. A page shows one tab at a time, each read only while shown. An
|
||||||
|
* administrator switches pages with the page menu at the right end of the header; the button with the user's name,
|
||||||
|
* left of Sign out, opens the user's own account, and an administrator's shell button, left of it, the shell; pressed
|
||||||
|
* on their page, each returns to the page it was opened from. While files are open in the shell's editor, the editor
|
||||||
|
* button left of the shell button switches the Shell page between the terminal and the editor. The help button, between the theme switcher and the page menu, opens or closes the help on
|
||||||
|
* the right (HelpPanel) at the topic of the page and tab shown (HELP_TOPICS), the page moving left; the browser keeps
|
||||||
|
* whether it is open. A call answered 401 ends the session and asks for a sign-in again.
|
||||||
|
*/
|
||||||
|
export default function App(props: { info: Info }) {
|
||||||
|
const [view, setView] = useState<View>('router')
|
||||||
|
const [usersView, setUsersView] = useState<UsersView>('users')
|
||||||
|
const [siteToSiteView, setSiteToSiteView] = useState<SiteToSiteView>('status')
|
||||||
|
const [remoteAccessView, setRemoteAccessView] = useState<RemoteAccessSystem>('p2s')
|
||||||
|
const [sitesView, setSitesView] = useState<SitesView>('sites')
|
||||||
|
const [filesSite, setFilesSite] = useState<string | null>(null)
|
||||||
|
const [session, setSession] = useState<Session | null | undefined>(undefined)
|
||||||
|
const [role, setRole] = useState<{ name: string; admin: boolean } | null>(null)
|
||||||
|
const page = PAGE_PATHS.find((path) => path === window.location.pathname) ?? '/'
|
||||||
|
const userPage = page === USER_PATH
|
||||||
|
const admin = session && role?.name === session.name ? role.admin : undefined
|
||||||
|
// The account page is the page of every user who is not an administrator, and of an administrator at USER_PATH.
|
||||||
|
const accountShown = Boolean(session) && admin !== undefined && (userPage || !admin)
|
||||||
|
const [helpOpen, setHelpOpen] = useState(() => window.localStorage.getItem(HELP_KEY) === 'open')
|
||||||
|
// The files open in the Shell page's editor, and whether the editor is shown in place of the terminal.
|
||||||
|
const [editorFiles, setEditorFiles] = useState(0)
|
||||||
|
const [editorShown, setEditorShown] = useState(false)
|
||||||
|
const showFiles = useCallback((count: number) => {
|
||||||
|
setEditorFiles(count)
|
||||||
|
if (count === 0) {
|
||||||
|
setEditorShown(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
const showEditor = useCallback(() => setEditorShown(true), [])
|
||||||
|
const helpShown = Boolean(session) && helpOpen
|
||||||
|
const topic = !admin ? 'account.md'
|
||||||
|
: page === '/' ? `${view === 'router' ? 'router' : view}.md`
|
||||||
|
: page === USERS_PATH ? `${usersView}.md`
|
||||||
|
: page === SITES_PATH ? SITES_TOPICS[sitesView]
|
||||||
|
: HELP_TOPICS[page] ?? HELP_HOME
|
||||||
|
|
||||||
|
/** Opens or closes the help, kept in localStorage. */
|
||||||
|
function toggleHelp() {
|
||||||
|
window.localStorage.setItem(HELP_KEY, helpOpen ? 'closed' : 'open')
|
||||||
|
setHelpOpen(!helpOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<Session>('GET', '/session', SESSION_SHAPE).then(setSession).catch(() => setSession(null))
|
||||||
|
const expired = () => setSession(null)
|
||||||
|
window.addEventListener(SESSION_EXPIRED, expired)
|
||||||
|
return () => window.removeEventListener(SESSION_EXPIRED, expired)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request<Account>('GET', '/account', ACCOUNT_SHAPE)
|
||||||
|
.then((account) => setRole({ name: session.name, admin: account.admin }))
|
||||||
|
.catch(() => setRole({ name: session.name, admin: false }))
|
||||||
|
}, [session])
|
||||||
|
|
||||||
|
/** Opens the page of the path, keeping the page it was opened from in sessionStorage, or returns to that page from it. */
|
||||||
|
function toggle(path: string) {
|
||||||
|
if (page === path) {
|
||||||
|
window.location.assign(window.sessionStorage.getItem(RETURN_KEY + path) ?? '/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.sessionStorage.setItem(RETURN_KEY + path, window.location.pathname)
|
||||||
|
window.location.assign(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
await request('DELETE', '/session', () => true).catch(() => undefined)
|
||||||
|
setSession(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={(theme) => ({
|
||||||
|
mr: { sm: helpShown ? `${HELP_WIDTH}px` : 0 },
|
||||||
|
transition: theme.transitions.create('margin', { duration: theme.transitions.duration.enteringScreen }),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{session && <HelpPanel open={helpShown} topic={topic} onClose={toggleHelp} />}
|
||||||
|
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||||
|
<Stack direction="row" sx={{ mb: 2, alignItems: 'center', justifyContent: 'space-between', gap: 2 }}>
|
||||||
|
<Stack direction="row" sx={{ alignItems: 'center', gap: 2 }}>
|
||||||
|
<Box component="img" src="/favicon.svg" alt="" sx={{ width: 56, height: 56 }} />
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" sx={{ alignItems: 'baseline', gap: 1 }}>
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
VPN Router Manager
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
|
||||||
|
{props.info.version ?? 'development'}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
Router status page:{' '}
|
||||||
|
{props.info.status_url
|
||||||
|
? <Link href={props.info.status_url} target="_blank" rel="noreferrer">{props.info.status_url}</Link>
|
||||||
|
: <b>not configured</b>}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" sx={{ alignItems: 'center', gap: 2 }}>
|
||||||
|
{session && admin && page === SHELL_PATH && editorFiles > 0 && (
|
||||||
|
<Tooltip title={editorShown ? 'Back to the terminal' : 'Editor'}>
|
||||||
|
<IconButton
|
||||||
|
aria-label="editor"
|
||||||
|
color={editorShown ? 'primary' : 'default'}
|
||||||
|
onClick={() => setEditorShown(!editorShown)}
|
||||||
|
sx={editorShown ? { bgcolor: 'action.selected' } : undefined}
|
||||||
|
>
|
||||||
|
<EditNote />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{session && admin && (
|
||||||
|
<Tooltip title={page === SHELL_PATH ? 'Back from the shell' : 'Shell'}>
|
||||||
|
<IconButton
|
||||||
|
aria-label="shell"
|
||||||
|
color={page === SHELL_PATH ? 'primary' : 'default'}
|
||||||
|
onClick={() => toggle(SHELL_PATH)}
|
||||||
|
sx={page === SHELL_PATH ? { bgcolor: 'action.selected' } : undefined}
|
||||||
|
>
|
||||||
|
<Terminal />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{session && (
|
||||||
|
<>
|
||||||
|
<Button variant={userPage ? 'contained' : 'outlined'} startIcon={<AccountCircle />} onClick={() => toggle(USER_PATH)} sx={{ textTransform: 'none' }}>
|
||||||
|
{session.name}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" startIcon={<Logout />} onClick={() => void signOut()}>Sign out</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{session && admin !== undefined && !accountShown && <SettingsButton />}
|
||||||
|
<ThemeMenu />
|
||||||
|
{session && (
|
||||||
|
<Tooltip title={helpShown ? 'Close the help' : 'Help'}>
|
||||||
|
<IconButton
|
||||||
|
aria-label="help"
|
||||||
|
color={helpShown ? 'primary' : 'default'}
|
||||||
|
onClick={toggleHelp}
|
||||||
|
sx={helpShown ? { bgcolor: 'action.selected' } : undefined}
|
||||||
|
>
|
||||||
|
<HelpOutline />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{session && admin && <PageMenu path={page} />}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
{session === undefined && <CircularProgress />}
|
||||||
|
{session === null && <SignIn onSignedIn={setSession} />}
|
||||||
|
{session && admin === undefined && <CircularProgress />}
|
||||||
|
{accountShown && <AccountPanel onPasswordChanged={() => void signOut()} />}
|
||||||
|
{session && admin && page === CA_PATH && (
|
||||||
|
<>
|
||||||
|
<Typography variant="h6" component="h2" sx={{ mb: 2 }}>Certificates</Typography>
|
||||||
|
<CertificatesPanel />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{session && admin && page === SITES_PATH && (
|
||||||
|
<>
|
||||||
|
<Tabs value={sitesView} onChange={(_event, next: SitesView) => setSitesView(next)} sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<Tab value="sites" label="Sites" />
|
||||||
|
<Tab value="files" label="Files" />
|
||||||
|
<Tab value="dns" label="DNS" />
|
||||||
|
</Tabs>
|
||||||
|
{sitesView === 'sites' && (
|
||||||
|
<SitesPanel
|
||||||
|
onOpenFiles={(name) => {
|
||||||
|
setFilesSite(name)
|
||||||
|
setSitesView('files')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{sitesView === 'files' && <SiteFiles key={filesSite ?? ''} site={filesSite} onSite={setFilesSite} />}
|
||||||
|
{sitesView === 'dns' && <DnsPanel />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{session && admin && page === DOCKER_PATH && (
|
||||||
|
<>
|
||||||
|
<Typography variant="h6" component="h2" sx={{ mb: 2 }}>Docker</Typography>
|
||||||
|
<DockerPanel />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{session && admin && page === SHELL_PATH && (
|
||||||
|
<>
|
||||||
|
<Typography variant="h6" component="h2" sx={{ mb: 2 }}>Shell</Typography>
|
||||||
|
<ShellPanel editorShown={editorShown} onFiles={showFiles} onOpened={showEditor} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{session && admin && page === REMOTE_ACCESS_PATH && (
|
||||||
|
<>
|
||||||
|
<Tabs value={remoteAccessView} onChange={(_event, next: RemoteAccessSystem) => setRemoteAccessView(next)} sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<Tab value="p2s" label="IKEv2" />
|
||||||
|
<Tab value="openvpn" label="OpenVPN" />
|
||||||
|
<Tab value="wireguard" label="WireGuard" />
|
||||||
|
</Tabs>
|
||||||
|
<RemoteAccessPanel key={remoteAccessView} system={remoteAccessView} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{session && admin && page === SITE_TO_SITE_PATH && (
|
||||||
|
<>
|
||||||
|
<Tabs value={siteToSiteView} onChange={(_event, next: SiteToSiteView) => setSiteToSiteView(next)} sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<Tab value="status" label="Status" />
|
||||||
|
<Tab value="network" label="Network" />
|
||||||
|
<Tab value="connections" label="Connections" />
|
||||||
|
</Tabs>
|
||||||
|
{siteToSiteView === 'connections' && <SiteToSitePanel />}
|
||||||
|
{siteToSiteView === 'status' && <SiteToSiteStatusPanel />}
|
||||||
|
{siteToSiteView === 'network' && <NetworkPanel />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{session && admin && page === USERS_PATH && (
|
||||||
|
<>
|
||||||
|
<Tabs value={usersView} onChange={(_event, next: UsersView) => setUsersView(next)} sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<Tab value="users" label="Users" />
|
||||||
|
<Tab value="groups" label="Groups" />
|
||||||
|
<Tab value="service-accounts" label="Service Accounts" />
|
||||||
|
</Tabs>
|
||||||
|
{usersView === 'users' && <UsersPanel />}
|
||||||
|
{usersView === 'groups' && <GroupsPanel />}
|
||||||
|
{usersView === 'service-accounts' && <ServiceAccountsPanel />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{session && admin && page === '/' && (
|
||||||
|
<>
|
||||||
|
<Tabs value={view} onChange={(_event, next: View) => setView(next)} sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<Tab value="router" label="Overview" />
|
||||||
|
<Tab value="metrics" label="Metrics" />
|
||||||
|
<Tab value="logs" label="Logs" />
|
||||||
|
<Tab value="backup" label="Backup" />
|
||||||
|
<Tab value="system" label="System" />
|
||||||
|
</Tabs>
|
||||||
|
{view === 'router' && <RouterPanel />}
|
||||||
|
{view === 'metrics' && <MetricsPanel />}
|
||||||
|
{view === 'logs' && <LogsPanel />}
|
||||||
|
{view === 'backup' && <BackupPanel />}
|
||||||
|
{view === 'system' && <SystemPanel />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import {
|
||||||
|
Alert, Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, MenuItem, Stack, Switch,
|
||||||
|
TextField, Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { TARGET_LABELS } from './BackupSummary.tsx'
|
||||||
|
import { BACKUP_KEEPS, BACKUP_SCHEDULES, BACKUP_TARGETS, RCLONE_MODES, type Backup, type BackupKeep, type BackupSettings } from './api.ts'
|
||||||
|
|
||||||
|
/** The labels of the retention settings. */
|
||||||
|
const KEEP_LABELS: Record<BackupKeep, string> = {
|
||||||
|
keep_hourly: 'Hourly',
|
||||||
|
keep_daily: 'Daily',
|
||||||
|
keep_weekly: 'Weekly',
|
||||||
|
keep_monthly: 'Monthly',
|
||||||
|
keep_yearly: 'Yearly',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The labels of the rclone modes. */
|
||||||
|
const MODE_LABELS: Record<string, string> = {
|
||||||
|
sync: 'Sync: the cloud mirrors the archives, retention applies to both',
|
||||||
|
copy: 'Copy: nothing is deleted in the cloud',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A PBS certificate fingerprint: 32 hexadecimal pairs joined by colons. */
|
||||||
|
const FINGERPRINT = /^([0-9A-Fa-f]{2}:){31}[0-9A-Fa-f]{2}$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog that edits the router's backup settings: whether backups run, the schedule and the target; for a local
|
||||||
|
* folder, its path, the retention settings that apply to the schedule and the cloud copy with a remote of the router's
|
||||||
|
* rclone.conf; for a Proxmox Backup Server, the repository, the token secret, the fingerprint, the namespace and the
|
||||||
|
* key file; and whether the metrics database is included. An empty token secret keeps the current one. onSave sends
|
||||||
|
* the settings and the secret; the dialog closes when it succeeds and shows the router's message when it fails.
|
||||||
|
*/
|
||||||
|
export default function BackupDialog(props: {
|
||||||
|
backup: Backup
|
||||||
|
onSave: (settings: BackupSettings, password: string) => Promise<void>
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const current = props.backup.settings
|
||||||
|
const [settings, setSettings] = useState<BackupSettings>(() => {
|
||||||
|
const { pbs_password_set: _set, ...rest } = current
|
||||||
|
return rest
|
||||||
|
})
|
||||||
|
const [remote, setRemote] = useState(current.rclone_remote.split(':')[0] ?? '')
|
||||||
|
const [remotePath, setRemotePath] = useState(current.rclone_remote.includes(':') ? current.rclone_remote.slice(current.rclone_remote.indexOf(':') + 1) : '')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const change = (values: Partial<BackupSettings>) => setSettings({ ...settings, ...values })
|
||||||
|
const local = settings.target === 'local'
|
||||||
|
const keeps = BACKUP_KEEPS.slice(BACKUP_SCHEDULES.indexOf(settings.schedule as (typeof BACKUP_SCHEDULES)[number]))
|
||||||
|
const remotes = [...new Set([...props.backup.rclone.remotes, ...(remote ? [remote] : [])])]
|
||||||
|
|
||||||
|
const pathProblem = local && !settings.path.startsWith('/') ? 'An absolute path.' : ''
|
||||||
|
const keepProblem = local && !keeps.some((keep) => settings[keep] > 0) ? 'At least one of them above 0.' : ''
|
||||||
|
const repositoryProblem = !local && settings.pbs_repository.trim() === '' ? 'Required.' : ''
|
||||||
|
const passwordProblem = !local && !current.pbs_password_set && password === '' ? 'Required.' : ''
|
||||||
|
const fingerprintProblem = settings.pbs_fingerprint !== '' && !FINGERPRINT.test(settings.pbs_fingerprint) ? '32 hexadecimal pairs joined by colons.' : ''
|
||||||
|
const keyfileProblem = settings.pbs_keyfile !== '' && !settings.pbs_keyfile.startsWith('/') ? 'An absolute path.' : ''
|
||||||
|
const blocked = [pathProblem, keepProblem, repositoryProblem, passwordProblem, fingerprintProblem, keyfileProblem].some((problem) => problem !== '')
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (blocked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await props.onSave({ ...settings, rclone_remote: remote ? `${remote}:${remotePath.trim()}` : '' }, password)
|
||||||
|
} catch (caught) {
|
||||||
|
setError((caught as Error).message)
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
|
||||||
|
<DialogTitle>Edit backup settings</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, pt: 1 }}>
|
||||||
|
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={settings.enabled} onChange={(event) => change({ enabled: event.target.checked })} />}
|
||||||
|
label="Scheduled backups"
|
||||||
|
/>
|
||||||
|
<Stack direction="row" sx={{ gap: 2 }}>
|
||||||
|
<TextField select fullWidth label="Schedule" value={settings.schedule} onChange={(event) => change({ schedule: event.target.value })}>
|
||||||
|
{BACKUP_SCHEDULES.map((item) => <MenuItem key={item} value={item}>{item}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
<TextField select fullWidth label="Target" value={settings.target} onChange={(event) => change({ target: event.target.value })}>
|
||||||
|
{BACKUP_TARGETS.map((item) => <MenuItem key={item} value={item}>{TARGET_LABELS[item]}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
</Stack>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox checked={settings.metrics} onChange={(event) => change({ metrics: event.target.checked })} />}
|
||||||
|
label="Include the metrics database"
|
||||||
|
/>
|
||||||
|
{local ? (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
label="Folder"
|
||||||
|
value={settings.path}
|
||||||
|
autoComplete="off"
|
||||||
|
error={pathProblem !== ''}
|
||||||
|
helperText={pathProblem || 'The folder of the archives on the router; it may be a mount.'}
|
||||||
|
onChange={(event) => change({ path: event.target.value })}
|
||||||
|
/>
|
||||||
|
<Typography variant="subtitle2">Archives kept</Typography>
|
||||||
|
<Stack direction="row" sx={{ gap: 2 }}>
|
||||||
|
{keeps.map((keep) => (
|
||||||
|
<TextField
|
||||||
|
key={keep}
|
||||||
|
label={KEEP_LABELS[keep]}
|
||||||
|
type="number"
|
||||||
|
value={settings[keep]}
|
||||||
|
error={keepProblem !== ''}
|
||||||
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
|
onChange={(event) => change({ [keep]: Math.max(0, Math.trunc(Number(event.target.value) || 0)) })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
{keepProblem && <Typography variant="caption" color="error">{keepProblem}</Typography>}
|
||||||
|
<Typography variant="subtitle2">Cloud copy</Typography>
|
||||||
|
<Stack direction="row" sx={{ gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
fullWidth
|
||||||
|
label="rclone remote"
|
||||||
|
value={remote}
|
||||||
|
helperText={props.backup.rclone.installed ? (remotes.length === 0 ? 'No remotes in rclone.conf.' : '') : 'rclone is not installed.'}
|
||||||
|
onChange={(event) => setRemote(event.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">None</MenuItem>
|
||||||
|
{remotes.map((item) => <MenuItem key={item} value={item}>{item}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Remote path"
|
||||||
|
value={remotePath}
|
||||||
|
disabled={remote === ''}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(event) => setRemotePath(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<TextField select label="Mode" value={settings.rclone_mode} disabled={remote === ''} onChange={(event) => change({ rclone_mode: event.target.value })}>
|
||||||
|
{RCLONE_MODES.map((item) => <MenuItem key={item} value={item}>{MODE_LABELS[item]}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
label="Repository"
|
||||||
|
value={settings.pbs_repository}
|
||||||
|
autoComplete="off"
|
||||||
|
error={repositoryProblem !== ''}
|
||||||
|
helperText={repositoryProblem || 'user@realm!token@host:datastore'}
|
||||||
|
onChange={(event) => change({ pbs_repository: event.target.value.trim() })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Token secret"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
autoComplete="new-password"
|
||||||
|
error={passwordProblem !== ''}
|
||||||
|
helperText={passwordProblem || (current.pbs_password_set ? 'Leave empty to keep the current secret.' : '')}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Fingerprint"
|
||||||
|
value={settings.pbs_fingerprint}
|
||||||
|
autoComplete="off"
|
||||||
|
error={fingerprintProblem !== ''}
|
||||||
|
helperText={fingerprintProblem || 'Of the server certificate, when the system CAs do not verify it.'}
|
||||||
|
onChange={(event) => change({ pbs_fingerprint: event.target.value.trim() })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Namespace"
|
||||||
|
value={settings.pbs_namespace}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(event) => change({ pbs_namespace: event.target.value.trim() })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Encryption key file"
|
||||||
|
value={settings.pbs_keyfile}
|
||||||
|
autoComplete="off"
|
||||||
|
error={keyfileProblem !== ''}
|
||||||
|
helperText={keyfileProblem || 'A key of proxmox-backup-client key create on the router; empty for none.'}
|
||||||
|
onChange={(event) => change({ pbs_keyfile: event.target.value.trim() })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={props.onCancel}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={saving || blocked}>Save</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import Edit from '@mui/icons-material/Edit'
|
||||||
|
import PlayArrow from '@mui/icons-material/PlayArrow'
|
||||||
|
import Refresh from '@mui/icons-material/Refresh'
|
||||||
|
import { Alert, Box, Button, Table, TableBody, TableCell, TableHead, TablePagination, TableRow } from '@mui/material'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import BackupDialog from './BackupDialog.tsx'
|
||||||
|
import { BackupChip, TARGET_LABELS, backupRows, when } from './BackupSummary.tsx'
|
||||||
|
import { Pairs, Section, bytes } from './RouterPanel.tsx'
|
||||||
|
import SearchField, { matches } from './SearchField.tsx'
|
||||||
|
import Toolbar from './Toolbar.tsx'
|
||||||
|
import { ApiError, BACKUP_KEEPS, BACKUP_SCHEDULES, BACKUP_SETTINGS_SHAPE, BACKUP_SHAPE, STARTED_SHAPE, request, type Backup } from './api.ts'
|
||||||
|
|
||||||
|
/** The note shown after a change, which the router applies when vpn-router-setup has run again. */
|
||||||
|
const APPLIED = 'The router applies the change in the background.'
|
||||||
|
|
||||||
|
/** Seconds between reads while a backup runs or is starting. */
|
||||||
|
const WATCH_SECONDS = 3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The router's backups: the settings, the last run and the local archives, searched by name and time and shown a page at
|
||||||
|
* a time; edit the settings and start a backup now.
|
||||||
|
* While a backup runs, or after Run now until it has ended, the tab reads every WATCH_SECONDS.
|
||||||
|
*/
|
||||||
|
export default function BackupPanel() {
|
||||||
|
const [backup, setBackup] = useState<Backup | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [unavailable, setUnavailable] = useState(false)
|
||||||
|
const [note, setNote] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [watching, setWatching] = useState(false)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [rows, setRows] = useState(10)
|
||||||
|
// The start of the last run when Run now was pressed; watching ends once a later run has ended.
|
||||||
|
const startedBefore = useRef<string | null>(null)
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const found = await request<Backup>('GET', '/backup', BACKUP_SHAPE)
|
||||||
|
setBackup(found)
|
||||||
|
setError(null)
|
||||||
|
setUnavailable(false)
|
||||||
|
if (found.state && found.state.result !== 'running' && found.state.started !== startedBefore.current) {
|
||||||
|
setWatching(false)
|
||||||
|
}
|
||||||
|
} catch (caught) {
|
||||||
|
const failure = caught as ApiError
|
||||||
|
setBackup(null)
|
||||||
|
setUnavailable(failure.status === 404)
|
||||||
|
setError(failure.message)
|
||||||
|
setWatching(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh()
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
const running = backup?.state?.result === 'running'
|
||||||
|
useEffect(() => {
|
||||||
|
if (!watching && !running) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const timer = window.setInterval(() => void refresh(), WATCH_SECONDS * 1000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [refresh, watching, running])
|
||||||
|
|
||||||
|
async function runNow() {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
startedBefore.current = backup?.state?.started ?? null
|
||||||
|
await request('POST', '/backup/run', STARTED_SHAPE)
|
||||||
|
setWatching(true)
|
||||||
|
setError(null)
|
||||||
|
} catch (caught) {
|
||||||
|
setError((caught as Error).message)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const archives = (backup?.archives ?? []).filter((archive) => matches(search, [archive.name, when(archive.time)]))
|
||||||
|
// The page shown stays within the archives found, which a refresh may make fewer.
|
||||||
|
const shownPage = Math.min(page, Math.max(0, Math.ceil(archives.length / rows) - 1))
|
||||||
|
const settings = backup?.settings
|
||||||
|
const local = settings?.target === 'local'
|
||||||
|
const keeps = settings ? BACKUP_KEEPS.slice(BACKUP_SCHEDULES.indexOf(settings.schedule as (typeof BACKUP_SCHEDULES)[number])) : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box component="section">
|
||||||
|
<Toolbar description="Backups of the router's configuration, keys, users and metrics.">
|
||||||
|
<Button variant="contained" startIcon={<Edit />} disabled={busy || backup === null} onClick={() => setEditing(true)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" startIcon={<PlayArrow />} disabled={busy || backup === null || running || watching} onClick={() => void runNow()}>
|
||||||
|
Run now
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" startIcon={<Refresh />} disabled={busy} onClick={() => void refresh()}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
{error && <Alert severity={unavailable ? 'info' : 'error'} sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
{note && <Alert severity="info" sx={{ mb: 2 }} onClose={() => setNote(null)}>{note}</Alert>}
|
||||||
|
{backup && settings && !local && !backup.client && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||||
|
proxmox-backup-client is not installed on the router; install proxmox-backup-client-static from Proxmox's client repository.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{backup && settings && local && settings.rclone_remote && !(backup.rclone.installed && backup.rclone.config) && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||||
|
{backup.rclone.installed ? 'The router has no /etc/vpn-router/rclone.conf.' : 'rclone is not installed on the router.'} The cloud copy fails until it has.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{backup && settings && (
|
||||||
|
<Box sx={{ display: 'grid', gap: 2, gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' } }}>
|
||||||
|
<Section title="Settings">
|
||||||
|
<Pairs
|
||||||
|
rows={[
|
||||||
|
['Scheduled backups', settings.enabled ? 'on' : 'off'],
|
||||||
|
['Schedule', settings.schedule],
|
||||||
|
['Target', TARGET_LABELS[settings.target] ?? settings.target],
|
||||||
|
['Metrics database', settings.metrics ? 'included' : 'not included'],
|
||||||
|
...(local
|
||||||
|
? [
|
||||||
|
['Folder', settings.path],
|
||||||
|
['Kept', keeps.map((keep) => `${settings[keep]} ${keep.slice(5)}`).join(', ')],
|
||||||
|
['Cloud copy', settings.rclone_remote ? `${settings.rclone_mode} to ${settings.rclone_remote}` : 'none'],
|
||||||
|
] satisfies [string, string][]
|
||||||
|
: [
|
||||||
|
['Repository', settings.pbs_repository || '-'],
|
||||||
|
['Token secret', settings.pbs_password_set ? 'set' : 'missing'],
|
||||||
|
['Fingerprint', settings.pbs_fingerprint || '-'],
|
||||||
|
['Namespace', settings.pbs_namespace || '-'],
|
||||||
|
['Encryption key file', settings.pbs_keyfile || 'none'],
|
||||||
|
] satisfies [string, string][]),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section title="Last run">
|
||||||
|
<Box sx={{ mb: 1 }}><BackupChip state={backup.state} /></Box>
|
||||||
|
<Pairs rows={backupRows(backup)} />
|
||||||
|
</Section>
|
||||||
|
{local && (
|
||||||
|
<Section title="Local archives" wide>
|
||||||
|
<Box sx={{ mb: 1 }}>
|
||||||
|
<SearchField value={search} onChange={(value) => { setSearch(value); setPage(0) }} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Archive</TableCell>
|
||||||
|
<TableCell>Time</TableCell>
|
||||||
|
<TableCell align="right">Size</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{archives.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} sx={{ color: 'text.secondary' }}>None.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{archives.slice(shownPage * rows, (shownPage + 1) * rows).map((archive) => (
|
||||||
|
<TableRow key={archive.name} hover>
|
||||||
|
<TableCell sx={{ fontFamily: 'monospace' }}>{archive.name}</TableCell>
|
||||||
|
<TableCell>{when(archive.time)}</TableCell>
|
||||||
|
<TableCell align="right">{bytes(archive.size)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={archives.length}
|
||||||
|
page={shownPage}
|
||||||
|
rowsPerPage={rows}
|
||||||
|
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||||
|
onPageChange={(_event, next) => setPage(next)}
|
||||||
|
onRowsPerPageChange={(event) => { setRows(Number(event.target.value)); setPage(0) }}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && backup && (
|
||||||
|
<BackupDialog
|
||||||
|
backup={backup}
|
||||||
|
onCancel={() => setEditing(false)}
|
||||||
|
onSave={async (next, password) => {
|
||||||
|
await request('PUT', '/backup', BACKUP_SETTINGS_SHAPE, password === '' ? next : { ...next, pbs_password: password })
|
||||||
|
setEditing(false)
|
||||||
|
setNote(APPLIED)
|
||||||
|
await refresh()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Chip } from '@mui/material'
|
||||||
|
import { bytes } from './RouterPanel.tsx'
|
||||||
|
import type { Backup, BackupState } from './api.ts'
|
||||||
|
|
||||||
|
/** The colour of each result of a backup run. */
|
||||||
|
const RESULT_COLORS: Record<string, 'success' | 'warning' | 'error' | 'info'> = {
|
||||||
|
ok: 'success',
|
||||||
|
warning: 'warning',
|
||||||
|
failed: 'error',
|
||||||
|
interrupted: 'error',
|
||||||
|
running: 'info',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The labels of the backup targets. */
|
||||||
|
export const TARGET_LABELS: Record<string, string> = { local: 'Local folder', pbs: 'Proxmox Backup Server' }
|
||||||
|
|
||||||
|
/** A time of the router in the browser's format, or a dash. */
|
||||||
|
export function when(time: string | null | undefined) {
|
||||||
|
return time ? new Date(time).toLocaleString() : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The result of the last backup run as a chip, or "never" before the first. */
|
||||||
|
export function BackupChip(props: { state: BackupState | null }) {
|
||||||
|
return props.state
|
||||||
|
? <Chip size="small" label={props.state.result} color={RESULT_COLORS[props.state.result] ?? 'default'} />
|
||||||
|
: <Chip size="small" label="never" variant="outlined" />
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The schedule, the next run (as systemd shows it) and the last run of the router's backups, as rows of Pairs. */
|
||||||
|
export function backupRows(backup: Backup): [string, string][] {
|
||||||
|
const { settings, state } = backup
|
||||||
|
const rows: [string, string][] = [
|
||||||
|
['Backups', settings.enabled ? `${settings.schedule}, to ${TARGET_LABELS[settings.target] ?? settings.target}` : 'off'],
|
||||||
|
['Next run', backup.next ?? '-'],
|
||||||
|
]
|
||||||
|
if (!state) {
|
||||||
|
return [...rows, ['Last run', 'none']]
|
||||||
|
}
|
||||||
|
rows.push(
|
||||||
|
['Last run', `${when(state.started)}${state.finished ? ` to ${new Date(state.finished).toLocaleTimeString()}` : ''}`],
|
||||||
|
['Last result', state.result],
|
||||||
|
)
|
||||||
|
if (state.archive ?? state.snapshot) {
|
||||||
|
rows.push(['Last backup', `${state.archive ?? state.snapshot}${state.size === null ? '' : `, ${bytes(state.size)}`}`])
|
||||||
|
}
|
||||||
|
if (state.cloud) {
|
||||||
|
rows.push(['Cloud copy', `${state.cloud.mode} to ${state.cloud.remote}: ${state.cloud.error ?? state.cloud.result}`])
|
||||||
|
}
|
||||||
|
if (state.error) {
|
||||||
|
rows.push(['Error', state.error])
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Box, Paper, Typography } from '@mui/material'
|
||||||
|
|
||||||
|
/** A centered panel with a title and a message, for states before the application can render. */
|
||||||
|
export default function Card(props: { title: string; message: string }) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', p: 3 }}>
|
||||||
|
<Paper sx={{ maxWidth: 480, p: 3 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||||
|
{props.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'pre-line' }}>
|
||||||
|
{props.message}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import UploadFile from '@mui/icons-material/UploadFile'
|
||||||
|
import {
|
||||||
|
Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, MenuItem, Radio, RadioGroup, Stack, TextField,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { ROUTER_TEMPLATES, type Pki } from './api.ts'
|
||||||
|
|
||||||
|
/** A request of POST /ca/certificates: a CSR to sign, or a subject and names for a key the router makes into a PFX file. */
|
||||||
|
export type IssueRequest =
|
||||||
|
| { ca: string; template: string; csr: string }
|
||||||
|
| { ca: string; template: string; subject: string; sans: string[]; password: string }
|
||||||
|
|
||||||
|
/** The labels of the kinds of common name of a template. */
|
||||||
|
const SUBJECT_LABELS: Record<string, string> = { dns: 'a DNS name', name: 'a name', email: 'an e-mail address' }
|
||||||
|
|
||||||
|
/** A list typed as text separated by commas. */
|
||||||
|
function listOf(text: string) {
|
||||||
|
return text.split(',').map((item) => item.trim()).filter((item) => item !== '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog that issues a certificate from an active issuing CA with one of the templates it issues but those only the
|
||||||
|
* router issues: from a CSR, pasted or read from a file, or with a key the router makes for a subject and subject
|
||||||
|
* alternative names, returned once in a PFX file protected by a password. onSave sends the request and saves what the
|
||||||
|
* router answers; the dialog closes when it succeeds and shows the router's message when it fails.
|
||||||
|
*/
|
||||||
|
export default function CertificateDialog(props: { pki: Pki; onSave: (request: IssueRequest) => Promise<void>; onCancel: () => void }) {
|
||||||
|
const authorities = props.pki.status.authorities
|
||||||
|
.map((authority) => ({ ...authority, templates: authority.templates.filter((name) => !(ROUTER_TEMPLATES as readonly string[]).includes(name)) }))
|
||||||
|
.filter((authority) => authority.state === 'active' && authority.templates.length > 0)
|
||||||
|
const [ca, setCa] = useState(authorities[0]?.name ?? '')
|
||||||
|
const [template, setTemplate] = useState(authorities[0]?.templates[0] ?? '')
|
||||||
|
const [mode, setMode] = useState<'csr' | 'key'>('csr')
|
||||||
|
const [csr, setCsr] = useState('')
|
||||||
|
const [subject, setSubject] = useState('')
|
||||||
|
const [sans, setSans] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [repeated, setRepeated] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const templates = authorities.find((authority) => authority.name === ca)?.templates ?? []
|
||||||
|
const values = props.pki.templates[template]
|
||||||
|
const csrProblem = mode === 'csr' && csr.trim() !== '' && !csr.includes('-----BEGIN CERTIFICATE REQUEST-----') ? 'A PEM certificate signing request.' : ''
|
||||||
|
const repeatProblem = mode === 'key' && repeated !== '' && repeated !== password ? 'The passwords differ.' : ''
|
||||||
|
const blocked = ca === '' || template === '' || (mode === 'csr'
|
||||||
|
? csr.trim() === '' || csrProblem !== ''
|
||||||
|
: subject.trim() === '' || password === '' || password !== repeated)
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (blocked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await props.onSave(mode === 'csr'
|
||||||
|
? { ca, template, csr: csr.trim() }
|
||||||
|
: { ca, template, subject: subject.trim(), sans: listOf(sans), password })
|
||||||
|
} catch (caught) {
|
||||||
|
setError((caught as Error).message)
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
|
||||||
|
<DialogTitle>Issue a certificate</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, pt: 1 }}>
|
||||||
|
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
{authorities.length === 0 && <Alert severity="info">No active issuing CA issues certificates for other systems; add one in Settings.</Alert>}
|
||||||
|
<Stack direction="row" sx={{ gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
fullWidth
|
||||||
|
label="Issuing CA"
|
||||||
|
value={ca}
|
||||||
|
onChange={(event) => {
|
||||||
|
setCa(event.target.value)
|
||||||
|
setTemplate(authorities.find((authority) => authority.name === event.target.value)?.templates[0] ?? '')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{authorities.map((authority) => <MenuItem key={authority.name} value={authority.name}>{`${authority.name}: ${authority.common_name}`}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
<TextField select fullWidth label="Template" value={template} onChange={(event) => setTemplate(event.target.value)}>
|
||||||
|
{templates.map((name) => <MenuItem key={name} value={name}>{name}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
</Stack>
|
||||||
|
<RadioGroup row value={mode} onChange={(event) => setMode(event.target.value as 'csr' | 'key')}>
|
||||||
|
<FormControlLabel value="csr" control={<Radio />} label="Sign a CSR" />
|
||||||
|
<FormControlLabel value="key" control={<Radio />} label="Make a key and a PFX file" />
|
||||||
|
</RadioGroup>
|
||||||
|
{mode === 'csr' ? (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
label="CSR"
|
||||||
|
value={csr}
|
||||||
|
multiline
|
||||||
|
minRows={6}
|
||||||
|
maxRows={16}
|
||||||
|
autoComplete="off"
|
||||||
|
error={csrProblem !== ''}
|
||||||
|
helperText={csrProblem || "The certificate takes the CSR's key, common name and subject alternative names, checked against the template."}
|
||||||
|
slotProps={{ htmlInput: { sx: { fontFamily: 'monospace', fontSize: 12 } } }}
|
||||||
|
onChange={(event) => setCsr(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button component="label" variant="outlined" startIcon={<UploadFile />} sx={{ alignSelf: 'flex-start' }}>
|
||||||
|
Read a file
|
||||||
|
<input hidden type="file" accept=".csr,.pem,.req,.txt" onChange={(event) => void event.target.files?.[0]?.text().then(setCsr)} />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
label="Subject"
|
||||||
|
value={subject}
|
||||||
|
autoComplete="off"
|
||||||
|
helperText={values ? `The common name: ${SUBJECT_LABELS[values.subject] ?? values.subject}.` : ''}
|
||||||
|
onChange={(event) => setSubject(event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Subject alternative names"
|
||||||
|
value={sans}
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={values !== undefined && values.sans.length === 0}
|
||||||
|
helperText={values ? `Separated by commas; the template allows ${values.sans.join(', ') || 'none'}.` : ''}
|
||||||
|
onChange={(event) => setSans(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" sx={{ gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="PFX password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
autoComplete="new-password"
|
||||||
|
helperText="Protects the key in the PFX file; the router keeps no copy of the key."
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Repeat the password"
|
||||||
|
type="password"
|
||||||
|
value={repeated}
|
||||||
|
autoComplete="new-password"
|
||||||
|
error={repeatProblem !== ''}
|
||||||
|
helperText={repeatProblem}
|
||||||
|
onChange={(event) => setRepeated(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={props.onCancel}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={saving || blocked}>Issue</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,754 @@
|
|||||||
|
import Add from '@mui/icons-material/Add'
|
||||||
|
import Block from '@mui/icons-material/Block'
|
||||||
|
import Delete from '@mui/icons-material/Delete'
|
||||||
|
import Download from '@mui/icons-material/Download'
|
||||||
|
import Edit from '@mui/icons-material/Edit'
|
||||||
|
import Refresh from '@mui/icons-material/Refresh'
|
||||||
|
import Settings from '@mui/icons-material/Settings'
|
||||||
|
import UploadFile from '@mui/icons-material/UploadFile'
|
||||||
|
import WarningAmber from '@mui/icons-material/WarningAmber'
|
||||||
|
import {
|
||||||
|
Alert, Box, Button, Chip, IconButton, MenuItem, Stack, Tab, Table, TableBody, TableCell, TableHead, TableRow, Tabs, TextField,
|
||||||
|
Tooltip, Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { when } from './BackupSummary.tsx'
|
||||||
|
import CertificateDialog from './CertificateDialog.tsx'
|
||||||
|
import ConfirmDialog, { type ConfirmDialogProps } from './ConfirmDialog.tsx'
|
||||||
|
import ImportDialog from './ImportDialog.tsx'
|
||||||
|
import IssuingCaDialog from './IssuingCaDialog.tsx'
|
||||||
|
import TemplateDialog from './TemplateDialog.tsx'
|
||||||
|
import Toolbar from './Toolbar.tsx'
|
||||||
|
import EkuDialog from './EkuDialog.tsx'
|
||||||
|
import PkiSettingsDialog, { PUBLISH_LABELS } from './PkiSettingsDialog.tsx'
|
||||||
|
import { Pairs, Section } from './RouterPanel.tsx'
|
||||||
|
import {
|
||||||
|
ApiError, BUNDLE_SHAPE, CA_IMPORTED_SHAPE, CRL_IMPORTED_SHAPE, CSR_SHAPE, ISSUED_SHAPE, PFX_SHAPE, PKI_SETTINGS_SHAPE, PKI_SHAPE, REVOKED_SHAPE,
|
||||||
|
BUILTIN_TEMPLATES, REVOKE_REASONS, request, type Pki, type PkiCertificate, type PkiIssuingCa, type PkiTemplate,
|
||||||
|
} from './api.ts'
|
||||||
|
import { saveFile } from './saveFile.ts'
|
||||||
|
|
||||||
|
/** The tabs of the page: the root and the general information, the templates, the extended key usages, the two
|
||||||
|
* built-in issuing CAs, the general-purpose ones. */
|
||||||
|
type View = 'root' | 'templates' | 'usages' | 'p2s' | 's2s' | 'issuing'
|
||||||
|
|
||||||
|
/** The built-in CAs, whose certificates the Issuing CAs tab leaves out. */
|
||||||
|
const BUILTIN_CAS = ['root', 'p2s', 's2s']
|
||||||
|
|
||||||
|
/** The note shown after a change, which the router applies when vpn-router-setup has run again. */
|
||||||
|
const APPLIED = 'The router applies the change in the background.'
|
||||||
|
|
||||||
|
/** The colour of each state of an issuing CA and of a certificate. */
|
||||||
|
const STATE_COLORS: Record<string, 'success' | 'warning' | 'error' | 'default'> = {
|
||||||
|
active: 'success',
|
||||||
|
valid: 'success',
|
||||||
|
pending: 'warning',
|
||||||
|
missing: 'error',
|
||||||
|
expired: 'default',
|
||||||
|
revoked: 'error',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The labels of the revocation reasons. */
|
||||||
|
const REASON_LABELS: Record<string, string> = {
|
||||||
|
unspecified: 'Unspecified',
|
||||||
|
key_compromise: 'Key compromise',
|
||||||
|
affiliation_changed: 'Affiliation changed',
|
||||||
|
superseded: 'Superseded',
|
||||||
|
cessation_of_operation: 'Cessation of operation',
|
||||||
|
privilege_withdrawn: 'Privilege withdrawn',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The router's PKI in tabs: Root, the root CA with the general settings and the certificates of the issuing CAs;
|
||||||
|
* Templates, the certificate templates, which are added, changed, removed and reset there; Extended key usages, the
|
||||||
|
* built-in ones and the custom ones, which are added, changed and removed there; P2S Issuing CA and S2S
|
||||||
|
* Issuing CA, each built-in CA with the certificates it issued; Issuing CAs, the general-purpose CAs, which are added,
|
||||||
|
* changed and removed there, with their certificates, filtered by CA. The warnings, Issue and Settings
|
||||||
|
* are above the tabs. Settings edits the pki section, Issue issues a certificate from a CSR or into a PFX file, a
|
||||||
|
* certificate of another system or of the router is revoked, an issuing CA waiting for the offline root gives its CSR
|
||||||
|
* and takes its signed certificate, and the root takes a CRL signed offline. The root key is managed on the router
|
||||||
|
* with vpn-router-ca only.
|
||||||
|
*/
|
||||||
|
export default function CertificatesPanel() {
|
||||||
|
const [pki, setPki] = useState<Pki | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [unavailable, setUnavailable] = useState(false)
|
||||||
|
const [note, setNote] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [authority, setAuthority] = useState<{ name?: string } | null>(null)
|
||||||
|
const [template, setTemplate] = useState<{ name?: string } | null>(null)
|
||||||
|
const [usage, setUsage] = useState<{ name?: string } | null>(null)
|
||||||
|
const [issuing, setIssuing] = useState(false)
|
||||||
|
const [importing, setImporting] = useState<string | null>(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [confirm, setConfirm] = useState<ConfirmDialogProps | null>(null)
|
||||||
|
const [filter, setFilter] = useState('')
|
||||||
|
const [view, setView] = useState<View>('root')
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
setPki(await request<Pki>('GET', '/ca', PKI_SHAPE))
|
||||||
|
setError(null)
|
||||||
|
setUnavailable(false)
|
||||||
|
} catch (caught) {
|
||||||
|
const failure = caught as ApiError
|
||||||
|
setPki(null)
|
||||||
|
setUnavailable(failure.status === 404)
|
||||||
|
setError(failure.message)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh()
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
/** Saves the certificates a client trusts the CA with: the root alone, or an issuing CA followed by the root. */
|
||||||
|
async function downloadBundle(name: string) {
|
||||||
|
try {
|
||||||
|
const { pem } = await request<{ name: string; pem: string }>('GET', `/ca/bundle/${name}`, BUNDLE_SHAPE)
|
||||||
|
saveFile(name === 'root' ? 'root-ca.pem' : `${name}-ca-bundle.pem`, pem, 'application/x-pem-file')
|
||||||
|
setError(null)
|
||||||
|
} catch (caught) {
|
||||||
|
setError((caught as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadCsr(name: string) {
|
||||||
|
try {
|
||||||
|
const { csr, extensions } = await request<{ csr: string; extensions: string }>('GET', `/ca/issuing/${name}/csr`, CSR_SHAPE)
|
||||||
|
saveFile(`${name}.csr`, csr, 'application/pkcs10')
|
||||||
|
saveFile(`${name}-extensions.cnf`, extensions, 'text/plain')
|
||||||
|
setError(null)
|
||||||
|
} catch (caught) {
|
||||||
|
setError((caught as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the general-purpose issuing CAs: the current ones with name set to values, or without it when values is null. */
|
||||||
|
async function saveAuthority(name: string, values: PkiIssuingCa | null) {
|
||||||
|
const { [name]: _current, ...others } = pki?.settings.issuing_cas ?? {}
|
||||||
|
await request('PUT', '/ca', PKI_SETTINGS_SHAPE, { issuing_cas: values === null ? others : { ...others, [name]: values } })
|
||||||
|
setNote(APPLIED)
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the templates of pki.templates: the current ones with name set to values, or without it when values is
|
||||||
|
* null, which removes a template of the router's own or brings a built-in one back to its defaults. */
|
||||||
|
async function saveTemplate(name: string, values: PkiTemplate | null) {
|
||||||
|
const { [name]: _current, ...others } = pki?.settings.templates ?? {}
|
||||||
|
await request('PUT', '/ca', PKI_SETTINGS_SHAPE, { templates: values === null ? others : { ...others, [name]: values } })
|
||||||
|
setNote(APPLIED)
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the custom extended key usages: the current ones with name set to oid, or without it when oid is null. */
|
||||||
|
async function saveUsage(name: string, oid: string | null) {
|
||||||
|
const { [name]: _current, ...others } = pki?.settings.extended_key_usages ?? {}
|
||||||
|
await request('PUT', '/ca', PKI_SETTINGS_SHAPE, { extended_key_usages: oid === null ? others : { ...others, [name]: oid } })
|
||||||
|
setNote(APPLIED)
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeUsage(name: string) {
|
||||||
|
setConfirm({
|
||||||
|
title: 'Remove extended key usage',
|
||||||
|
message: `Remove extended key usage ${name}? The router refuses while a template gives it; certificates issued with it keep it.`,
|
||||||
|
confirmLabel: 'Remove',
|
||||||
|
destructive: true,
|
||||||
|
onCancel: () => setConfirm(null),
|
||||||
|
onConfirm: () => {
|
||||||
|
setConfirm(null)
|
||||||
|
setBusy(true)
|
||||||
|
saveUsage(name, null)
|
||||||
|
.then(() => setError(null))
|
||||||
|
.catch((caught: Error) => setError(caught.message))
|
||||||
|
.finally(() => setBusy(false))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTemplate(name: string, builtin: boolean) {
|
||||||
|
setConfirm({
|
||||||
|
title: builtin ? 'Reset template' : 'Remove template',
|
||||||
|
message: builtin
|
||||||
|
? `Bring template ${name} back to its built-in options? The certificates issued afterwards follow them.`
|
||||||
|
: `Remove template ${name}? The router refuses while an issuing CA issues it; certificates issued with it stay valid.`,
|
||||||
|
confirmLabel: builtin ? 'Reset' : 'Remove',
|
||||||
|
destructive: !builtin,
|
||||||
|
onCancel: () => setConfirm(null),
|
||||||
|
onConfirm: () => {
|
||||||
|
setConfirm(null)
|
||||||
|
setBusy(true)
|
||||||
|
saveTemplate(name, null)
|
||||||
|
.then(() => setError(null))
|
||||||
|
.catch((caught: Error) => setError(caught.message))
|
||||||
|
.finally(() => setBusy(false))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAuthority(name: string) {
|
||||||
|
setConfirm({
|
||||||
|
title: 'Remove issuing CA',
|
||||||
|
message: `Remove issuing CA ${name}? The router retires it and revokes its certificate on the root CRL; with the root offline, the root CRL is signed offline. Its certificates stay valid until they expire or are revoked.`,
|
||||||
|
confirmLabel: 'Remove',
|
||||||
|
destructive: true,
|
||||||
|
onCancel: () => setConfirm(null),
|
||||||
|
onConfirm: () => {
|
||||||
|
setConfirm(null)
|
||||||
|
setBusy(true)
|
||||||
|
saveAuthority(name, null)
|
||||||
|
.then(() => setError(null))
|
||||||
|
.catch((caught: Error) => setError(caught.message))
|
||||||
|
.finally(() => setBusy(false))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function revoke(certificate: PkiCertificate) {
|
||||||
|
let reason: string = REVOKE_REASONS[0]
|
||||||
|
setConfirm({
|
||||||
|
title: 'Revoke certificate',
|
||||||
|
message: `Revoke certificate ${certificate.serial} of ${certificate.subject}? The CRL of ${certificate.ca} lists it at once.${certificate.kind === 'router' ? ' The router issues itself a new one.' : ''}`,
|
||||||
|
confirmLabel: 'Revoke',
|
||||||
|
destructive: true,
|
||||||
|
children: (
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
fullWidth
|
||||||
|
label="Reason"
|
||||||
|
defaultValue={reason}
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
onChange={(event) => {
|
||||||
|
reason = event.target.value
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{REVOKE_REASONS.map((item) => <MenuItem key={item} value={item}>{REASON_LABELS[item]}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
),
|
||||||
|
onCancel: () => setConfirm(null),
|
||||||
|
onConfirm: () => {
|
||||||
|
setConfirm(null)
|
||||||
|
setBusy(true)
|
||||||
|
request<{ reissue: boolean }>('POST', `/ca/certificates/${certificate.serial}/revoke`, REVOKED_SHAPE, { reason })
|
||||||
|
.then((revoked) => {
|
||||||
|
setError(null)
|
||||||
|
setNote(revoked.reissue ? APPLIED : null)
|
||||||
|
})
|
||||||
|
.catch((caught: Error) => setError(caught.message))
|
||||||
|
.finally(() => void refresh())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = pki?.status.root
|
||||||
|
const general = pki?.status.authorities.filter((authority) => !authority.builtin) ?? []
|
||||||
|
const generalNames = [...new Set([...general.map((authority) => authority.name),
|
||||||
|
...(pki?.certificates ?? []).map((certificate) => certificate.ca).filter((name) => !BUILTIN_CAS.includes(name))])]
|
||||||
|
const generalCertificates = (pki?.certificates ?? [])
|
||||||
|
.filter((certificate) => !BUILTIN_CAS.includes(certificate.ca) && (filter === '' || certificate.ca === filter))
|
||||||
|
|
||||||
|
/** The actions of an issuing CA waiting for the offline root: download its CSR and import its signed certificate. */
|
||||||
|
const pendingActions = (name: string) => (
|
||||||
|
<>
|
||||||
|
<Tooltip title="Download the CSR and the openssl extensions to sign it with">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" disabled={busy} onClick={() => void downloadCsr(name)}><Download fontSize="small" /></IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Import the certificate signed by the root">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" disabled={busy} onClick={() => setImporting(name)}><UploadFile fontSize="small" /></IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The active issuing CA a certificate the root issued belongs to, matched by its subject and expiry. */
|
||||||
|
const authorityOf = (certificate: PkiCertificate) => certificate.kind === 'ca' && certificate.state === 'valid'
|
||||||
|
? pki?.status.authorities.find((item) => item.state === 'active' && item.subject === certificate.subject && item.expires === certificate.expires)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
/** A table of certificates with their state and, for one of another system or of the router, Revoke; for an active
|
||||||
|
* issuing CA, the download of its CA bundle. */
|
||||||
|
const certificateTable = (list: PkiCertificate[]) => (
|
||||||
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Serial</TableCell>
|
||||||
|
<TableCell>CA</TableCell>
|
||||||
|
<TableCell>Kind</TableCell>
|
||||||
|
<TableCell>Template</TableCell>
|
||||||
|
<TableCell>Subject</TableCell>
|
||||||
|
<TableCell>Alternative names</TableCell>
|
||||||
|
<TableCell>Expires</TableCell>
|
||||||
|
<TableCell>State</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{list.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} sx={{ color: 'text.secondary' }}>None.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{list.map((certificate) => (
|
||||||
|
<TableRow key={certificate.serial} hover>
|
||||||
|
<TableCell sx={{ fontFamily: 'monospace', whiteSpace: 'nowrap' }}>{certificate.serial}</TableCell>
|
||||||
|
<TableCell sx={{ whiteSpace: 'nowrap' }}>{certificate.ca}</TableCell>
|
||||||
|
<TableCell sx={{ whiteSpace: 'nowrap' }}>{certificate.kind}</TableCell>
|
||||||
|
<TableCell sx={{ whiteSpace: 'nowrap' }}>{certificate.template ?? '-'}</TableCell>
|
||||||
|
<TableCell>{certificate.subject}</TableCell>
|
||||||
|
<TableCell>{certificate.sans.join(', ') || '-'}</TableCell>
|
||||||
|
<TableCell sx={{ whiteSpace: 'nowrap' }}>{when(certificate.expires)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title={certificate.revoked ? `${when(certificate.revoked)}, ${REASON_LABELS[certificate.reason ?? ''] ?? certificate.reason ?? ''}` : ''}>
|
||||||
|
<Chip size="small" label={certificate.state} color={STATE_COLORS[certificate.state] ?? 'default'} />
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{authorityOf(certificate) && (
|
||||||
|
<Tooltip title="Download the CA bundle: the issuing CA and the root">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" disabled={busy} onClick={() => void downloadBundle(authorityOf(certificate)!.name)}>
|
||||||
|
<Download fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{(certificate.kind === 'external' || certificate.kind === 'router') && certificate.state === 'valid' && (
|
||||||
|
<Tooltip title="Revoke">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" color="error" disabled={busy} onClick={() => revoke(certificate)}><Block fontSize="small" /></IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A tab of a built-in issuing CA: its details, the actions while it waits for the offline root, its certificates. */
|
||||||
|
const builtinTab = (name: 'p2s' | 's2s', title: string) => {
|
||||||
|
const authority = pki?.status.authorities.find((item) => item.name === name)
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'grid', gap: 2 }}>
|
||||||
|
<Section title={title}>
|
||||||
|
{authority ? (
|
||||||
|
<>
|
||||||
|
<Pairs
|
||||||
|
rows={[
|
||||||
|
['Common name', authority.subject ?? authority.common_name],
|
||||||
|
['State', <Chip size="small" label={authority.state} color={STATE_COLORS[authority.state] ?? 'default'} />],
|
||||||
|
['Expires', when(authority.expires)],
|
||||||
|
['CRL valid until', when(authority.crl_next_update)],
|
||||||
|
['Templates', authority.templates.join(', ')],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{authority.state === 'pending' && (
|
||||||
|
<Stack direction="row" sx={{ mt: 1, alignItems: 'center', justifyContent: 'flex-end', gap: 1 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">Waits for its certificate signed by the root:</Typography>
|
||||||
|
{pendingActions(name)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
{authority.state === 'active' && (
|
||||||
|
<Stack direction="row" sx={{ mt: 1, justifyContent: 'flex-end' }}>
|
||||||
|
<Button size="small" variant="outlined" startIcon={<Download />} disabled={busy} onClick={() => void downloadBundle(name)}>
|
||||||
|
Download CA bundle
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : <Typography variant="body2" color="text.secondary">None yet; the router creates it once a platform is chosen.</Typography>}
|
||||||
|
</Section>
|
||||||
|
<Section title="Certificates issued">{certificateTable((pki?.certificates ?? []).filter((certificate) => certificate.ca === name))}</Section>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box component="section">
|
||||||
|
<Toolbar description="The root CA and the issuing CAs for P2S, site-to-site and general use.">
|
||||||
|
<Button variant="contained" startIcon={<Add />} disabled={busy || pki === null} onClick={() => setIssuing(true)}>
|
||||||
|
Issue
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" startIcon={<Settings />} disabled={busy || pki === null} onClick={() => setEditing(true)}>
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" startIcon={<Refresh />} disabled={busy} onClick={() => void refresh()}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
{error && <Alert severity={unavailable ? 'info' : 'error'} sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
{note && <Alert severity="info" sx={{ mb: 2 }} onClose={() => setNote(null)}>{note}</Alert>}
|
||||||
|
{pki?.status.warnings.map((warning) => <Alert key={warning} severity="warning" sx={{ mb: 2 }}>{warning}</Alert>)}
|
||||||
|
|
||||||
|
{pki && (
|
||||||
|
<Tabs value={view} onChange={(_event, next: View) => setView(next)} sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<Tab value="root" label="Root" />
|
||||||
|
<Tab value="templates" label="Templates" />
|
||||||
|
<Tab value="usages" label="Extended key usages" />
|
||||||
|
<Tab value="p2s" label="P2S Issuing CA" />
|
||||||
|
<Tab value="s2s" label="S2S Issuing CA" />
|
||||||
|
<Tab value="issuing" label="Issuing CAs" />
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pki && view === 'root' && (
|
||||||
|
<Box sx={{ display: 'grid', gap: 2, gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' } }}>
|
||||||
|
<Section title="Root CA">
|
||||||
|
{root ? (
|
||||||
|
<>
|
||||||
|
<Pairs
|
||||||
|
rows={[
|
||||||
|
['Subject', root.subject],
|
||||||
|
['Expires', when(root.expires)],
|
||||||
|
['Key', root.key ? (
|
||||||
|
<Box component="span" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
on the router
|
||||||
|
<Tooltip title="Keeping the root key off the router is recommended: vpn-router-ca root export-key, then vpn-router-ca root remove-key.">
|
||||||
|
<WarningAmber fontSize="small" color="warning" aria-label="The root key is on the router" />
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
) : 'offline'],
|
||||||
|
['CRL valid until', when(root.crl_next_update)],
|
||||||
|
['CRL', root.crl_source === 'uploaded' ? 'uploaded' : root.crl_source === 'signed' ? 'signed by the router' : '-'],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" sx={{ mt: 1, justifyContent: 'flex-end', gap: 1 }}>
|
||||||
|
<Button size="small" variant="outlined" startIcon={<Download />} disabled={busy} onClick={() => void downloadBundle('root')}>
|
||||||
|
Download root CA
|
||||||
|
</Button>
|
||||||
|
<Button size="small" variant="outlined" startIcon={<UploadFile />} disabled={busy} onClick={() => setUploading(true)}>
|
||||||
|
Upload CRL
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
) : <Typography variant="body2" color="text.secondary">None yet; the router creates it once a platform is chosen.</Typography>}
|
||||||
|
</Section>
|
||||||
|
<Section title="General">
|
||||||
|
<Pairs
|
||||||
|
rows={[
|
||||||
|
['Published at', pki.settings.publish.map((item) => PUBLISH_LABELS[item] ?? item).join('; ') || 'nowhere'],
|
||||||
|
['CA key', pki.settings.key],
|
||||||
|
['Lifetimes', `root ${pki.settings.root_days} days, issuing CAs ${pki.settings.ca_days} days`],
|
||||||
|
['CRLs', `issuing CAs ${pki.settings.crl_days} days, root ${pki.settings.root_crl_days} days`],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section title="Issuing CA certificates" wide>
|
||||||
|
{certificateTable(pki.certificates.filter((certificate) => certificate.ca === 'root'))}
|
||||||
|
{pki.status.retired.length > 0 && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
{`Retired: ${pki.status.retired.map((retired) => `${retired.name} (${when(retired.retired)})`).join(', ')}`}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pki && view === 'templates' && (
|
||||||
|
<Box sx={{ display: 'grid', gap: 2 }}>
|
||||||
|
<Section title="Certificate templates">
|
||||||
|
<Stack direction="row" sx={{ mb: 1, justifyContent: 'flex-end' }}>
|
||||||
|
<Button size="small" variant="outlined" startIcon={<Add />} disabled={busy} onClick={() => setTemplate({})}>
|
||||||
|
Add template
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Key</TableCell>
|
||||||
|
<TableCell>Days</TableCell>
|
||||||
|
<TableCell>Subject</TableCell>
|
||||||
|
<TableCell>Key usage</TableCell>
|
||||||
|
<TableCell>Extended key usage</TableCell>
|
||||||
|
<TableCell>Alternative names</TableCell>
|
||||||
|
<TableCell>Policies</TableCell>
|
||||||
|
<TableCell>Issued by</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{Object.entries(pki.templates).map(([name, values]) => {
|
||||||
|
const builtin = (BUILTIN_TEMPLATES as readonly string[]).includes(name)
|
||||||
|
const changed = pki.settings.templates[name] !== undefined
|
||||||
|
return (
|
||||||
|
<TableRow key={name} hover>
|
||||||
|
<TableCell>
|
||||||
|
{name}
|
||||||
|
{builtin && <Chip size="small" variant="outlined" label={changed ? 'built in, changed' : 'built in'} sx={{ ml: 1 }} />}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{values.key}</TableCell>
|
||||||
|
<TableCell>{values.days}</TableCell>
|
||||||
|
<TableCell>{values.subject}</TableCell>
|
||||||
|
<TableCell>{values.key_usage.join(', ')}</TableCell>
|
||||||
|
<TableCell>{values.extended_key_usage.join(', ') || '-'}</TableCell>
|
||||||
|
<TableCell>{values.sans.join(', ') || '-'}</TableCell>
|
||||||
|
<TableCell>{values.policies.join(', ') || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{pki.status.authorities.filter((item) => item.templates.includes(name)).map((item) => item.name).join(', ') || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||||
|
<Tooltip title="Edit">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" disabled={busy} onClick={() => setTemplate({ name })}><Edit fontSize="small" /></IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
{(!builtin || changed) && (
|
||||||
|
<Tooltip title={builtin ? 'Reset to the built-in options' : 'Remove'}>
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" color={builtin ? 'default' : 'error'} disabled={busy} onClick={() => removeTemplate(name, builtin)}>
|
||||||
|
{builtin ? <Refresh fontSize="small" /> : <Delete fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</Section>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pki && view === 'usages' && (
|
||||||
|
<Box sx={{ display: 'grid', gap: 2 }}>
|
||||||
|
<Section title="Extended key usages">
|
||||||
|
<Stack direction="row" sx={{ mb: 1, justifyContent: 'flex-end' }}>
|
||||||
|
<Button size="small" variant="outlined" startIcon={<Add />} disabled={busy} onClick={() => setUsage({})}>
|
||||||
|
Add extended key usage
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>OID</TableCell>
|
||||||
|
<TableCell>Given by</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{Object.entries(pki.extended_key_usages).map(([name, usage]) => (
|
||||||
|
<TableRow key={name} hover>
|
||||||
|
<TableCell>
|
||||||
|
{name}
|
||||||
|
{usage.builtin && <Chip size="small" variant="outlined" label="built in" sx={{ ml: 1 }} />}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ fontFamily: 'monospace' }}>{usage.oid}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{Object.entries(pki.templates).filter(([, values]) => values.extended_key_usage.includes(name)).map(([template]) => template).join(', ') || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{!usage.builtin && (
|
||||||
|
<>
|
||||||
|
<Tooltip title="Edit">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" disabled={busy} onClick={() => setUsage({ name })}><Edit fontSize="small" /></IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Remove">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" color="error" disabled={busy} onClick={() => removeUsage(name)}><Delete fontSize="small" /></IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</Section>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pki && view === 'p2s' && builtinTab('p2s', 'P2S Issuing CA: the VPN server certificate and the users\' certificates')}
|
||||||
|
{pki && view === 's2s' && builtinTab('s2s', 'S2S Issuing CA: the site-to-site certificates of the router and its peers')}
|
||||||
|
|
||||||
|
{pki && view === 'issuing' && (
|
||||||
|
<Box sx={{ display: 'grid', gap: 2 }}>
|
||||||
|
<Section title="General-purpose issuing CAs">
|
||||||
|
<Stack direction="row" sx={{ mb: 1, justifyContent: 'flex-end' }}>
|
||||||
|
<Button size="small" variant="outlined" startIcon={<Add />} disabled={busy} onClick={() => setAuthority({})}>
|
||||||
|
Add issuing CA
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Common name</TableCell>
|
||||||
|
<TableCell>State</TableCell>
|
||||||
|
<TableCell>Expires</TableCell>
|
||||||
|
<TableCell>CRL valid until</TableCell>
|
||||||
|
<TableCell>Templates</TableCell>
|
||||||
|
<TableCell>ACME</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{general.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} sx={{ color: 'text.secondary' }}>None.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{general.map((authority) => (
|
||||||
|
<TableRow key={authority.name} hover>
|
||||||
|
<TableCell>{authority.name}</TableCell>
|
||||||
|
<TableCell>{authority.subject ?? authority.common_name}</TableCell>
|
||||||
|
<TableCell><Chip size="small" label={authority.state} color={STATE_COLORS[authority.state] ?? 'default'} /></TableCell>
|
||||||
|
<TableCell>{when(authority.expires)}</TableCell>
|
||||||
|
<TableCell>{when(authority.crl_next_update)}</TableCell>
|
||||||
|
<TableCell>{authority.templates.join(', ')}</TableCell>
|
||||||
|
<TableCell sx={{ wordBreak: 'break-all' }}>{authority.acme_url ?? (authority.acme ? 'waits for the certificate' : '-')}</TableCell>
|
||||||
|
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{authority.state === 'pending' && pendingActions(authority.name)}
|
||||||
|
{authority.state === 'active' && (
|
||||||
|
<Tooltip title="Download the CA bundle: the issuing CA and the root">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" disabled={busy} onClick={() => void downloadBundle(authority.name)}><Download fontSize="small" /></IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip title="Edit">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" disabled={busy} onClick={() => setAuthority({ name: authority.name })}><Edit fontSize="small" /></IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Remove">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" color="error" disabled={busy} onClick={() => removeAuthority(authority.name)}><Delete fontSize="small" /></IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</Section>
|
||||||
|
<Section title="Certificates issued">
|
||||||
|
<TextField select size="small" label="Issuing CA" value={filter} sx={{ mb: 1, minWidth: 200 }} onChange={(event) => setFilter(event.target.value)}>
|
||||||
|
<MenuItem value="">All</MenuItem>
|
||||||
|
{generalNames.map((name) => <MenuItem key={name} value={name}>{name}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
{certificateTable(generalCertificates)}
|
||||||
|
</Section>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && pki && (
|
||||||
|
<PkiSettingsDialog
|
||||||
|
pki={pki}
|
||||||
|
onCancel={() => setEditing(false)}
|
||||||
|
onSave={async (settings) => {
|
||||||
|
await request('PUT', '/ca', PKI_SETTINGS_SHAPE, settings)
|
||||||
|
setEditing(false)
|
||||||
|
setNote(APPLIED)
|
||||||
|
await refresh()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{usage && pki && (
|
||||||
|
<EkuDialog
|
||||||
|
pki={pki}
|
||||||
|
name={usage.name}
|
||||||
|
onCancel={() => setUsage(null)}
|
||||||
|
onSave={async (name, oid) => {
|
||||||
|
await saveUsage(name, oid)
|
||||||
|
setUsage(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{template && pki && (
|
||||||
|
<TemplateDialog
|
||||||
|
pki={pki}
|
||||||
|
name={template.name}
|
||||||
|
onCancel={() => setTemplate(null)}
|
||||||
|
onSave={async (name, values) => {
|
||||||
|
await saveTemplate(name, values)
|
||||||
|
setTemplate(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{authority && pki && (
|
||||||
|
<IssuingCaDialog
|
||||||
|
pki={pki}
|
||||||
|
name={authority.name}
|
||||||
|
onCancel={() => setAuthority(null)}
|
||||||
|
onSave={async (name, values) => {
|
||||||
|
await saveAuthority(name, values)
|
||||||
|
setAuthority(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{issuing && pki && (
|
||||||
|
<CertificateDialog
|
||||||
|
pki={pki}
|
||||||
|
onCancel={() => setIssuing(false)}
|
||||||
|
onSave={async (issue) => {
|
||||||
|
if ('csr' in issue) {
|
||||||
|
const issued = await request<{ serial: string; certificate: string; chain: string }>('POST', '/ca/certificates', ISSUED_SHAPE, issue)
|
||||||
|
saveFile(`${issued.serial}.pem`, issued.certificate + issued.chain, 'application/x-pem-file')
|
||||||
|
} else {
|
||||||
|
const issued = await request<{ serial: string; pfx: string }>('POST', '/ca/certificates', PFX_SHAPE, issue)
|
||||||
|
saveFile(`${issue.subject}.pfx`, Uint8Array.from(atob(issued.pfx), (character) => character.charCodeAt(0)), 'application/x-pkcs12')
|
||||||
|
}
|
||||||
|
setIssuing(false)
|
||||||
|
await refresh()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{importing && (
|
||||||
|
<ImportDialog
|
||||||
|
title={`Import the certificate of ${importing}`}
|
||||||
|
message="The certificate of the issuing CA, signed by the root CA for the key of its CSR."
|
||||||
|
label="Certificate"
|
||||||
|
begin="-----BEGIN CERTIFICATE-----"
|
||||||
|
onCancel={() => setImporting(null)}
|
||||||
|
onSave={async (certificate) => {
|
||||||
|
await request('PUT', `/ca/issuing/${importing}/certificate`, CA_IMPORTED_SHAPE, { certificate })
|
||||||
|
setImporting(null)
|
||||||
|
setNote(APPLIED)
|
||||||
|
await refresh()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{uploading && (
|
||||||
|
<ImportDialog
|
||||||
|
title="Upload the root CRL"
|
||||||
|
message="A CRL of the root CA signed offline, not older than the current one."
|
||||||
|
label="CRL"
|
||||||
|
begin="-----BEGIN X509 CRL-----"
|
||||||
|
onCancel={() => setUploading(false)}
|
||||||
|
onSave={async (crl) => {
|
||||||
|
await request('PUT', '/ca/root/crl', CRL_IMPORTED_SHAPE, { crl })
|
||||||
|
setUploading(false)
|
||||||
|
await refresh()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{confirm && <ConfirmDialog {...confirm} />}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export type ConfirmDialogProps = {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
confirmLabel: string
|
||||||
|
destructive: boolean
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
/** Fields shown below the message, such as a choice the confirmation sends. */
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A question that must be answered before the page goes on. Cancel has the initial focus. */
|
||||||
|
export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={props.onCancel} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle>{props.title}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText sx={{ whiteSpace: 'pre-line' }}>{props.message}</DialogContentText>
|
||||||
|
{props.children}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button autoFocus onClick={props.onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" color={props.destructive ? 'error' : 'primary'} onClick={props.onConfirm}>
|
||||||
|
{props.confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField } from '@mui/material'
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { ZONE_PATTERN, type DnsDomain } from './api.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog that adds a domain of the name server, or edits one given as current: its name, fixed for an existing one,
|
||||||
|
* and an optional TTL. onSave sends it; the dialog closes when it succeeds and shows the router's message when it fails.
|
||||||
|
*/
|
||||||
|
export default function DnsDomainDialog(props: {
|
||||||
|
current?: DnsDomain
|
||||||
|
onSave: (domain: { name: string; ttl: number | null }) => Promise<void>
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(props.current?.name ?? '')
|
||||||
|
const [ttl, setTtl] = useState(props.current?.ttl ? String(props.current.ttl) : '')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const nameProblem = name === '' || ZONE_PATTERN.test(name) ? '' : 'A DNS name in lower case.'
|
||||||
|
const ttlProblem = ttl === '' || (/^\d{2,5}$/.test(ttl) && Number(ttl) >= 60 && Number(ttl) <= 86400) ? '' : '60 to 86400 seconds.'
|
||||||
|
const blocked = name === '' || nameProblem !== '' || ttlProblem !== ''
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (blocked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await props.onSave({ name, ttl: ttl ? Number(ttl) : null })
|
||||||
|
} catch (caught) {
|
||||||
|
setError((caught as Error).message)
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
|
||||||
|
<DialogTitle>{props.current ? `Domain ${props.current.name}` : 'Add domain'}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, pt: 1 }}>
|
||||||
|
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
<TextField
|
||||||
|
label="Domain"
|
||||||
|
value={name}
|
||||||
|
autoFocus={!props.current}
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={Boolean(props.current)}
|
||||||
|
error={nameProblem !== ''}
|
||||||
|
helperText={nameProblem || 'The domain the router is the primary name server of.'}
|
||||||
|
onChange={(event) => setName(event.target.value.trim().toLowerCase())}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="TTL (seconds)"
|
||||||
|
value={ttl}
|
||||||
|
autoComplete="off"
|
||||||
|
error={ttlProblem !== ''}
|
||||||
|
helperText={ttlProblem || 'Empty for the name server\'s TTL.'}
|
||||||
|
slotProps={{ htmlInput: { inputMode: 'numeric' } }}
|
||||||
|
onChange={(event) => setTtl(event.target.value.trim())}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={props.onCancel} disabled={saving}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={saving || blocked}>{props.current ? 'Save' : 'Add'}</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import Add from '@mui/icons-material/Add'
|
||||||
|
import Delete from '@mui/icons-material/Delete'
|
||||||
|
import Edit from '@mui/icons-material/Edit'
|
||||||
|
import Refresh from '@mui/icons-material/Refresh'
|
||||||
|
import Settings from '@mui/icons-material/Settings'
|
||||||
|
import { Alert, Box, Button, Chip, IconButton, Table, TableBody, TableCell, TableHead, TableRow, Tooltip } from '@mui/material'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import ConfirmDialog, { type ConfirmDialogProps } from './ConfirmDialog.tsx'
|
||||||
|
import DnsDomainDialog from './DnsDomainDialog.tsx'
|
||||||
|
import DnsRecordDialog from './DnsRecordDialog.tsx'
|
||||||
|
import DnsSettingsDialog from './DnsSettingsDialog.tsx'
|
||||||
|
import { Pairs, Section } from './RouterPanel.tsx'
|
||||||
|
import Toolbar from './Toolbar.tsx'
|
||||||
|
import {
|
||||||
|
ApiError, DNS_DOMAIN_REMOVED_SHAPE, DNS_DOMAIN_SHAPE, DNS_RECORD_SHAPE, DNS_REMOVED_SHAPE, DNS_SETTINGS_SHAPE, DNS_SHAPE,
|
||||||
|
request, type Dns, type DnsDomain, type DnsRecord,
|
||||||
|
} from './api.ts'
|
||||||
|
|
||||||
|
/** A table of records: name, type, content and TTL, with a remove action when onRemove is given. */
|
||||||
|
function Records(props: { records: DnsRecord[]; busy: boolean; onRemove?: (record: DnsRecord) => void }) {
|
||||||
|
return (
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Type</TableCell>
|
||||||
|
<TableCell>Content</TableCell>
|
||||||
|
<TableCell align="right">TTL</TableCell>
|
||||||
|
{props.onRemove && <TableCell align="right">Actions</TableCell>}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{props.records.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={props.onRemove ? 5 : 4} sx={{ color: 'text.secondary' }}>None.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{props.records.map((record) => (
|
||||||
|
<TableRow key={`${record.name} ${record.type} ${record.content}`} hover>
|
||||||
|
<TableCell>{record.name}</TableCell>
|
||||||
|
<TableCell>{record.type}</TableCell>
|
||||||
|
<TableCell sx={{ fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all' }}>{record.content}</TableCell>
|
||||||
|
<TableCell align="right">{record.ttl}</TableCell>
|
||||||
|
{props.onRemove && (
|
||||||
|
<TableCell align="right">
|
||||||
|
<Tooltip title="Remove">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" color="error" disabled={props.busy} onClick={() => props.onRemove?.(record)}><Delete fontSize="small" /></IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The router's name server, from its /dns: the settings of the dns section with whether PowerDNS is installed and runs,
|
||||||
|
* its primary name server, the router's FQDN, and the router's public IP, edited with Settings; the domains it is the
|
||||||
|
* primary of: add, edit and remove; and for the selected domain its manual records: add and remove, and the records it
|
||||||
|
* serves. The records of the web sites follow the DNS setting of each site on the Sites page.
|
||||||
|
*/
|
||||||
|
export default function DnsPanel() {
|
||||||
|
const [dns, setDns] = useState<Dns | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [unavailable, setUnavailable] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [settings, setSettings] = useState(false)
|
||||||
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
|
const [editing, setEditing] = useState<DnsDomain | 'new' | null>(null)
|
||||||
|
const [addingRecord, setAddingRecord] = useState(false)
|
||||||
|
const [confirm, setConfirm] = useState<ConfirmDialogProps | null>(null)
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
setDns(await request<Dns>('GET', '/dns', DNS_SHAPE))
|
||||||
|
setError(null)
|
||||||
|
setUnavailable(false)
|
||||||
|
} catch (caught) {
|
||||||
|
const failure = caught as ApiError
|
||||||
|
setDns(null)
|
||||||
|
setUnavailable(failure.status === 404)
|
||||||
|
setError(failure.message)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh()
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
/** Ask for a confirmation, then send the removal and refresh. */
|
||||||
|
function confirmRemove(title: string, message: string, send: () => Promise<unknown>) {
|
||||||
|
setConfirm({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel: 'Remove',
|
||||||
|
destructive: true,
|
||||||
|
onCancel: () => setConfirm(null),
|
||||||
|
onConfirm: async () => {
|
||||||
|
setConfirm(null)
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await send()
|
||||||
|
setError(null)
|
||||||
|
} catch (caught) {
|
||||||
|
setError((caught as Error).message)
|
||||||
|
}
|
||||||
|
await refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const section = dns?.settings
|
||||||
|
const domain = dns?.domains.find((item) => item.name === selected) ?? null
|
||||||
|
const path = domain ? `/dns/domains/${encodeURIComponent(domain.name)}` : ''
|
||||||
|
|
||||||
|
function removeDomain(item: DnsDomain) {
|
||||||
|
confirmRemove('Remove domain', `Remove the domain ${item.name} with its manual records?`,
|
||||||
|
() => request('DELETE', `/dns/domains/${encodeURIComponent(item.name)}`, DNS_DOMAIN_REMOVED_SHAPE))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRecord(record: DnsRecord) {
|
||||||
|
const query = new URLSearchParams({ name: `${record.name}.`, type: record.type, content: record.content })
|
||||||
|
confirmRemove('Remove record', `Remove the ${record.type} record of ${record.name}?`,
|
||||||
|
() => request('DELETE', `${path}/records?${query.toString()}`, DNS_REMOVED_SHAPE))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box component="section">
|
||||||
|
<Toolbar description="The primary name server of the router's domains.">
|
||||||
|
<Button variant="outlined" startIcon={<Settings />} disabled={busy || !section} onClick={() => setSettings(true)}>
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" startIcon={<Add />} disabled={busy || !section} onClick={() => setEditing('new')}>
|
||||||
|
Add domain
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" startIcon={<Add />} disabled={busy || !domain} onClick={() => setAddingRecord(true)}>
|
||||||
|
Add record
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" startIcon={<Refresh />} disabled={busy} onClick={() => void refresh()}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
{error && <Alert severity={unavailable ? 'info' : 'error'} sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
{dns?.problem && section?.enabled && <Alert severity="warning" sx={{ mb: 2, whiteSpace: 'pre-line' }}>{dns.problem}</Alert>}
|
||||||
|
|
||||||
|
{dns && section && (
|
||||||
|
<Box sx={{ display: 'grid', gap: 2 }}>
|
||||||
|
<Section title="Settings">
|
||||||
|
<Pairs
|
||||||
|
rows={[
|
||||||
|
['State', <Chip key="state" size="small" label={!section.enabled ? 'disabled' : dns.running ? 'running' : 'not running'} color={!section.enabled ? 'default' : dns.running ? 'success' : 'warning'} />],
|
||||||
|
['PowerDNS', dns.installed ? 'installed' : 'not installed'],
|
||||||
|
['Primary name server', dns.nameserver || '-'],
|
||||||
|
['Secondary name server', section.secondary || (section.enabled ? 'not set' : '-')],
|
||||||
|
['Public IP address', dns.public_address || '-'],
|
||||||
|
['Listen', section.listen],
|
||||||
|
['TTL', `${section.ttl} seconds`],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section title="Domains">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Domain</TableCell>
|
||||||
|
<TableCell align="right">Manual records</TableCell>
|
||||||
|
<TableCell align="right">TTL</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{dns.domains.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} sx={{ color: 'text.secondary' }}>None.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{dns.domains.map((item) => (
|
||||||
|
<TableRow key={item.name} hover selected={item.name === selected} sx={{ cursor: 'pointer' }} onClick={() => setSelected(item.name)}>
|
||||||
|
<TableCell>{item.name}</TableCell>
|
||||||
|
<TableCell align="right">{item.manual.length}</TableCell>
|
||||||
|
<TableCell align="right">{item.ttl ?? section.ttl}</TableCell>
|
||||||
|
<TableCell align="right" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<Tooltip title="Edit">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" disabled={busy} onClick={() => setEditing(item)}><Edit fontSize="small" /></IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Remove">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" color="error" disabled={busy} onClick={() => removeDomain(item)}><Delete fontSize="small" /></IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Section>
|
||||||
|
{domain && (
|
||||||
|
<>
|
||||||
|
<Section title={`Records served of ${domain.name}`}>
|
||||||
|
<Records records={domain.records} busy={busy} />
|
||||||
|
</Section>
|
||||||
|
<Section title={`Manual records of ${domain.name}`}>
|
||||||
|
<Records records={domain.manual} busy={busy} onRemove={removeRecord} />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settings && section && (
|
||||||
|
<DnsSettingsDialog
|
||||||
|
current={section}
|
||||||
|
nameserver={dns.nameserver}
|
||||||
|
publicAddress={dns.public_address}
|
||||||
|
onCancel={() => setSettings(false)}
|
||||||
|
onSave={async (values, publicAddress) => {
|
||||||
|
await request('PUT', '/dns', DNS_SETTINGS_SHAPE, { ...values, public_address: publicAddress })
|
||||||
|
setSettings(false)
|
||||||
|
await refresh()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editing && (
|
||||||
|
<DnsDomainDialog
|
||||||
|
current={editing === 'new' ? undefined : editing}
|
||||||
|
onCancel={() => setEditing(null)}
|
||||||
|
onSave={async (values) => {
|
||||||
|
if (editing === 'new') {
|
||||||
|
await request('POST', '/dns/domains', DNS_DOMAIN_SHAPE, values)
|
||||||
|
} else {
|
||||||
|
await request('PUT', `/dns/domains/${encodeURIComponent(editing.name)}`, DNS_DOMAIN_SHAPE, { ttl: values.ttl })
|
||||||
|
}
|
||||||
|
setEditing(null)
|
||||||
|
setSelected(values.name)
|
||||||
|
await refresh()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{addingRecord && domain && (
|
||||||
|
<DnsRecordDialog
|
||||||
|
domain={domain.name}
|
||||||
|
onCancel={() => setAddingRecord(false)}
|
||||||
|
onSave={async (record) => {
|
||||||
|
await request('POST', `${path}/records`, DNS_RECORD_SHAPE, record)
|
||||||
|
setAddingRecord(false)
|
||||||
|
await refresh()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{confirm && <ConfirmDialog {...confirm} />}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField } from '@mui/material'
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { DNS_TYPES } from './api.ts'
|
||||||
|
|
||||||
|
/** The content each type of record takes. */
|
||||||
|
const CONTENT_HELP: Record<string, string> = {
|
||||||
|
A: 'An IPv4 address.',
|
||||||
|
AAAA: 'An IPv6 address.',
|
||||||
|
CNAME: 'The host name the name points to.',
|
||||||
|
TXT: 'The text.',
|
||||||
|
MX: '<preference> <host>',
|
||||||
|
SRV: '<priority> <weight> <port> <host>',
|
||||||
|
CAA: '<flags> <tag> "<value>"',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog that adds a record to a domain: its name, relative to the domain, @ for the domain itself, its type, its
|
||||||
|
* content and an optional TTL. onSave sends it; the dialog closes when it succeeds and shows the router's message when
|
||||||
|
* it fails.
|
||||||
|
*/
|
||||||
|
export default function DnsRecordDialog(props: {
|
||||||
|
domain: string
|
||||||
|
onSave: (record: { name: string; type: string; content: string; ttl?: number }) => Promise<void>
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [type, setType] = useState('A')
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
const [ttl, setTtl] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const ttlProblem = ttl === '' || (/^\d{2,5}$/.test(ttl) && Number(ttl) >= 60 && Number(ttl) <= 86400) ? '' : '60 to 86400 seconds.'
|
||||||
|
const blocked = content.trim() === '' || ttlProblem !== ''
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (blocked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await props.onSave({ name: name.trim() || '@', type, content: content.trim(), ...(ttl ? { ttl: Number(ttl) } : {}) })
|
||||||
|
} catch (caught) {
|
||||||
|
setError((caught as Error).message)
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
|
||||||
|
<DialogTitle>Add record</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, pt: 1 }}>
|
||||||
|
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
autoFocus
|
||||||
|
autoComplete="off"
|
||||||
|
helperText={`The name in ${props.domain}; @ or empty for ${props.domain} itself.`}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField select label="Type" value={type} onChange={(event) => setType(event.target.value)}>
|
||||||
|
{DNS_TYPES.map((item) => <MenuItem key={item} value={item}>{item}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
<TextField label="Content" value={content} autoComplete="off" helperText={CONTENT_HELP[type]} onChange={(event) => setContent(event.target.value)} />
|
||||||
|
<TextField
|
||||||
|
label="TTL (seconds)"
|
||||||
|
value={ttl}
|
||||||
|
autoComplete="off"
|
||||||
|
error={ttlProblem !== ''}
|
||||||
|
helperText={ttlProblem || 'Empty for the domain\'s TTL.'}
|
||||||
|
slotProps={{ htmlInput: { inputMode: 'numeric' } }}
|
||||||
|
onChange={(event) => setTtl(event.target.value.trim())}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={props.onCancel} disabled={saving}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={saving || blocked}>Add</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, MenuItem, Stack, Switch, TextField } from '@mui/material'
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { ADDRESS_PATTERN, LISTEN_CHOICES, ZONE_PATTERN, type DnsSettings, type Listen } from './api.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog that edits the router's dns section: whether the name server runs, the secondary name server of every
|
||||||
|
* domain, the interfaces it answers on and the TTL, and wan.public_address, the router's public IP the records of the
|
||||||
|
* sites with Public IP take. The primary name server is the router's FQDN, nameserver, shown and not edited. An enabled
|
||||||
|
* name server needs a secondary other than the router's FQDN. onSave sends the section and the public address; the
|
||||||
|
* dialog closes when it succeeds and shows the router's message when it fails.
|
||||||
|
*/
|
||||||
|
export default function DnsSettingsDialog(props: {
|
||||||
|
current: DnsSettings
|
||||||
|
nameserver: string
|
||||||
|
publicAddress: string
|
||||||
|
onSave: (settings: DnsSettings, publicAddress: string) => Promise<void>
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const [enabled, setEnabled] = useState(props.current.enabled)
|
||||||
|
const [secondary, setSecondary] = useState(props.current.secondary)
|
||||||
|
const [publicAddress, setPublicAddress] = useState(props.publicAddress)
|
||||||
|
const [listen, setListen] = useState<Listen>(props.current.listen)
|
||||||
|
const [ttl, setTtl] = useState(String(props.current.ttl))
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const secondaryProblem = secondary === '' ? (enabled ? 'Required while enabled.' : '')
|
||||||
|
: !ZONE_PATTERN.test(secondary) ? 'A DNS name in lower case.'
|
||||||
|
: secondary === props.nameserver ? 'The secondary is not the router\'s FQDN, the primary.' : ''
|
||||||
|
const addressProblem = publicAddress === '' || ADDRESS_PATTERN.test(publicAddress) ? '' : 'Not an IP address.'
|
||||||
|
const ttlProblem = /^\d{2,5}$/.test(ttl) && Number(ttl) >= 60 && Number(ttl) <= 86400 ? '' : '60 to 86400 seconds.'
|
||||||
|
const blocked = secondaryProblem !== '' || addressProblem !== '' || ttlProblem !== ''
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (blocked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await props.onSave({ enabled, secondary, listen, ttl: Number(ttl) }, publicAddress)
|
||||||
|
} catch (caught) {
|
||||||
|
setError((caught as Error).message)
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
|
||||||
|
<DialogTitle>Name server settings</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, pt: 1 }}>
|
||||||
|
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
<FormControlLabel control={<Switch checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />} label="Enabled" />
|
||||||
|
<TextField
|
||||||
|
label="Primary name server"
|
||||||
|
value={props.nameserver || '(the router has no FQDN)'}
|
||||||
|
disabled
|
||||||
|
helperText="The router's FQDN: the SOA and the first NS of every domain."
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Secondary name server"
|
||||||
|
value={secondary}
|
||||||
|
autoFocus
|
||||||
|
autoComplete="off"
|
||||||
|
error={secondaryProblem !== ''}
|
||||||
|
helperText={secondaryProblem || 'The second NS of every domain; the addresses it resolves to may transfer the domains.'}
|
||||||
|
onChange={(event) => setSecondary(event.target.value.trim().toLowerCase())}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Public IP address"
|
||||||
|
value={publicAddress}
|
||||||
|
autoComplete="off"
|
||||||
|
error={addressProblem !== ''}
|
||||||
|
helperText={addressProblem || 'The router\'s public IP, for the records of the sites with Public IP. Empty: none.'}
|
||||||
|
onChange={(event) => setPublicAddress(event.target.value.trim().toLowerCase())}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" sx={{ gap: 2 }}>
|
||||||
|
<TextField select label="Listen" value={listen} helperText="The interfaces port 53 is open on." onChange={(event) => setListen(event.target.value as Listen)} sx={{ flex: 1 }}>
|
||||||
|
{LISTEN_CHOICES.map((item) => <MenuItem key={item} value={item}>{item}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="TTL (seconds)"
|
||||||
|
value={ttl}
|
||||||
|
autoComplete="off"
|
||||||
|
error={ttlProblem !== ''}
|
||||||
|
helperText={ttlProblem || 'The TTL of the domains without their own.'}
|
||||||
|
slotProps={{ htmlInput: { inputMode: 'numeric' } }}
|
||||||
|
onChange={(event) => setTtl(event.target.value.trim())}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={props.onCancel} disabled={saving}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={saving || blocked}>Save</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Refresh from '@mui/icons-material/Refresh'
|
||||||
|
import { Alert, Box, Button, Typography } from '@mui/material'
|
||||||
|
import { Pairs, Section, text } from './RouterPanel.tsx'
|
||||||
|
import Toolbar from './Toolbar.tsx'
|
||||||
|
import { DETAILS_SHAPE, STATUS_SHAPE, request, type RouterDetails, type RouterStatus } from './api.ts'
|
||||||
|
import { useRead } from './useRead.ts'
|
||||||
|
|
||||||
|
/** Reads the router's /status/full and /config. */
|
||||||
|
const readDocker = async () => {
|
||||||
|
const [status, details] = await Promise.all([
|
||||||
|
request<RouterStatus>('GET', '/status/full', STATUS_SHAPE),
|
||||||
|
request<RouterDetails>('GET', '/config', DETAILS_SHAPE),
|
||||||
|
])
|
||||||
|
return { status, details }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The state of Docker on the router: whether it is installed, the state of docker.service and of the router's Docker
|
||||||
|
* rules, from /status/full, and the ports exposed on the external interface and the rules of the VPN-ROUTER-DOCKER
|
||||||
|
* chain, from /config.
|
||||||
|
*/
|
||||||
|
export default function DockerPanel() {
|
||||||
|
const { data, error, busy, refresh } = useRead(readDocker)
|
||||||
|
const status = data?.status
|
||||||
|
const details = data?.details
|
||||||
|
const docker = status?.docker
|
||||||
|
return (
|
||||||
|
<Box component="section">
|
||||||
|
<Toolbar description="Docker and its firewall rules.">
|
||||||
|
<Button variant="outlined" startIcon={<Refresh />} disabled={busy} onClick={() => void refresh()}>Refresh</Button>
|
||||||
|
</Toolbar>
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
{status && details && (
|
||||||
|
<Box sx={{ display: 'grid', gap: 2, gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' } }}>
|
||||||
|
<Section title="Status">
|
||||||
|
<Pairs
|
||||||
|
rows={[
|
||||||
|
['Docker', docker === undefined ? 'unknown' : docker.installed ? 'installed' : 'not installed'],
|
||||||
|
['docker.service', text(docker?.service)],
|
||||||
|
['Docker rules', text(docker?.rules)],
|
||||||
|
['Exposed ports', text(details.docker.exposed_ports)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section title="Rules of VPN-ROUTER-DOCKER">
|
||||||
|
{details.docker.rules === null
|
||||||
|
? <Typography variant="body2" color="text.secondary">Not available.</Typography>
|
||||||
|
: (
|
||||||
|
<Box component="pre" sx={{ m: 0, overflow: 'auto', fontFamily: 'monospace', fontSize: 12 }}>
|
||||||
|
{details.docker.rules.length === 0 ? '(none)' : details.docker.rules.join('\n')}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import Close from '@mui/icons-material/Close'
|
||||||
|
import MapIcon from '@mui/icons-material/Map'
|
||||||
|
import MapOutlined from '@mui/icons-material/MapOutlined'
|
||||||
|
import Save from '@mui/icons-material/Save'
|
||||||
|
import { Alert, Box, Button, CircularProgress, IconButton, Stack, Tab, Tabs, Tooltip, Typography } from '@mui/material'
|
||||||
|
import type * as Monaco from 'monaco-editor/editor'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import ConfirmDialog from './ConfirmDialog.tsx'
|
||||||
|
import { ApiError, EDITOR_CONTENT_SHAPE, EDITOR_SAVED_SHAPE, request, type EditorContent, type EditorFile } from './api.ts'
|
||||||
|
|
||||||
|
/** The fonts of VS Code's editor on macOS, Windows and Linux. */
|
||||||
|
const FONT = "Menlo, Monaco, Consolas, 'Droid Sans Mono', 'DejaVu Sans Mono', 'Courier New', monospace"
|
||||||
|
|
||||||
|
/** The localStorage key of whether the editor's minimap is hidden. */
|
||||||
|
const MINIMAP_KEY = 'vpn-router-manager.editor-minimap'
|
||||||
|
|
||||||
|
/** What the page knows of an open file: its version on the router, whether it has unsaved changes, and its trouble. */
|
||||||
|
type FileState = { version: string | null; dirty: boolean; saving: boolean; conflict: boolean; error: string | null }
|
||||||
|
|
||||||
|
/** The router's path of a file of the editor. */
|
||||||
|
function pathOf(file: EditorFile) {
|
||||||
|
return `/shell/editor/${encodeURIComponent(file.session)}/${encodeURIComponent(file.id)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The editor of the Shell page: a tab for each file the shell opened with vre, its name on the tab, its path as the
|
||||||
|
* tab's tooltip, a dot while it has unsaved changes and a close icon, above a Monaco editor of the file chosen, with
|
||||||
|
* the syntax colours of the language of its extension and the light or dark theme of the page. Monaco is loaded when
|
||||||
|
* the panel first shows. The minimap button shows or hides the editor's minimap, kept in localStorage.
|
||||||
|
*
|
||||||
|
* Each file is read from the router when it opens. Save, or Ctrl+S, writes the editor's text with the version it
|
||||||
|
* replaces; when the file changed on the router meanwhile, Reload takes the router's text and Overwrite writes over
|
||||||
|
* it. Closing a tab with unsaved changes asks first; a closed tab is closed on the router too, and onClosed tells the
|
||||||
|
* shell. Files no longer in files, as when the shell ended, are dropped.
|
||||||
|
*/
|
||||||
|
export default function EditorPanel(props: { files: EditorFile[]; scheme: 'light' | 'dark'; onClosed: (id: string) => void }) {
|
||||||
|
const { files, scheme, onClosed } = props
|
||||||
|
const [monaco, setMonaco] = useState<typeof Monaco | null>(null)
|
||||||
|
const [failed, setFailed] = useState<string | null>(null)
|
||||||
|
const [states, setStates] = useState<Record<string, FileState>>({})
|
||||||
|
const [active, setActive] = useState<string | null>(null)
|
||||||
|
const [closing, setClosing] = useState<EditorFile | null>(null)
|
||||||
|
const [minimap, setMinimap] = useState(() => window.localStorage.getItem(MINIMAP_KEY) !== 'hidden')
|
||||||
|
const container = useRef<HTMLDivElement>(null)
|
||||||
|
const editor = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null)
|
||||||
|
const models = useRef(new Map<string, { model: Monaco.editor.ITextModel; saved: number }>())
|
||||||
|
const activeRef = useRef(active)
|
||||||
|
const saveRef = useRef<(id: string) => void>(() => undefined)
|
||||||
|
|
||||||
|
const change = useCallback((id: string, next: Partial<FileState>) => {
|
||||||
|
setStates((current) => (current[id] ? { ...current, [id]: { ...current[id], ...next } } : current))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
import('./monaco.ts').then((loaded) => setMonaco(loaded.default), (caught: Error) => setFailed(caught.message))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// One editor shows the model of the file chosen; Ctrl+S saves it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!monaco || !container.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const created = monaco.editor.create(container.current, { automaticLayout: true, fontFamily: FONT, fontSize: 14, model: null })
|
||||||
|
created.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||||
|
if (activeRef.current) {
|
||||||
|
saveRef.current(activeRef.current)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
editor.current = created
|
||||||
|
const owned = models.current
|
||||||
|
return () => {
|
||||||
|
created.dispose()
|
||||||
|
editor.current = null
|
||||||
|
owned.forEach(({ model }) => model.dispose())
|
||||||
|
owned.clear()
|
||||||
|
}
|
||||||
|
}, [monaco])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
monaco?.editor.setTheme(scheme === 'dark' ? 'vs-dark' : 'vs')
|
||||||
|
}, [monaco, scheme])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
editor.current?.updateOptions({ minimap: { enabled: minimap } })
|
||||||
|
}, [monaco, minimap])
|
||||||
|
|
||||||
|
/** Shows or hides the minimap, kept in localStorage. */
|
||||||
|
function toggleMinimap() {
|
||||||
|
window.localStorage.setItem(MINIMAP_KEY, minimap ? 'hidden' : 'shown')
|
||||||
|
setMinimap(!minimap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new file is read and chosen; a file no longer open is dropped.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!monaco) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const open = new Set(files.map((file) => file.id))
|
||||||
|
for (const [id, { model }] of models.current) {
|
||||||
|
if (!open.has(id)) {
|
||||||
|
model.dispose()
|
||||||
|
models.current.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const file of files) {
|
||||||
|
if (models.current.has(file.id)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const model = monaco.editor.createModel('', undefined, monaco.Uri.file(file.path).with({ query: file.id }))
|
||||||
|
const entry = { model, saved: model.getAlternativeVersionId() }
|
||||||
|
models.current.set(file.id, entry)
|
||||||
|
model.onDidChangeContent(() => change(file.id, { dirty: model.getAlternativeVersionId() !== entry.saved }))
|
||||||
|
setStates((current) => ({ ...current, [file.id]: { version: null, dirty: false, saving: false, conflict: false, error: null } }))
|
||||||
|
setActive(file.id)
|
||||||
|
request<EditorContent>('GET', pathOf(file), EDITOR_CONTENT_SHAPE).then((answer) => {
|
||||||
|
model.setValue(answer.content)
|
||||||
|
entry.saved = model.getAlternativeVersionId()
|
||||||
|
change(file.id, { version: answer.version, dirty: false })
|
||||||
|
}, (caught: Error) => change(file.id, { error: caught.message }))
|
||||||
|
}
|
||||||
|
}, [monaco, files, change])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeRef.current = active
|
||||||
|
editor.current?.setModel(active ? models.current.get(active)?.model ?? null : null)
|
||||||
|
editor.current?.focus()
|
||||||
|
}, [active, monaco])
|
||||||
|
|
||||||
|
/** Writes the editor's text of the file; with overwrite, over whatever the router has now. */
|
||||||
|
const save = useCallback(async (id: string, overwrite = false) => {
|
||||||
|
const file = files.find((item) => item.id === id)
|
||||||
|
const entry = models.current.get(id)
|
||||||
|
const state = states[id]
|
||||||
|
if (!file || !entry || !state || state.saving || (state.version === null && !overwrite)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
change(id, { saving: true, error: null })
|
||||||
|
try {
|
||||||
|
const version = overwrite ? (await request<EditorContent>('GET', pathOf(file), EDITOR_CONTENT_SHAPE)).version : state.version
|
||||||
|
const alternative = entry.model.getAlternativeVersionId()
|
||||||
|
const answer = await request<{ version: string }>('PUT', pathOf(file), EDITOR_SAVED_SHAPE, { content: entry.model.getValue(), version })
|
||||||
|
entry.saved = alternative
|
||||||
|
change(id, { version: answer.version, saving: false, conflict: false, dirty: entry.model.getAlternativeVersionId() !== alternative })
|
||||||
|
} catch (caught) {
|
||||||
|
const conflict = caught instanceof ApiError && caught.status === 409
|
||||||
|
change(id, { saving: false, conflict, error: conflict ? null : (caught as Error).message })
|
||||||
|
}
|
||||||
|
}, [files, states, change])
|
||||||
|
useEffect(() => {
|
||||||
|
saveRef.current = (id) => void save(id)
|
||||||
|
}, [save])
|
||||||
|
|
||||||
|
/** Replaces the editor's text of the file with the router's. */
|
||||||
|
async function reload(file: EditorFile) {
|
||||||
|
const entry = models.current.get(file.id)
|
||||||
|
try {
|
||||||
|
const answer = await request<EditorContent>('GET', pathOf(file), EDITOR_CONTENT_SHAPE)
|
||||||
|
if (entry) {
|
||||||
|
entry.model.setValue(answer.content)
|
||||||
|
entry.saved = entry.model.getAlternativeVersionId()
|
||||||
|
}
|
||||||
|
change(file.id, { version: answer.version, dirty: false, conflict: false, error: null })
|
||||||
|
} catch (caught) {
|
||||||
|
change(file.id, { error: (caught as Error).message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the file here and on the router, choosing the tab beside it. */
|
||||||
|
function close(file: EditorFile) {
|
||||||
|
setClosing(null)
|
||||||
|
void request('DELETE', pathOf(file), () => true).catch(() => undefined)
|
||||||
|
if (active === file.id) {
|
||||||
|
const others = files.filter((item) => item.id !== file.id)
|
||||||
|
const index = files.findIndex((item) => item.id === file.id)
|
||||||
|
setActive(others[Math.min(index, others.length - 1)]?.id ?? null)
|
||||||
|
}
|
||||||
|
onClosed(file.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = files.find((file) => file.id === active) ?? null
|
||||||
|
const state = current ? states[current.id] : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, borderBottom: 1, borderColor: 'divider', mb: 1 }}>
|
||||||
|
<Tabs value={current ? current.id : false} onChange={(_event, id: string) => setActive(id)} variant="scrollable" sx={{ flex: '1 1 0', minWidth: 0 }}>
|
||||||
|
{files.map((file) => (
|
||||||
|
<Tab
|
||||||
|
key={file.id}
|
||||||
|
value={file.id}
|
||||||
|
sx={{ textTransform: 'none', minHeight: 40, py: 0 }}
|
||||||
|
label={
|
||||||
|
<Tooltip title={file.path}>
|
||||||
|
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<span>{file.path.split('/').pop() || file.path}</span>
|
||||||
|
{states[file.id]?.dirty && <Box component="span" aria-label="unsaved changes" sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'text.primary' }} />}
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
role="button"
|
||||||
|
aria-label={`close ${file.path}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (states[file.id]?.dirty) {
|
||||||
|
setClosing(file)
|
||||||
|
} else {
|
||||||
|
close(file)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{ display: 'inline-flex', borderRadius: 1, '&:hover': { bgcolor: 'action.hover' } }}
|
||||||
|
>
|
||||||
|
<Close sx={{ fontSize: 16 }} />
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
<Tooltip title={minimap ? 'Hide the minimap' : 'Show the minimap'}>
|
||||||
|
<IconButton size="small" aria-label="minimap" aria-pressed={minimap} onClick={toggleMinimap} sx={{ flexShrink: 0 }}>
|
||||||
|
{minimap ? <MapIcon /> : <MapOutlined />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Save />}
|
||||||
|
disabled={!current || !state?.dirty || state.saving || state.version === null}
|
||||||
|
onClick={() => current && void save(current.id)}
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
{failed && <Alert severity="error" sx={{ mb: 1 }}>The editor cannot be loaded: {failed}</Alert>}
|
||||||
|
{state?.error && <Alert severity="error" sx={{ mb: 1, whiteSpace: 'pre-line' }}>{state.error}</Alert>}
|
||||||
|
{current && state?.conflict && (
|
||||||
|
<Alert
|
||||||
|
severity="warning"
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
action={
|
||||||
|
<>
|
||||||
|
<Button color="inherit" size="small" onClick={() => void reload(current)}>Reload</Button>
|
||||||
|
<Button color="inherit" size="small" onClick={() => void save(current.id, true)}>Overwrite</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{`${current.path} changed on the router since it was read.`}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Box sx={{ position: 'relative', height: 'calc(100vh - 280px)', minHeight: 280, border: 1, borderColor: 'divider', borderRadius: 1, overflow: 'hidden' }}>
|
||||||
|
{!monaco && !failed && (
|
||||||
|
<Stack sx={{ position: 'absolute', inset: 0, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
{current && state?.version === null && !state.error && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ position: 'absolute', top: 8, right: 16, zIndex: 1 }}>Reading...</Typography>
|
||||||
|
)}
|
||||||
|
<Box ref={container} sx={{ width: '100%', height: '100%' }} />
|
||||||
|
</Box>
|
||||||
|
{closing && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Close without saving"
|
||||||
|
message={`${closing.path} has changes that are not saved. Closing it drops them.`}
|
||||||
|
confirmLabel="Close"
|
||||||
|
destructive
|
||||||
|
onConfirm={() => close(closing)}
|
||||||
|
onCancel={() => setClosing(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField, Typography } from '@mui/material'
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { EKU_NAME_PATTERN, OID_PATTERN, type Pki } from './api.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog that adds a custom extended key usage, or with name changes the OID of one: its name, fixed once it exists,
|
||||||
|
* by which templates give it, and its OID in dotted form. onSave gets the name and the OID; the dialog closes when it
|
||||||
|
* succeeds and shows the router's message when it fails.
|
||||||
|
*/
|
||||||
|
export default function EkuDialog(props: { pki: Pki; name?: string; onSave: (name: string, oid: string) => Promise<void>; onCancel: () => void }) {
|
||||||
|
const [name, setName] = useState(props.name ?? '')
|
||||||
|
const [oid, setOid] = useState(props.name !== undefined ? props.pki.extended_key_usages[props.name].oid : '')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const nameError = props.name !== undefined ? ''
|
||||||
|
: !EKU_NAME_PATTERN.test(name.trim()) ? 'Up to 31 lower case letters, digits, dashes and underscores, starting with a letter.'
|
||||||
|
: props.pki.extended_key_usages[name.trim()] !== undefined ? 'An extended key usage has this name.' : ''
|
||||||
|
const oidError = OID_PATTERN.test(oid.trim()) ? '' : 'An OID in dotted form, such as 1.3.6.1.4.1.311.20.2.2.'
|
||||||
|
const blocked = nameError !== '' || oidError !== ''
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (blocked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await props.onSave(name.trim(), oid.trim())
|
||||||
|
} catch (caught) {
|
||||||
|
setError((caught as Error).message)
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
|
||||||
|
<DialogTitle>{props.name === undefined ? 'Add extended key usage' : `Edit extended key usage ${props.name}`}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, pt: 1 }}>
|
||||||
|
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Templates give an extended key usage by its name; the certificates carry its OID.
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
disabled={props.name !== undefined}
|
||||||
|
autoComplete="off"
|
||||||
|
error={nameError !== ''}
|
||||||
|
helperText={nameError}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="OID"
|
||||||
|
value={oid}
|
||||||
|
autoComplete="off"
|
||||||
|
error={oid !== '' && oidError !== ''}
|
||||||
|
helperText={oidError}
|
||||||
|
onChange={(event) => setOid(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={props.onCancel}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={saving || blocked}>Save</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Alert, Autocomplete, Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField } from '@mui/material'
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import type { Group } from './api.ts'
|
||||||
|
|
||||||
|
/** The group names vpn-router-group accepts, the same as user names. */
|
||||||
|
const NAME_PATTERN = /^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$/
|
||||||
|
|
||||||
|
/** The longest description vpn-router-group accepts. */
|
||||||
|
const DESCRIPTION_MAX = 256
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog that adds a group, or, given a group, edits it: its name, description and members, at least one, chosen
|
||||||
|
* from users. onSave sends them; the dialog closes when it succeeds and shows the router's message when it fails.
|
||||||
|
*/
|
||||||
|
export default function GroupDialog(props: {
|
||||||
|
group?: Group
|
||||||
|
users: string[]
|
||||||
|
onSave: (name: string, description: string, members: string[]) => Promise<void>
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const adding = props.group === undefined
|
||||||
|
const [name, setName] = useState(props.group?.name ?? '')
|
||||||
|
const [description, setDescription] = useState(props.group?.description ?? '')
|
||||||
|
const [members, setMembers] = useState<string[]>(props.group?.members ?? [])
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const nameProblem = name !== '' && !NAME_PATTERN.test(name)
|
||||||
|
? 'Up to 32 lower case letters, digits and dashes, starting with a letter and not ending with a dash.'
|
||||||
|
: ''
|
||||||
|
const descriptionProblem = description.trim().length > DESCRIPTION_MAX ? `At most ${DESCRIPTION_MAX} characters.` : ''
|
||||||
|
const blocked = name === '' || nameProblem !== '' || descriptionProblem !== '' || members.length === 0
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (blocked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await props.onSave(name, description.trim(), members)
|
||||||
|
} catch (caught) {
|
||||||
|
setError((caught as Error).message)
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="xs" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
|
||||||
|
<DialogTitle>{adding ? 'Add group' : `Edit group ${props.group?.name}`}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, pt: 1 }}>
|
||||||
|
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
autoFocus
|
||||||
|
autoComplete="off"
|
||||||
|
error={nameProblem !== ''}
|
||||||
|
helperText={nameProblem}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Description"
|
||||||
|
value={description}
|
||||||
|
autoComplete="off"
|
||||||
|
error={descriptionProblem !== ''}
|
||||||
|
helperText={descriptionProblem}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
options={props.users}
|
||||||
|
value={members}
|
||||||
|
onChange={(_event, next) => setMembers(next)}
|
||||||
|
renderInput={(params) => <TextField {...params} label="Members" helperText={members.length === 0 ? 'Select at least one member.' : undefined} />}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={props.onCancel}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={saving || blocked}>
|
||||||
|
{adding ? 'Add' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import Add from '@mui/icons-material/Add'
|
||||||
|
import Delete from '@mui/icons-material/Delete'
|
||||||
|
import Edit from '@mui/icons-material/Edit'
|
||||||
|
import Refresh from '@mui/icons-material/Refresh'
|
||||||
|
import {
|
||||||
|
Alert, Box, Button, Chip, IconButton, Stack, Table, TableBody, TableCell, TableHead, TableRow, Tooltip,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import ConfirmDialog, { type ConfirmDialogProps } from './ConfirmDialog.tsx'
|
||||||
|
import GroupDialog from './GroupDialog.tsx'
|
||||||
|
import { Section } from './RouterPanel.tsx'
|
||||||
|
import SearchField, { matches } from './SearchField.tsx'
|
||||||
|
import Toolbar from './Toolbar.tsx'
|
||||||
|
import { ApiError, GROUPS_SHAPE, GROUP_SHAPE, REMOVED_SHAPE, SERVICE_ACCOUNTS_SHAPE, USERS_SHAPE, request, type Group, type User } from './api.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The groups of the router's directory: list with their descriptions and members, add, edit the name, the
|
||||||
|
* description and the members, and remove. A group always keeps at least one member; the members offered are the users
|
||||||
|
* and the service accounts but the internal ones.
|
||||||
|
*/
|
||||||
|
export default function GroupsPanel() {
|
||||||
|
const [groups, setGroups] = useState<Group[] | null>(null)
|
||||||
|
const [users, setUsers] = useState<string[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [unavailable, setUnavailable] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<Group | null>(null)
|
||||||
|
const [confirm, setConfirm] = useState<ConfirmDialogProps | null>(null)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const [found, people, services] = await Promise.all([
|
||||||
|
request<{ groups: Group[] }>('GET', '/groups', GROUPS_SHAPE),
|
||||||
|
request<{ users: User[] }>('GET', '/users', USERS_SHAPE),
|
||||||
|
request<{ service_accounts: User[] }>('GET', '/service-accounts', SERVICE_ACCOUNTS_SHAPE),
|
||||||
|
])
|
||||||
|
setGroups(found.groups)
|
||||||
|
setUsers([...people.users, ...services.service_accounts.filter((account) => !account.internal)].map((user) => user.name).sort())
|
||||||
|
setError(null)
|
||||||
|
setUnavailable(false)
|
||||||
|
} catch (caught) {
|
||||||
|
const failure = caught as ApiError
|
||||||
|
setGroups(null)
|
||||||
|
setUnavailable(failure.status === 404)
|
||||||
|
setError(failure.message)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh()
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
/** Runs a change of a group, then reads the list again; a failure is shown above the list. */
|
||||||
|
async function change(work: () => Promise<unknown>) {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await work()
|
||||||
|
setError(null)
|
||||||
|
} catch (caught) {
|
||||||
|
setError((caught as Error).message)
|
||||||
|
}
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(group: Group) {
|
||||||
|
setConfirm({
|
||||||
|
title: 'Remove group',
|
||||||
|
message: `Remove ${group.name}? Its members are kept.`,
|
||||||
|
confirmLabel: 'Remove',
|
||||||
|
destructive: true,
|
||||||
|
onCancel: () => setConfirm(null),
|
||||||
|
onConfirm: () => {
|
||||||
|
setConfirm(null)
|
||||||
|
void change(() => request('DELETE', `/groups/${group.name}`, REMOVED_SHAPE))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const shown = (groups ?? []).filter((group) => matches(search, [group.name, group.description, ...group.members]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box component="section">
|
||||||
|
<Toolbar description="User groups.">
|
||||||
|
<SearchField value={search} onChange={setSearch} />
|
||||||
|
<Button variant="contained" startIcon={<Add />} disabled={busy || groups === null || users.length === 0} onClick={() => setAdding(true)}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" startIcon={<Refresh />} disabled={busy} onClick={() => void refresh()}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
{error && <Alert severity={unavailable ? 'info' : 'error'} sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
|
||||||
|
{groups && (
|
||||||
|
<Section title="Groups">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Group</TableCell>
|
||||||
|
<TableCell>Description</TableCell>
|
||||||
|
<TableCell>Members</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{shown.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} sx={{ color: 'text.secondary' }}>None.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{shown.map((group) => (
|
||||||
|
<TableRow key={group.name} hover>
|
||||||
|
<TableCell>{group.name}</TableCell>
|
||||||
|
<TableCell>{group.description || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Stack direction="row" sx={{ gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
{group.members.map((member) => <Chip key={member} size="small" label={member} />)}
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
|
||||||
|
<Tooltip title="Edit">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" disabled={busy} onClick={() => setEditing(group)}><Edit fontSize="small" /></IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Remove">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" color="error" disabled={busy} onClick={() => remove(group)}><Delete fontSize="small" /></IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{adding && (
|
||||||
|
<GroupDialog
|
||||||
|
users={users}
|
||||||
|
onCancel={() => setAdding(false)}
|
||||||
|
onSave={async (name, description, members) => {
|
||||||
|
await request('POST', '/groups', GROUP_SHAPE, { name, description, members })
|
||||||
|
setAdding(false)
|
||||||
|
await refresh()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editing && (
|
||||||
|
<GroupDialog
|
||||||
|
group={editing}
|
||||||
|
users={users}
|
||||||
|
onCancel={() => setEditing(null)}
|
||||||
|
onSave={async (name, description, members) => {
|
||||||
|
// Only what changed is sent: the changed properties, then each member added and each member taken out,
|
||||||
|
// the additions first so that the group never loses its last member; nothing when nothing changed.
|
||||||
|
const properties: { name?: string; description?: string } = {}
|
||||||
|
if (name !== editing.name) {
|
||||||
|
properties.name = name
|
||||||
|
}
|
||||||
|
if (description !== editing.description) {
|
||||||
|
properties.description = description
|
||||||
|
}
|
||||||
|
const added = members.filter((member) => !editing.members.includes(member))
|
||||||
|
const removed = editing.members.filter((member) => !members.includes(member))
|
||||||
|
if (Object.keys(properties).length > 0) {
|
||||||
|
await request('PUT', `/groups/${editing.name}`, GROUP_SHAPE, properties)
|
||||||
|
}
|
||||||
|
for (const user of added) {
|
||||||
|
await request('POST', `/groups/${name}/members`, GROUP_SHAPE, { user })
|
||||||
|
}
|
||||||
|
for (const user of removed) {
|
||||||
|
await request('DELETE', `/groups/${name}/members/${user}`, GROUP_SHAPE)
|
||||||
|
}
|
||||||
|
setEditing(null)
|
||||||
|
if (Object.keys(properties).length > 0 || added.length > 0 || removed.length > 0) {
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{confirm && <ConfirmDialog {...confirm} />}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import ArrowBack from '@mui/icons-material/ArrowBack'
|
||||||
|
import Close from '@mui/icons-material/Close'
|
||||||
|
import Home from '@mui/icons-material/Home'
|
||||||
|
import { Alert, Box, Drawer, IconButton, Stack, Tooltip, Typography, useColorScheme } from '@mui/material'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
import darkCss from 'github-markdown-css/github-markdown-dark.css?inline'
|
||||||
|
import lightCss from 'github-markdown-css/github-markdown-light.css?inline'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import { useEffect, useRef, useState, type MouseEvent } from 'react'
|
||||||
|
import { SESSION_EXPIRED } from './api.ts'
|
||||||
|
|
||||||
|
/** The width of the panel, in pixels, by which the page's right edge moves left while it is open. */
|
||||||
|
export const HELP_WIDTH = 420
|
||||||
|
|
||||||
|
/** The first document of the help. */
|
||||||
|
export const HELP_HOME = 'index.md'
|
||||||
|
|
||||||
|
/** The anchor name of a heading's text, unique within the document, as serve-markdown makes it. */
|
||||||
|
function headingSlug(text: string, taken: Set<string>) {
|
||||||
|
const base = text.toLowerCase().trim().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '') || 'section'
|
||||||
|
let slug = base
|
||||||
|
for (let suffix = 2; taken.has(slug); suffix += 1) {
|
||||||
|
slug = `${base}-${suffix}`
|
||||||
|
}
|
||||||
|
taken.add(slug)
|
||||||
|
return slug
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names the headings of the rendered document and resolves its links and pictures against the document's folder in /docs,
|
||||||
|
* as serve-markdown's resolveLinks does: a link to another document of the help gets data-doc, the document's path in /docs
|
||||||
|
* with its anchor, which the panel opens; an anchor gets data-anchor; a link to another host opens in a new tab; a picture
|
||||||
|
* of /docs keeps its path.
|
||||||
|
*/
|
||||||
|
function resolve(content: HTMLElement, path: string) {
|
||||||
|
const taken = new Set<string>()
|
||||||
|
content.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6').forEach((heading) => {
|
||||||
|
heading.id = headingSlug(heading.textContent ?? '', taken)
|
||||||
|
})
|
||||||
|
const base = new URL(`/docs/${path}`, window.location.origin)
|
||||||
|
content.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((link) => {
|
||||||
|
const href = link.getAttribute('href') ?? ''
|
||||||
|
if (href.startsWith('#')) {
|
||||||
|
link.dataset.anchor = href.slice(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const target = new URL(href, base)
|
||||||
|
if (target.origin !== window.location.origin) {
|
||||||
|
link.target = '_blank'
|
||||||
|
link.rel = 'noopener noreferrer'
|
||||||
|
} else if (target.pathname.startsWith('/docs/') && target.pathname.endsWith('.md')) {
|
||||||
|
link.dataset.doc = decodeURIComponent(target.pathname.slice('/docs/'.length)) + target.hash
|
||||||
|
}
|
||||||
|
})
|
||||||
|
content.querySelectorAll<HTMLImageElement>('img[src]').forEach((picture) => {
|
||||||
|
const target = new URL(picture.getAttribute('src') ?? '', base)
|
||||||
|
if (target.origin === window.location.origin) {
|
||||||
|
picture.src = target.pathname
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The help: a panel on the right, under which the page moves left, that renders the Markdown documents of /docs in the
|
||||||
|
* browser with marked and DOMPurify, styled with github-markdown-css in the Manager's light or dark scheme. It opens at
|
||||||
|
* topic, and goes to topic again when topic changes; a link to another document opens it in the panel, Back returns to
|
||||||
|
* the one before and Home opens HELP_HOME. Documents link to each other and to their SVG and PNG pictures by paths
|
||||||
|
* relative to their folder.
|
||||||
|
*/
|
||||||
|
export default function HelpPanel(props: { open: boolean; topic: string; onClose: () => void }) {
|
||||||
|
const { mode, systemMode } = useColorScheme()
|
||||||
|
const scheme = (mode === 'system' ? systemMode : mode) === 'dark' ? 'dark' : 'light'
|
||||||
|
const [history, setHistory] = useState<string[]>([props.topic])
|
||||||
|
const [title, setTitle] = useState('Help')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const content = useRef<HTMLDivElement>(null)
|
||||||
|
const current = history[history.length - 1]
|
||||||
|
const [path, anchor] = current.split('#')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHistory([props.topic])
|
||||||
|
}, [props.topic])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!props.open) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let stale = false
|
||||||
|
fetch(`/docs/${path}`)
|
||||||
|
.then(async (response) => {
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.dispatchEvent(new Event(SESSION_EXPIRED))
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.status === 404 ? `${path} is not in the help.` : `/docs/${path} failed (${response.status}).`)
|
||||||
|
}
|
||||||
|
return response.text()
|
||||||
|
})
|
||||||
|
.then((text) => {
|
||||||
|
if (stale || !content.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content.current.innerHTML = DOMPurify.sanitize(marked.parse(text, { gfm: true, async: false }))
|
||||||
|
resolve(content.current, path)
|
||||||
|
setTitle(content.current.querySelector('h1')?.textContent || 'Help')
|
||||||
|
setError(null)
|
||||||
|
const target = anchor ? content.current.querySelector(`#${CSS.escape(anchor)}`) : null
|
||||||
|
if (target) {
|
||||||
|
target.scrollIntoView()
|
||||||
|
} else {
|
||||||
|
content.current.parentElement?.scrollTo(0, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((caught: Error) => {
|
||||||
|
if (!stale) {
|
||||||
|
setError(caught.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
stale = true
|
||||||
|
}
|
||||||
|
}, [props.open, path, anchor])
|
||||||
|
|
||||||
|
/** Opens a link to another document in the panel and scrolls to an anchor; other links go their own way. */
|
||||||
|
function follow(event: MouseEvent<HTMLDivElement>) {
|
||||||
|
const link = (event.target as HTMLElement).closest('a')
|
||||||
|
if (!link) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (link.dataset.doc) {
|
||||||
|
event.preventDefault()
|
||||||
|
setHistory((previous) => [...previous, link.dataset.doc as string])
|
||||||
|
} else if (link.dataset.anchor) {
|
||||||
|
event.preventDefault()
|
||||||
|
content.current?.querySelector(`#${CSS.escape(link.dataset.anchor)}`)?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
variant="persistent"
|
||||||
|
anchor="right"
|
||||||
|
open={props.open}
|
||||||
|
slotProps={{ paper: { sx: { width: { xs: '100vw', sm: HELP_WIDTH }, boxSizing: 'border-box' } } }}
|
||||||
|
>
|
||||||
|
<style>{scheme === 'dark' ? darkCss : lightCss}</style>
|
||||||
|
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.5, px: 1, py: 1, borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<Tooltip title="Back">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" aria-label="back" disabled={history.length < 2} onClick={() => setHistory((previous) => previous.slice(0, -1))}>
|
||||||
|
<ArrowBack fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Contents">
|
||||||
|
<IconButton size="small" aria-label="contents" onClick={() => setHistory((previous) => [...previous, HELP_HOME])}>
|
||||||
|
<Home fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Typography variant="subtitle1" noWrap sx={{ flex: 1, fontWeight: 700, ml: 1 }}>{title}</Typography>
|
||||||
|
<Tooltip title="Close the help">
|
||||||
|
<IconButton size="small" aria-label="close the help" onClick={props.onClose}>
|
||||||
|
<Close fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', p: 2 }}>
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
<Box
|
||||||
|
ref={content}
|
||||||
|
className="markdown-body"
|
||||||
|
onClick={follow}
|
||||||
|
sx={{ bgcolor: 'transparent !important', fontSize: 14, '& img': { maxWidth: '100%' } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, Stack, Switch, Typography } from '@mui/material'
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import type { HttpsSettings } from './api.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog that switches the CA of the certificates Caddy obtains on the router: Let's Encrypt's staging environment,
|
||||||
|
* https.acme_staging, or Caddy's default CAs. onSave sends acme_staging; the dialog closes when it succeeds and shows
|
||||||
|
* the router's message when it fails.
|
||||||
|
*/
|
||||||
|
export default function HttpsSettingsDialog(props: {
|
||||||
|
current: HttpsSettings
|
||||||
|
onSave: (settings: { acme_staging: boolean }) => Promise<void>
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const [staging, setStaging] = useState(props.current.acme_staging)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await props.onSave({ acme_staging: staging })
|
||||||
|
} catch (caught) {
|
||||||
|
setError((caught as Error).message)
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
|
||||||
|
<DialogTitle>HTTPS settings</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, pt: 1 }}>
|
||||||
|
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
<FormControlLabel control={<Switch checked={staging} onChange={(event) => setStaging(event.target.checked)} />} label="Let's Encrypt staging CA" />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{staging
|
||||||
|
? 'Caddy takes every certificate from Let\'s Encrypt\'s staging environment, the web sites\' included. Its rate limits are far higher, but no browser trusts its certificates: for routers rebuilt often, for tests only.'
|
||||||
|
: 'Caddy takes its certificates from Let\'s Encrypt, and from ZeroSSL when Let\'s Encrypt refuses, such as for its limit of 5 certificates a week for the same name.'}
|
||||||
|
</Typography>
|
||||||
|
{staging !== props.current.acme_staging && (
|
||||||
|
<Alert severity="info">The router runs its setup again, which applies the change.</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={props.onCancel} disabled={saving}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={saving || staging === props.current.acme_staging}>Save</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import UploadFile from '@mui/icons-material/UploadFile'
|
||||||
|
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Stack, TextField } from '@mui/material'
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog that takes a PEM text, pasted or read from a file, such as the certificate of an issuing CA signed by the
|
||||||
|
* offline root or a root CRL. begin is the PEM header the text must have. onSave sends the text; the dialog closes when
|
||||||
|
* it succeeds and shows the router's message when it fails.
|
||||||
|
*/
|
||||||
|
export default function ImportDialog(props: {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
label: string
|
||||||
|
begin: string
|
||||||
|
onSave: (text: string) => Promise<void>
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const problem = text.trim() !== '' && !text.includes(props.begin) ? `A PEM text with ${props.begin}.` : ''
|
||||||
|
const blocked = text.trim() === '' || problem !== ''
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (blocked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await props.onSave(text.trim())
|
||||||
|
} catch (caught) {
|
||||||
|
setError((caught as Error).message)
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
|
||||||
|
<DialogTitle>{props.title}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, pt: 1 }}>
|
||||||
|
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
<DialogContentText>{props.message}</DialogContentText>
|
||||||
|
<TextField
|
||||||
|
label={props.label}
|
||||||
|
value={text}
|
||||||
|
multiline
|
||||||
|
minRows={6}
|
||||||
|
maxRows={16}
|
||||||
|
autoFocus
|
||||||
|
autoComplete="off"
|
||||||
|
error={problem !== ''}
|
||||||
|
helperText={problem || 'Paste the PEM text, or read it from a file.'}
|
||||||
|
slotProps={{ htmlInput: { sx: { fontFamily: 'monospace', fontSize: 12 } } }}
|
||||||
|
onChange={(event) => setText(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button component="label" variant="outlined" startIcon={<UploadFile />} sx={{ alignSelf: 'flex-start' }}>
|
||||||
|
Read a file
|
||||||
|
<input hidden type="file" accept=".pem,.crt,.crl,.cer,.txt" onChange={(event) => void event.target.files?.[0]?.text().then(setText)} />
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={props.onCancel}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={saving || blocked}>Import</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import {
|
||||||
|
Alert, Autocomplete, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, Stack, Switch, TextField, Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { NAME_PATTERN, RESERVED, nameProblem } from './PkiSettingsDialog.tsx'
|
||||||
|
import { ROUTER_TEMPLATES, type Pki, type PkiIssuingCa } from './api.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog that adds a general-purpose issuing CA, or with name changes one: its name, fixed once it exists, its common
|
||||||
|
* name, the templates it issues and whether Caddy runs an ACME server for it. onSave gets the name and the options; the
|
||||||
|
* dialog closes when it succeeds and shows the router's message when it fails.
|
||||||
|
*/
|
||||||
|
export default function IssuingCaDialog(props: {
|
||||||
|
pki: Pki
|
||||||
|
name?: string
|
||||||
|
onSave: (name: string, values: PkiIssuingCa) => Promise<void>
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const existing = props.pki.status.authorities.find((authority) => authority.name === props.name)
|
||||||
|
const issuable = Object.keys(props.pki.templates).filter((name) => !(ROUTER_TEMPLATES as readonly string[]).includes(name))
|
||||||
|
const [name, setName] = useState(props.name ?? '')
|
||||||
|
const [commonName, setCommonName] = useState(existing?.common_name ?? '')
|
||||||
|
const [templates, setTemplates] = useState<string[]>(existing?.templates ?? (issuable.includes('server') ? ['server'] : []))
|
||||||
|
const [acme, setAcme] = useState(existing?.acme ?? false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const taken = props.pki.status.authorities.some((authority) => authority.name === name.trim())
|
||||||
|
const nameError = props.name !== undefined ? ''
|
||||||
|
: !NAME_PATTERN.test(name.trim()) || RESERVED.includes(name.trim()) ? 'Up to 31 lower case letters, digits and dashes, starting with a letter; not root, p2s or s2s.'
|
||||||
|
: taken ? 'An issuing CA has this name.' : ''
|
||||||
|
const commonNameError = nameProblem(commonName)
|
||||||
|
const templatesError = templates.length === 0 ? 'At least one template.' : ''
|
||||||
|
const blocked = nameError !== '' || commonNameError !== '' || templatesError !== ''
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (blocked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await props.onSave(name.trim(), { name: commonName.trim(), templates, acme })
|
||||||
|
} catch (caught) {
|
||||||
|
setError((caught as Error).message)
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
|
||||||
|
<DialogTitle>{props.name === undefined ? 'Add issuing CA' : `Edit issuing CA ${props.name}`}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, pt: 1 }}>
|
||||||
|
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{props.name === undefined
|
||||||
|
? 'The root signs the new CA at once while its key is on the router; otherwise the CA waits with a CSR for the offline root.'
|
||||||
|
: 'The common name applies when the CA is created; the templates and the ACME server apply at once.'}
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
disabled={props.name !== undefined}
|
||||||
|
autoComplete="off"
|
||||||
|
error={nameError !== ''}
|
||||||
|
helperText={nameError || 'Names its directory and its published files.'}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Common name"
|
||||||
|
value={commonName}
|
||||||
|
autoComplete="off"
|
||||||
|
error={commonNameError !== ''}
|
||||||
|
helperText={commonNameError}
|
||||||
|
onChange={(event) => setCommonName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
options={issuable}
|
||||||
|
value={templates}
|
||||||
|
onChange={(_event, next) => setTemplates(next)}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="Templates" error={templatesError !== ''} helperText={templatesError || 'The templates the CA issues.'} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={acme} onChange={(event) => setAcme(event.target.checked)} />}
|
||||||
|
label="ACME server (Caddy, needs HTTPS)"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={props.onCancel}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={saving || blocked}>Save</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Box } from '@mui/material'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
/** Lines of a log in a scrolling monospace box, or empty when there are none; with follow, scrolled to the last line whenever the lines change. */
|
||||||
|
export default function LogView(props: { lines: string[] | null; empty: string; maxHeight?: number; follow?: boolean }) {
|
||||||
|
const box = useRef<HTMLPreElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.follow && box.current) {
|
||||||
|
box.current.scrollTop = box.current.scrollHeight
|
||||||
|
}
|
||||||
|
}, [props.lines, props.follow])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box ref={box} component="pre" sx={{ m: 0, p: 1, maxHeight: props.maxHeight ?? 400, overflow: 'auto', fontFamily: 'monospace', fontSize: 12, bgcolor: 'action.hover' }}>
|
||||||
|
{props.lines && props.lines.length > 0 ? props.lines.join('\n') : props.empty}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import Refresh from '@mui/icons-material/Refresh'
|
||||||
|
import VerticalAlignBottom from '@mui/icons-material/VerticalAlignBottom'
|
||||||
|
import { Alert, Box, Button, MenuItem, Tab, Tabs, TextField, ToggleButton } from '@mui/material'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import LogView from './LogView.tsx'
|
||||||
|
import Toolbar from './Toolbar.tsx'
|
||||||
|
import { LOGS_SHAPE, LOG_LINES_SHAPE, request, type Logs } from './api.ts'
|
||||||
|
|
||||||
|
/** The numbers of lines offered, up to the router's most. */
|
||||||
|
const LINE_CHOICES = [100, 200, 500, 1000]
|
||||||
|
|
||||||
|
/** Seconds between the reads of a followed log. */
|
||||||
|
const FOLLOW_SECONDS = 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The router's logs, from its /logs: the Journal sub-tab shows the last lines of the journal of a unit the router names, or of
|
||||||
|
* the whole journal, and the Files sub-tab the last lines of a log file the router's web.log_files lists. The number of lines
|
||||||
|
* is chosen up to the router's most; a log is read when its unit, file or number of lines is chosen and with Refresh, and
|
||||||
|
* while Follow is on every FOLLOW_SECONDS, scrolled to its last line, as tail -f shows it.
|
||||||
|
*/
|
||||||
|
export default function LogsPanel() {
|
||||||
|
const [sources, setSources] = useState<Logs | null>(null)
|
||||||
|
const [kind, setKind] = useState<'journal' | 'file'>('journal')
|
||||||
|
const [unit, setUnit] = useState('')
|
||||||
|
const [file, setFile] = useState('')
|
||||||
|
const [count, setCount] = useState(200)
|
||||||
|
const [lines, setLines] = useState<string[] | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [follow, setFollow] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<Logs>('GET', '/logs', LOGS_SHAPE)
|
||||||
|
.then((found) => {
|
||||||
|
setSources(found)
|
||||||
|
setFile((current) => current || found.files[0] || '')
|
||||||
|
})
|
||||||
|
.catch((caught) => setError((caught as Error).message))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const read = useCallback(async () => {
|
||||||
|
if (kind === 'file' && !file) {
|
||||||
|
setLines(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
const query = kind === 'journal'
|
||||||
|
? `/logs/journal?lines=${count}${unit ? `&unit=${encodeURIComponent(unit)}` : ''}`
|
||||||
|
: `/logs/file?lines=${count}&path=${encodeURIComponent(file)}`
|
||||||
|
try {
|
||||||
|
setLines((await request<{ lines: string[] }>('GET', query, LOG_LINES_SHAPE)).lines)
|
||||||
|
setError(null)
|
||||||
|
} catch (caught) {
|
||||||
|
setLines(null)
|
||||||
|
setError((caught as Error).message)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}, [kind, unit, file, count])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sources) {
|
||||||
|
void read()
|
||||||
|
}
|
||||||
|
}, [sources, read])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!follow || !sources) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const timer = window.setInterval(() => void read(), FOLLOW_SECONDS * 1000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [follow, sources, read])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box component="section">
|
||||||
|
<Tabs value={kind} onChange={(_event, next: 'journal' | 'file') => setKind(next)} sx={{ mb: 2 }}>
|
||||||
|
<Tab value="journal" label="Journal" />
|
||||||
|
<Tab value="file" label="Files" />
|
||||||
|
</Tabs>
|
||||||
|
<Toolbar description={kind === 'journal' ? 'The last lines of the systemd journal.' : 'The last lines of a log file.'}>
|
||||||
|
{kind === 'journal' ? (
|
||||||
|
<TextField select size="small" label="Unit" value={unit} onChange={(event) => setUnit(event.target.value)} sx={{ minWidth: 240 }}>
|
||||||
|
<MenuItem value="">All units</MenuItem>
|
||||||
|
{(sources?.units ?? []).map((name) => <MenuItem key={name} value={name}>{name}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
label="File"
|
||||||
|
value={file}
|
||||||
|
disabled={!sources?.files.length}
|
||||||
|
onChange={(event) => setFile(event.target.value)}
|
||||||
|
sx={{ minWidth: 240 }}
|
||||||
|
>
|
||||||
|
{(sources?.files ?? []).map((name) => <MenuItem key={name} value={name}>{name}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
<TextField select size="small" label="Lines" value={count} onChange={(event) => setCount(Number(event.target.value))} sx={{ minWidth: 100 }}>
|
||||||
|
{LINE_CHOICES.filter((choice) => choice <= (sources?.max_lines ?? 0)).map((choice) => <MenuItem key={choice} value={choice}>{choice}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
<ToggleButton value="follow" size="small" color="primary" selected={follow} disabled={!sources} onChange={() => setFollow(!follow)}>
|
||||||
|
<VerticalAlignBottom fontSize="small" sx={{ mr: 0.5 }} />
|
||||||
|
Follow
|
||||||
|
</ToggleButton>
|
||||||
|
<Button variant="outlined" startIcon={<Refresh />} disabled={busy || follow || !sources} onClick={() => void read()}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Toolbar>
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
{kind === 'file' && sources && sources.files.length === 0 && <Alert severity="info" sx={{ mb: 2 }}>No log files are configured.</Alert>}
|
||||||
|
<LogView lines={lines} empty={busy ? '(reading)' : '(no lines)'} maxHeight={600} follow={follow} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import Refresh from '@mui/icons-material/Refresh'
|
||||||
|
import Settings from '@mui/icons-material/Settings'
|
||||||
|
import { Alert, Box, Button, Stack, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
|
||||||
|
import { LineChart } from '@mui/x-charts/LineChart'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import MetricsSettingsDialog from './MetricsSettingsDialog.tsx'
|
||||||
|
import { Board, BoardButtons, useBoard, type BoardPanel } from './PanelBoard.tsx'
|
||||||
|
import { bytes } from './RouterPanel.tsx'
|
||||||
|
import {
|
||||||
|
METRICS_RANGES, METRICS_SETTINGS_SHAPE, METRICS_SHAPE, request, type Aggregate, type MetricsHistory, type MetricsRange,
|
||||||
|
type MetricsSettings,
|
||||||
|
} from './api.ts'
|
||||||
|
import { useBrowserSettings } from './settings.ts'
|
||||||
|
|
||||||
|
|
||||||
|
/** The localStorage key of the tab's layout. */
|
||||||
|
const LAYOUT_KEY = 'vpn-router-manager.metrics-layout'
|
||||||
|
|
||||||
|
/** The localStorage key of the lines hidden by their legend item: chart id -> line labels. */
|
||||||
|
const HIDDEN_KEY = 'vpn-router-manager.metrics-hidden'
|
||||||
|
|
||||||
|
/** The hidden lines kept in localStorage, or none when there are none or they cannot be read. */
|
||||||
|
function readHidden(): Record<string, string[]> {
|
||||||
|
try {
|
||||||
|
const value: unknown = JSON.parse(window.localStorage.getItem(HIDDEN_KEY) ?? 'null')
|
||||||
|
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return Object.fromEntries(Object.entries(value).map(([chart, labels]) => [
|
||||||
|
chart, Array.isArray(labels) ? labels.filter((label): label is string => typeof label === 'string') : []]))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Hidden lines that cannot be read are shown.
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The label of each view, how a time on its axis is written, and the start of the span it shows, which ends at end, as the router's span. */
|
||||||
|
const RANGES: Record<MetricsRange, { label: string; format: Intl.DateTimeFormatOptions; start: (end: Date) => Date }> = {
|
||||||
|
hour: { label: 'Hour', format: { hour: '2-digit', minute: '2-digit' }, start: (end) => new Date(end.getTime() - 3600_000) },
|
||||||
|
day: { label: 'Day', format: { hour: '2-digit', minute: '2-digit' }, start: (end) => new Date(end.getTime() - 86400_000) },
|
||||||
|
month: { label: 'Month', format: { month: 'short', day: 'numeric' }, start: (end) => new Date(new Date(end).setMonth(end.getMonth() - 1)) },
|
||||||
|
year: { label: 'Year', format: { year: 'numeric', month: 'short' }, start: (end) => new Date(new Date(end).setFullYear(end.getFullYear() - 1)) },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The labels of the kinds of VPN clients the router counts. */
|
||||||
|
const CLIENT_KINDS: Record<string, string> = {
|
||||||
|
p2s: 'IKEv2',
|
||||||
|
openvpn: 'OpenVPN',
|
||||||
|
wireguard: 'WireGuard',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which value of a bucket the charts show: its average or its maximum. */
|
||||||
|
type Consolidation = 'avg' | 'max'
|
||||||
|
|
||||||
|
/** A line of a chart: its label and its values, null where a bucket has no data. */
|
||||||
|
type Line = { label: string; data: (number | null)[] }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The router's metrics as charts, for the last hour, day, month or year, as the router's /metrics/history answers them:
|
||||||
|
* CPU, memory, free disk space in bytes and in percent, the bytes per second of each interface, the VPN clients, the
|
||||||
|
* load averages, and, per site-to-site connection, the part of the time it was up and its bytes and packets per
|
||||||
|
* second; each bucket as its average or its maximum. The time axis spans the whole view, ending at the last read,
|
||||||
|
* so a part without data stays empty. Read at once, on every change of the view, then as the browser's settings say (every minute by default, or only on Refresh).
|
||||||
|
*
|
||||||
|
* The charts are panels of a PanelBoard, as on the Overview tab, with their layout kept in the browser: each folds by its
|
||||||
|
* title bar and hides by its [X]; Panels shows them again, and Edit layout moves them by dragging and sets their width.
|
||||||
|
* A click on a legend item hides or shows its line; the hidden lines of each chart are kept in the browser too.
|
||||||
|
* Settings reads the router's metrics section from /metrics/settings and edits it in MetricsSettingsDialog.
|
||||||
|
*/
|
||||||
|
export default function MetricsPanel() {
|
||||||
|
const board = useBoard(LAYOUT_KEY)
|
||||||
|
const [range, setRange] = useState<MetricsRange>('hour')
|
||||||
|
const [consolidation, setConsolidation] = useState<Consolidation>('avg')
|
||||||
|
const [history, setHistory] = useState<MetricsHistory | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [reading, setReading] = useState(true)
|
||||||
|
const [at, setAt] = useState<Date | null>(null)
|
||||||
|
const [hidden, setHidden] = useState<Record<string, string[]>>(readHidden)
|
||||||
|
const [settings, setSettings] = useState<(MetricsSettings & { otlp_password_set: boolean }) | null>(null)
|
||||||
|
const [opening, setOpening] = useState(false)
|
||||||
|
|
||||||
|
/** Reads the metrics section and opens the settings dialog with it. */
|
||||||
|
async function openSettings() {
|
||||||
|
setOpening(true)
|
||||||
|
try {
|
||||||
|
setSettings((await request<{ settings: MetricsSettings & { otlp_password_set: boolean } }>('GET', '/metrics/settings', METRICS_SETTINGS_SHAPE)).settings)
|
||||||
|
setError(null)
|
||||||
|
} catch (caught) {
|
||||||
|
setError(`The router did not give its metrics settings: ${(caught as Error).message}`)
|
||||||
|
}
|
||||||
|
setOpening(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps the lines of a chart that are hidden, in the state and in localStorage. */
|
||||||
|
function hide(chart: string, labels: string[]) {
|
||||||
|
setHidden((current) => {
|
||||||
|
const next = { ...current, [chart]: labels }
|
||||||
|
window.localStorage.setItem(HIDDEN_KEY, JSON.stringify(next))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setReading(true)
|
||||||
|
try {
|
||||||
|
setHistory(await request<MetricsHistory>('GET', `/metrics/history?range=${range}`, METRICS_SHAPE))
|
||||||
|
setError(null)
|
||||||
|
} catch (caught) {
|
||||||
|
setError(`The router did not give its metrics: ${(caught as Error).message}`)
|
||||||
|
}
|
||||||
|
setAt(new Date())
|
||||||
|
setReading(false)
|
||||||
|
}, [range])
|
||||||
|
|
||||||
|
// A read at once and on every change of the view, then every metricsRefresh seconds of the browser's settings, unless
|
||||||
|
// it is manual (0).
|
||||||
|
const { metricsRefresh } = useBrowserSettings()
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh()
|
||||||
|
}, [refresh])
|
||||||
|
useEffect(() => {
|
||||||
|
if (metricsRefresh === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const timer = window.setInterval(() => void refresh(), metricsRefresh * 1000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [refresh, metricsRefresh])
|
||||||
|
|
||||||
|
const points = history?.range === range ? history.points : []
|
||||||
|
const times = points.map((point) => new Date(point.time * 1000))
|
||||||
|
const pick = (aggregate: Aggregate | undefined) => aggregate?.[consolidation] ?? null
|
||||||
|
const interfaces = [...new Set(points.flatMap((point) => Object.keys(point.network)))].sort()
|
||||||
|
const kinds = [...new Set(points.flatMap((point) => Object.keys(point.clients)))].sort()
|
||||||
|
const tunnels = [...new Set(points.flatMap((point) => Object.keys(point.site_to_site)))].sort()
|
||||||
|
const format = new Intl.DateTimeFormat(undefined, RANGES[range].format)
|
||||||
|
// The axis spans the whole view, whatever part of it has data.
|
||||||
|
const end = at ?? new Date()
|
||||||
|
const start = RANGES[range].start(end)
|
||||||
|
|
||||||
|
const percent = (value: number) => `${value.toFixed(1)} %`
|
||||||
|
const rate = (value: number) => `${bytes(value)}/s`
|
||||||
|
|
||||||
|
/** A chart panel of lines over x, the times of the view's buckets unless given. */
|
||||||
|
function chart(id: string, title: string, lines: Line[], unit: (value: number) => string, max?: number, x: Date[] = times): BoardPanel {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
content: x.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">No data for this view yet.</Typography>
|
||||||
|
) : (
|
||||||
|
<LineChart
|
||||||
|
height={240}
|
||||||
|
skipAnimation
|
||||||
|
xAxis={[{ data: x, scaleType: 'time', min: start, max: end, valueFormatter: (value: Date) => format.format(value) }]}
|
||||||
|
yAxis={[{ min: 0, max, width: 72, valueFormatter: (value: number) => unit(value) }]}
|
||||||
|
slotProps={{ legend: { toggleVisibilityOnClick: true } }}
|
||||||
|
hiddenItems={(hidden[id] ?? []).map((label) => ({ type: 'line' as const, seriesId: label }))}
|
||||||
|
onHiddenItemsChange={(items) => hide(id, items.map((item) => String(item.seriesId)))}
|
||||||
|
series={lines.map((line) => ({
|
||||||
|
id: line.label,
|
||||||
|
label: line.label,
|
||||||
|
data: line.data,
|
||||||
|
showMark: false,
|
||||||
|
valueFormatter: (value: number | null) => (value === null ? 'no data' : unit(value)),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const panels = [
|
||||||
|
chart('cpu', 'CPU', [{ label: 'CPU', data: points.map((point) => pick(point.cpu)) }], percent, 100),
|
||||||
|
chart('memory', 'Memory', [
|
||||||
|
{ label: 'Used', data: points.map((point) => pick(point.memory_used)) },
|
||||||
|
{ label: 'Total', data: points.map((point) => pick(point.memory_total)) },
|
||||||
|
], bytes),
|
||||||
|
chart('disk', 'Disk free', [
|
||||||
|
{ label: 'Free', data: points.map((point) => pick(point.disk_free)) },
|
||||||
|
{ label: 'Total', data: points.map((point) => pick(point.disk_total)) },
|
||||||
|
], bytes),
|
||||||
|
chart('disk-percent', 'Disk free (%)', [{
|
||||||
|
label: 'Free',
|
||||||
|
data: points.map((point) => {
|
||||||
|
const free = pick(point.disk_free)
|
||||||
|
const total = pick(point.disk_total)
|
||||||
|
return free === null || !total ? null : (free / total) * 100
|
||||||
|
}),
|
||||||
|
}], percent, 100),
|
||||||
|
chart('network', 'Network', interfaces.flatMap((name) => [
|
||||||
|
{ label: `${name} in`, data: points.map((point) => pick(point.network[name]?.rx)) },
|
||||||
|
{ label: `${name} out`, data: points.map((point) => pick(point.network[name]?.tx)) },
|
||||||
|
]), rate),
|
||||||
|
chart('vpn-clients', 'VPN clients', kinds.map((kind) => ({
|
||||||
|
label: CLIENT_KINDS[kind] ?? kind,
|
||||||
|
data: points.map((point) => pick(point.clients[kind])),
|
||||||
|
})), (value) => value.toFixed(value % 1 === 0 ? 0 : 1)),
|
||||||
|
chart('load', 'Load average', [
|
||||||
|
{ label: '1 min', data: points.map((point) => pick(point.load1)) },
|
||||||
|
{ label: '5 min', data: points.map((point) => pick(point.load5)) },
|
||||||
|
{ label: '15 min', data: points.map((point) => pick(point.load15)) },
|
||||||
|
], (value) => value.toFixed(2)),
|
||||||
|
]
|
||||||
|
// The site-to-site charts, while the view has data of a connection: the percentage of each slot of the view it was up,
|
||||||
|
// which the router computes in slots of its own whatever Average or Maximum shows, its traffic and its packets.
|
||||||
|
if (tunnels.length > 0) {
|
||||||
|
const availability = history?.range === range ? history.site_to_site_up : { slot: '', points: [] }
|
||||||
|
panels.push(
|
||||||
|
chart(
|
||||||
|
'site-to-site-up',
|
||||||
|
`Site-to-site up, % of each ${availability.slot}`,
|
||||||
|
tunnels.map((name) => ({ label: name, data: availability.points.map((slot) => slot.connections[name] ?? null) })),
|
||||||
|
percent,
|
||||||
|
100,
|
||||||
|
availability.points.map((slot) => new Date(slot.time * 1000)),
|
||||||
|
),
|
||||||
|
chart('site-to-site-traffic', 'Site-to-site traffic', tunnels.flatMap((name) => [
|
||||||
|
{ label: `${name} in`, data: points.map((point) => pick(point.site_to_site[name]?.rx)) },
|
||||||
|
{ label: `${name} out`, data: points.map((point) => pick(point.site_to_site[name]?.tx)) },
|
||||||
|
]), rate),
|
||||||
|
chart('site-to-site-packets', 'Site-to-site packets', tunnels.flatMap((name) => [
|
||||||
|
{ label: `${name} in`, data: points.map((point) => pick(point.site_to_site[name]?.packets_rx)) },
|
||||||
|
{ label: `${name} out`, data: points.map((point) => pick(point.site_to_site[name]?.packets_tx)) },
|
||||||
|
]), (value) => `${value.toFixed(value < 10 ? 1 : 0)}/s`),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box component="section">
|
||||||
|
<Stack direction="row" sx={{ mb: 2, alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
<ToggleButtonGroup size="small" exclusive value={range} onChange={(_event, next: MetricsRange | null) => next && setRange(next)}>
|
||||||
|
{METRICS_RANGES.map((name) => <ToggleButton key={name} value={name}>{RANGES[name].label}</ToggleButton>)}
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
<ToggleButtonGroup size="small" exclusive value={consolidation} onChange={(_event, next: Consolidation | null) => next && setConsolidation(next)}>
|
||||||
|
<ToggleButton value="avg">Average</ToggleButton>
|
||||||
|
<ToggleButton value="max">Maximum</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ flexGrow: 1 }}>
|
||||||
|
{at ? `${history ? `Buckets of ${history.bucket}, ` : ''}read at ${at.toLocaleTimeString()}` : 'Reading the metrics...'}
|
||||||
|
</Typography>
|
||||||
|
<BoardButtons board={board} />
|
||||||
|
<Button variant="outlined" startIcon={<Settings />} disabled={opening} onClick={() => void openSettings()}>
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" startIcon={<Refresh />} disabled={reading} onClick={() => void refresh()}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && <Alert severity="warning" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
|
||||||
|
<Board board={board} panels={panels} />
|
||||||
|
|
||||||
|
{settings && (
|
||||||
|
<MetricsSettingsDialog
|
||||||
|
current={settings}
|
||||||
|
onCancel={() => setSettings(null)}
|
||||||
|
onSave={async (values) => {
|
||||||
|
await request('PUT', '/metrics/settings', METRICS_SETTINGS_SHAPE, values)
|
||||||
|
setSettings(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, MenuItem, Stack, Switch, TextField, Typography } from '@mui/material'
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { LISTEN_CHOICES, METRICS_KEEPS, type MetricsKeep, type MetricsSettings } from './api.ts'
|
||||||
|
|
||||||
|
/** The labels of the retention settings. */
|
||||||
|
const KEEP_LABELS: Record<MetricsKeep, string> = {
|
||||||
|
keep_details: 'Per-minute samples',
|
||||||
|
keep_hourly: 'Hourly summaries',
|
||||||
|
keep_daily: 'Daily summaries',
|
||||||
|
keep_monthly: 'Monthly summaries',
|
||||||
|
keep_yearly: 'Yearly summaries',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A retention period as the router takes it: a count and a unit. */
|
||||||
|
const KEEP_PATTERN = /^[1-9]\d{0,4} (hour|day|month|year)s?$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog that edits the router's metrics section: whether the metrics service runs, the interfaces and the ports of
|
||||||
|
* /metrics, its service account, the OTLP endpoint the samples are pushed to with its user name and password, and how
|
||||||
|
* long each level of the history is kept. An empty password keeps the current one, and an empty user name removes it.
|
||||||
|
* onSave sends the section, with otlp_password only when one is entered; the dialog closes when it succeeds and shows
|
||||||
|
* the router's message when it fails.
|
||||||
|
*/
|
||||||
|
export default function MetricsSettingsDialog(props: {
|
||||||
|
current: MetricsSettings & { otlp_password_set: boolean }
|
||||||
|
onSave: (settings: MetricsSettings & { otlp_password?: string }) => Promise<void>
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const { otlp_password_set: passwordSet, ...initial } = props.current
|
||||||
|
const [settings, setSettings] = useState<MetricsSettings>(initial)
|
||||||
|
const [port, setPort] = useState(String(initial.port))
|
||||||
|
const [httpsPort, setHttpsPort] = useState(String(initial.https_port))
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const change = (values: Partial<MetricsSettings>) => setSettings((current) => ({ ...current, ...values }))
|
||||||
|
const portProblem = (value: string) => /^\d{1,5}$/.test(value) && Number(value) >= 1 && Number(value) <= 65535 ? '' : '1 to 65535.'
|
||||||
|
const endpointProblem = settings.otlp_endpoint === '' || /^https?:\/\/\S+$/.test(settings.otlp_endpoint) ? '' : 'An http or https URL.'
|
||||||
|
const passwordProblem = settings.otlp_username !== '' && !passwordSet && password === '' ? 'Required with a user name.' : ''
|
||||||
|
const usernameProblem = settings.otlp_username === '' && password !== '' ? 'Required with a password.' : ''
|
||||||
|
const keepProblems = Object.fromEntries(METRICS_KEEPS.map((keep) => [keep, KEEP_PATTERN.test(settings[keep]) ? '' : 'A count and a unit, such as 24 hours.']))
|
||||||
|
const blocked = portProblem(port) !== '' || portProblem(httpsPort) !== '' || settings.service_account === '' ||
|
||||||
|
endpointProblem !== '' || passwordProblem !== '' || usernameProblem !== '' || Object.values(keepProblems).some((problem) => problem !== '')
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (blocked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await props.onSave({ ...settings, port: Number(port), https_port: Number(httpsPort), ...(password ? { otlp_password: password } : {}) })
|
||||||
|
} catch (caught) {
|
||||||
|
setError((caught as Error).message)
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
|
||||||
|
<DialogTitle>Metrics settings</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, pt: 1 }}>
|
||||||
|
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
<FormControlLabel control={<Switch checked={settings.enabled} onChange={(event) => change({ enabled: event.target.checked })} />} label="Enabled" />
|
||||||
|
<Stack direction="row" sx={{ gap: 2 }}>
|
||||||
|
<TextField select label="Listen" value={settings.listen} helperText="The interfaces /metrics is served on." onChange={(event) => change({ listen: event.target.value as MetricsSettings['listen'] })} sx={{ flex: 1 }}>
|
||||||
|
{LISTEN_CHOICES.map((item) => <MenuItem key={item} value={item}>{item}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="Port"
|
||||||
|
value={port}
|
||||||
|
autoComplete="off"
|
||||||
|
error={portProblem(port) !== ''}
|
||||||
|
helperText={portProblem(port) || 'Plain HTTP.'}
|
||||||
|
slotProps={{ htmlInput: { inputMode: 'numeric' } }}
|
||||||
|
onChange={(event) => setPort(event.target.value.trim())}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="HTTPS port"
|
||||||
|
value={httpsPort}
|
||||||
|
autoComplete="off"
|
||||||
|
error={portProblem(httpsPort) !== ''}
|
||||||
|
helperText={portProblem(httpsPort) || 'Served by Caddy.'}
|
||||||
|
slotProps={{ htmlInput: { inputMode: 'numeric' } }}
|
||||||
|
onChange={(event) => setHttpsPort(event.target.value.trim())}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<TextField
|
||||||
|
label="Service account"
|
||||||
|
value={settings.service_account}
|
||||||
|
autoComplete="off"
|
||||||
|
error={settings.service_account === ''}
|
||||||
|
helperText={settings.service_account === '' ? 'Required.' : 'The account a collector signs in to /metrics with.'}
|
||||||
|
onChange={(event) => change({ service_account: event.target.value.trim() })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="OTLP endpoint"
|
||||||
|
value={settings.otlp_endpoint}
|
||||||
|
autoComplete="off"
|
||||||
|
error={endpointProblem !== ''}
|
||||||
|
helperText={endpointProblem || 'An OTLP/HTTP URL every sample is pushed to; empty for none.'}
|
||||||
|
onChange={(event) => change({ otlp_endpoint: event.target.value.trim() })}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" sx={{ gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="OTLP user name"
|
||||||
|
value={settings.otlp_username}
|
||||||
|
autoComplete="off"
|
||||||
|
error={usernameProblem !== ''}
|
||||||
|
helperText={usernameProblem || 'Empty for no authentication.'}
|
||||||
|
onChange={(event) => change({ otlp_username: event.target.value.trim() })}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="OTLP password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={settings.otlp_username === '' && password === ''}
|
||||||
|
error={passwordProblem !== ''}
|
||||||
|
helperText={passwordProblem || (passwordSet && settings.otlp_username !== '' ? 'Leave empty to keep the current password.' : '')}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="subtitle2">History kept</Typography>
|
||||||
|
<Stack direction="row" sx={{ gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
{METRICS_KEEPS.map((keep) => (
|
||||||
|
<TextField
|
||||||
|
key={keep}
|
||||||
|
label={KEEP_LABELS[keep]}
|
||||||
|
value={settings[keep]}
|
||||||
|
autoComplete="off"
|
||||||
|
error={keepProblems[keep] !== ''}
|
||||||
|
helperText={keepProblems[keep]}
|
||||||
|
onChange={(event) => change({ [keep]: event.target.value })}
|
||||||
|
sx={{ flex: '1 1 30%' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={props.onCancel} disabled={saving}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={saving || blocked}>Save</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import Refresh from '@mui/icons-material/Refresh'
|
||||||
|
import { Alert, Box, Button } from '@mui/material'
|
||||||
|
import { Board, BoardButtons, useBoard, type BoardPanel } from './PanelBoard.tsx'
|
||||||
|
import { Grid, Pairs, text } from './RouterPanel.tsx'
|
||||||
|
import Toolbar from './Toolbar.tsx'
|
||||||
|
import { DETAILS_SHAPE, request, type RouterDetails } from './api.ts'
|
||||||
|
import { useRead } from './useRead.ts'
|
||||||
|
|
||||||
|
/** The localStorage key of the tab's layout. */
|
||||||
|
const LAYOUT_KEY = 'vpn-router-manager.network-layout'
|
||||||
|
|
||||||
|
/** Reads the router's /config. */
|
||||||
|
const readDetails = () => request<RouterDetails>('GET', '/config', DETAILS_SHAPE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The router's network, from its /config: the external and the internal interface with their addresses, the internal
|
||||||
|
* gateway and the local CIDRs, and the IPv4 routes. The panels are on a board whose layout is kept in the browser.
|
||||||
|
*/
|
||||||
|
export default function NetworkPanel() {
|
||||||
|
const { data: details, error, busy, refresh } = useRead(readDetails)
|
||||||
|
const board = useBoard(LAYOUT_KEY)
|
||||||
|
|
||||||
|
const network = details?.network
|
||||||
|
const panels: BoardPanel[] = network ? [
|
||||||
|
{
|
||||||
|
id: 'network',
|
||||||
|
title: 'Network',
|
||||||
|
content: (
|
||||||
|
<Pairs
|
||||||
|
rows={[
|
||||||
|
['External interface', `${network.external_interface ?? '-'}: ${text(network.external_addresses)}`],
|
||||||
|
['Internal interface', `${network.internal_interface ?? '-'}: ${text(network.internal_address)}`],
|
||||||
|
['Internal gateway', text(network.internal_gateway)],
|
||||||
|
['Local CIDRs', text(network.local_cidrs)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'routes',
|
||||||
|
title: 'Routes',
|
||||||
|
content: (
|
||||||
|
<Grid
|
||||||
|
head={['Destination', 'Gateway', 'Device', 'Source', 'Protocol', 'Metric']}
|
||||||
|
rows={network.routes?.map((route) => [
|
||||||
|
route.dst,
|
||||||
|
text(route.gateway),
|
||||||
|
route.flags.length > 0 ? `${route.dev} (${route.flags.join(', ')})` : route.dev,
|
||||||
|
text(route.prefsrc),
|
||||||
|
text(route.protocol),
|
||||||
|
text(route.metric),
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box component="section">
|
||||||
|
<Toolbar description="Interfaces, addresses and routes.">
|
||||||
|
<BoardButtons board={board} />
|
||||||
|
<Button variant="outlined" startIcon={<Refresh />} disabled={busy} onClick={() => void refresh()}>Refresh</Button>
|
||||||
|
</Toolbar>
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
<Board board={board} panels={panels} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField, Typography } from '@mui/material'
|
||||||
|
import { QRCodeSVG } from 'qrcode.react'
|
||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { CODE_PATTERN, OTP_SECRET_SHAPE, request, type OtpSecret, type Proof } from './api.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog that enrols a user for TOTP: it asks the router for a new secret at path, shows it as a QR code and as text
|
||||||
|
* for an authenticator app, and asks for a code the app shows. With proof it also asks for the user's current password
|
||||||
|
* and, when proof.otp says the user is enrolled, a code of the current authenticator. onSave sends the secret with the
|
||||||
|
* code and the proof; the dialog closes when it succeeds and shows the router's message when it fails. Nothing is
|
||||||
|
* stored before the code is verified.
|
||||||
|
*/
|
||||||
|
export default function OtpDialog(props: {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
proof?: { otp: boolean }
|
||||||
|
onSave: (secret: string, code: string, proof: Proof | null) => Promise<void>
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const [otp, setOtp] = useState<OtpSecret | null>(null)
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [currentCode, setCurrentCode] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<OtpSecret>('POST', props.path, OTP_SECRET_SHAPE)
|
||||||
|
.then(setOtp)
|
||||||
|
.catch((caught: Error) => setError(caught.message))
|
||||||
|
}, [props.path])
|
||||||
|
|
||||||
|
const proofBlocked = props.proof !== undefined && (currentPassword === '' || (props.proof.otp && !CODE_PATTERN.test(currentCode)))
|
||||||
|
const blocked = otp === null || !CODE_PATTERN.test(code) || proofBlocked
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (otp === null || blocked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
const proof = props.proof === undefined ? null : props.proof.otp ? { current_password: currentPassword, current_code: currentCode } : { current_password: currentPassword }
|
||||||
|
try {
|
||||||
|
await props.onSave(otp.secret, code, proof)
|
||||||
|
} catch (caught) {
|
||||||
|
setError((caught as Error).message)
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="xs" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
|
||||||
|
<DialogTitle>{`Enrol ${props.name} for one-time passwords`}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, pt: 1 }}>
|
||||||
|
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
{otp === null && !error && <CircularProgress sx={{ alignSelf: 'center' }} />}
|
||||||
|
{otp && (
|
||||||
|
<>
|
||||||
|
{props.proof && (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
label="Current password"
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
autoFocus
|
||||||
|
autoComplete="current-password"
|
||||||
|
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
{props.proof.otp && (
|
||||||
|
<TextField
|
||||||
|
label="Code of the current authenticator"
|
||||||
|
value={currentCode}
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
helperText="A code not used to sign in."
|
||||||
|
slotProps={{ htmlInput: { inputMode: 'numeric', maxLength: 6 } }}
|
||||||
|
onChange={(event) => setCurrentCode(event.target.value.trim())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Scan the code with an authenticator app, or enter the key, then enter the code the app shows.
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ alignSelf: 'center', p: 1, bgcolor: '#fff', lineHeight: 0 }}>
|
||||||
|
<QRCodeSVG value={otp.uri} size={200} title={`TOTP secret of ${props.name}`} />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" sx={{ fontFamily: 'monospace', textAlign: 'center', wordBreak: 'break-all' }}>
|
||||||
|
{otp.secret}
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Code"
|
||||||
|
value={code}
|
||||||
|
autoFocus={!props.proof}
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
slotProps={{ htmlInput: { inputMode: 'numeric', maxLength: 6 } }}
|
||||||
|
onChange={(event) => setCode(event.target.value.trim())}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={props.onCancel}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={saving || blocked}>
|
||||||
|
Enrol
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import ArrowDropDown from '@mui/icons-material/ArrowDropDown'
|
||||||
|
import Hub from '@mui/icons-material/Hub'
|
||||||
|
import MenuIcon from '@mui/icons-material/Menu'
|
||||||
|
import People from '@mui/icons-material/People'
|
||||||
|
import Public from '@mui/icons-material/Public'
|
||||||
|
import ViewInAr from '@mui/icons-material/ViewInAr'
|
||||||
|
import Router from '@mui/icons-material/Router'
|
||||||
|
import VerifiedUser from '@mui/icons-material/VerifiedUser'
|
||||||
|
import VpnKey from '@mui/icons-material/VpnKey'
|
||||||
|
import { Button, ListItemIcon, ListItemText, Menu, MenuItem } from '@mui/material'
|
||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
/** The path of the signed-in user's own account page. */
|
||||||
|
export const USER_PATH = '/user'
|
||||||
|
|
||||||
|
/** The path of the Remote Access page: IKEv2, OpenVPN and WireGuard. */
|
||||||
|
export const REMOTE_ACCESS_PATH = '/remote-access-manager'
|
||||||
|
|
||||||
|
/** The path of the Certificates page. */
|
||||||
|
export const CA_PATH = '/ca-manager'
|
||||||
|
|
||||||
|
/** The path of the Identities page: the users, the groups and the service accounts. */
|
||||||
|
export const USERS_PATH = '/user-manager'
|
||||||
|
|
||||||
|
/** The path of the Connectivity page: the site-to-site connections and their state. */
|
||||||
|
export const SITE_TO_SITE_PATH = '/site-to-site-manager'
|
||||||
|
|
||||||
|
/** The path of the Docker page: the state of Docker and of its rules. */
|
||||||
|
export const DOCKER_PATH = '/docker-manager'
|
||||||
|
|
||||||
|
/** The path of the Web Sites page: the sites Caddy serves, their files and the name server. */
|
||||||
|
export const SITES_PATH = '/sites-manager'
|
||||||
|
|
||||||
|
/** The path of the shell page: a login shell on the router, opened with the shell button of the header, not the menu. */
|
||||||
|
export const SHELL_PATH = '/shell'
|
||||||
|
|
||||||
|
/** The pages of an administrator in the menu: path, label and icon. */
|
||||||
|
const PAGES: { path: string; label: string; icon: ReactNode }[] = [
|
||||||
|
{ path: '/', label: 'Router', icon: <Router fontSize="small" /> },
|
||||||
|
{ path: USERS_PATH, label: 'Identities', icon: <People fontSize="small" /> },
|
||||||
|
{ path: SITE_TO_SITE_PATH, label: 'Connectivity', icon: <Hub fontSize="small" /> },
|
||||||
|
{ path: REMOTE_ACCESS_PATH, label: 'Remote Access', icon: <VpnKey fontSize="small" /> },
|
||||||
|
{ path: CA_PATH, label: 'Certificates', icon: <VerifiedUser fontSize="small" /> },
|
||||||
|
{ path: SITES_PATH, label: 'Web Sites', icon: <Public fontSize="small" /> },
|
||||||
|
{ path: DOCKER_PATH, label: 'Docker', icon: <ViewInAr fontSize="small" /> },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** A button naming the page shown, or Pages on the account and the shell page, whose menu opens the pages of an administrator. */
|
||||||
|
export default function PageMenu(props: { path: string }) {
|
||||||
|
const [anchor, setAnchor] = useState<HTMLElement | null>(null)
|
||||||
|
const current = PAGES.find((page) => page.path === props.path) ?? { path: props.path, label: 'Pages', icon: <MenuIcon fontSize="small" /> }
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
color="inherit"
|
||||||
|
startIcon={current.icon}
|
||||||
|
endIcon={<ArrowDropDown />}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={anchor !== null}
|
||||||
|
onClick={(event) => setAnchor(event.currentTarget)}
|
||||||
|
>
|
||||||
|
{current.label}
|
||||||
|
</Button>
|
||||||
|
<Menu anchorEl={anchor} open={anchor !== null} onClose={() => setAnchor(null)}>
|
||||||
|
{PAGES.map((page) => (
|
||||||
|
<MenuItem key={page.path} component="a" href={page.path} selected={page.path === current.path}>
|
||||||
|
<ListItemIcon>{page.icon}</ListItemIcon>
|
||||||
|
<ListItemText>{page.label}</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import Check from '@mui/icons-material/Check'
|
||||||
|
import Close from '@mui/icons-material/Close'
|
||||||
|
import DragIndicator from '@mui/icons-material/DragIndicator'
|
||||||
|
import Edit from '@mui/icons-material/Edit'
|
||||||
|
import ExpandLess from '@mui/icons-material/ExpandLess'
|
||||||
|
import ExpandMore from '@mui/icons-material/ExpandMore'
|
||||||
|
import Settings from '@mui/icons-material/Settings'
|
||||||
|
import WidthFull from '@mui/icons-material/WidthFull'
|
||||||
|
import WidthNormal from '@mui/icons-material/WidthNormal'
|
||||||
|
import {
|
||||||
|
Box, Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, Paper, Stack, Tooltip, Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { DndContext, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'
|
||||||
|
import { SortableContext, arrayMove, rectSortingStrategy, sortableKeyboardCoordinates, useSortable } from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
/** Colours of the panel titles: brown in the light scheme, gold in the dark one. */
|
||||||
|
const TITLE_LIGHT = '#8b4513'
|
||||||
|
const TITLE_DARK = '#d4af37'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A panel of a board: its id, which the layout keeps, its title, actions such as buttons or a state on the title bar,
|
||||||
|
* and its content; wide spans both columns by default, and a fixed panel cannot be hidden.
|
||||||
|
*/
|
||||||
|
export type BoardPanel = { id: string; title: string; actions?: ReactNode; content: ReactNode; wide?: boolean; fixed?: boolean }
|
||||||
|
|
||||||
|
/** The layout of a board: the order of the panel ids, the width chosen for a panel, and the hidden and the folded panels. */
|
||||||
|
export type Layout = { order: string[]; widths: Record<string, 'wide' | 'single'>; hidden: string[]; folded: string[] }
|
||||||
|
|
||||||
|
const EMPTY: Layout = { order: [], widths: {}, hidden: [], folded: [] }
|
||||||
|
|
||||||
|
/** The layout kept in localStorage under key, or an empty one when there is none or it cannot be read. */
|
||||||
|
function readLayout(key: string): Layout {
|
||||||
|
try {
|
||||||
|
const value: unknown = JSON.parse(window.localStorage.getItem(key) ?? 'null')
|
||||||
|
if (value !== null && typeof value === 'object') {
|
||||||
|
const stored = value as Partial<Layout>
|
||||||
|
const strings = (items: unknown) => (Array.isArray(items) ? items.filter((item): item is string => typeof item === 'string') : [])
|
||||||
|
return {
|
||||||
|
order: strings(stored.order),
|
||||||
|
widths: stored.widths !== null && typeof stored.widths === 'object' ? stored.widths : {},
|
||||||
|
hidden: strings(stored.hidden),
|
||||||
|
folded: strings(stored.folded),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A layout that cannot be read is replaced by the default.
|
||||||
|
}
|
||||||
|
return EMPTY
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The layout of a board kept in the browser's localStorage under key, and a function that changes and keeps it. */
|
||||||
|
export function useLayout(key: string): [Layout, (change: (current: Layout) => Layout) => void] {
|
||||||
|
const [layout, setLayout] = useState<Layout>(() => readLayout(key))
|
||||||
|
function update(change: (current: Layout) => Layout) {
|
||||||
|
setLayout((current) => {
|
||||||
|
const next = change(current)
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(next))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return [layout, update]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The panels in the order of the layout; a panel the layout does not know yet comes after the known ones, in the given order. */
|
||||||
|
function ordered(panels: BoardPanel[], layout: Layout) {
|
||||||
|
const rank = (panel: BoardPanel) => {
|
||||||
|
const index = layout.order.indexOf(panel.id)
|
||||||
|
return index === -1 ? layout.order.length + panels.indexOf(panel) : index
|
||||||
|
}
|
||||||
|
return [...panels].sort((a, b) => rank(a) - rank(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panels in a grid of two columns on wide screens, in the order of the layout, panels side by side of the same height;
|
||||||
|
* hidden panels are left out. A panel's
|
||||||
|
* title bar folds and unfolds it, and the [X] of a panel that is not fixed hides it. While editing, a panel is moved by
|
||||||
|
* dragging its handle, and the width button switches it between one column and both.
|
||||||
|
*/
|
||||||
|
export function PanelBoard(props: { panels: BoardPanel[]; layout: Layout; onLayout: (change: (current: Layout) => Layout) => void; editing: boolean }) {
|
||||||
|
const { layout, onLayout, editing } = props
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }))
|
||||||
|
const shown = ordered(props.panels, layout).filter((panel) => panel.fixed || !layout.hidden.includes(panel.id))
|
||||||
|
|
||||||
|
function dragEnd(event: DragEndEvent) {
|
||||||
|
const { active, over } = event
|
||||||
|
if (!over || active.id === over.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onLayout((current) => {
|
||||||
|
const ids = ordered(props.panels, current).map((panel) => panel.id)
|
||||||
|
const known = [...ids, ...current.order.filter((id) => !ids.includes(id))]
|
||||||
|
return { ...current, order: arrayMove(known, known.indexOf(String(active.id)), known.indexOf(String(over.id))) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={dragEnd}>
|
||||||
|
<SortableContext items={shown.map((panel) => panel.id)} strategy={rectSortingStrategy}>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1fr 1fr' }, gap: 2 }}>
|
||||||
|
{shown.map((panel) => (
|
||||||
|
<FoldablePanel
|
||||||
|
key={panel.id}
|
||||||
|
panel={panel}
|
||||||
|
wide={(layout.widths[panel.id] ?? (panel.wide ? 'wide' : 'single')) === 'wide'}
|
||||||
|
folded={layout.folded.includes(panel.id)}
|
||||||
|
editing={editing}
|
||||||
|
onFold={(folded) => onLayout((current) => ({
|
||||||
|
...current,
|
||||||
|
folded: folded ? [...current.folded, panel.id] : current.folded.filter((id) => id !== panel.id),
|
||||||
|
}))}
|
||||||
|
onWide={(wide) => onLayout((current) => ({ ...current, widths: { ...current.widths, [panel.id]: wide ? 'wide' : 'single' } }))}
|
||||||
|
onHide={() => onLayout((current) => ({ ...current, hidden: [...current.hidden, panel.id] }))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A panel with a title bar of a slightly different colour, whose title folds it, with the panel's actions and the buttons of the board. */
|
||||||
|
function FoldablePanel(props: {
|
||||||
|
panel: BoardPanel
|
||||||
|
wide: boolean
|
||||||
|
folded: boolean
|
||||||
|
editing: boolean
|
||||||
|
onFold: (folded: boolean) => void
|
||||||
|
onWide: (wide: boolean) => void
|
||||||
|
onHide: () => void
|
||||||
|
}) {
|
||||||
|
const { panel, wide, folded, editing } = props
|
||||||
|
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ id: panel.id, disabled: !editing })
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
ref={setNodeRef}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
gridColumn: wide ? '1 / -1' : undefined,
|
||||||
|
transform: CSS.Translate.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.6 : 1,
|
||||||
|
zIndex: isDragging ? 1 : undefined,
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.5, px: 1, py: 0.5, bgcolor: 'action.hover', borderBottom: folded ? 0 : 1, borderColor: 'divider' }}>
|
||||||
|
{editing && (
|
||||||
|
<IconButton size="small" ref={setActivatorNodeRef} {...attributes} {...listeners} aria-label={`Move ${panel.title}`} sx={{ cursor: 'grab' }}>
|
||||||
|
<DragIndicator fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-expanded={!folded}
|
||||||
|
onClick={() => props.onFold(!folded)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
props.onFold(!folded)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{ flexGrow: 1, alignItems: 'center', gap: 0.5, cursor: 'pointer', minWidth: 0 }}
|
||||||
|
>
|
||||||
|
{folded ? <ExpandMore fontSize="small" /> : <ExpandLess fontSize="small" />}
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
noWrap
|
||||||
|
sx={[{ fontWeight: 700, color: TITLE_LIGHT }, (theme) => theme.applyStyles('dark', { color: TITLE_DARK })]}
|
||||||
|
>
|
||||||
|
{panel.title}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
{panel.actions && <Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>{panel.actions}</Stack>}
|
||||||
|
{editing && (
|
||||||
|
<Tooltip title={wide ? 'One column' : 'Both columns'}>
|
||||||
|
<IconButton size="small" onClick={() => props.onWide(!wide)} aria-label={wide ? 'One column' : 'Both columns'}>
|
||||||
|
{wide ? <WidthNormal fontSize="small" /> : <WidthFull fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{!panel.fixed && (
|
||||||
|
<Tooltip title="Hide">
|
||||||
|
<IconButton size="small" onClick={props.onHide} aria-label={`Hide ${panel.title}`}>
|
||||||
|
<Close fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
{!folded && <Box sx={{ p: 2 }}>{panel.content}</Box>}
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A dialog that shows or hides each panel that is not fixed; a fixed panel is listed, always shown. */
|
||||||
|
export function PanelsDialog(props: { open: boolean; panels: BoardPanel[]; layout: Layout; onLayout: (change: (current: Layout) => Layout) => void; onClose: () => void }) {
|
||||||
|
const { layout, onLayout } = props
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onClose={props.onClose} fullWidth maxWidth="xs">
|
||||||
|
<DialogTitle>Panels</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack>
|
||||||
|
{ordered(props.panels, layout).map((panel) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={panel.id}
|
||||||
|
label={panel.title}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={panel.fixed || !layout.hidden.includes(panel.id)}
|
||||||
|
disabled={panel.fixed}
|
||||||
|
onChange={(event) => onLayout((current) => ({
|
||||||
|
...current,
|
||||||
|
hidden: event.target.checked ? current.hidden.filter((id) => id !== panel.id) : [...current.hidden, panel.id],
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={props.onClose}>Close</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The state of a page's board: its layout kept in the browser under key, whether it is edited, and the Panels dialog. */
|
||||||
|
export type BoardState = ReturnType<typeof useBoard>
|
||||||
|
|
||||||
|
export function useBoard(key: string) {
|
||||||
|
const [layout, setLayout] = useLayout(key)
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [choosing, setChoosing] = useState(false)
|
||||||
|
return { layout, setLayout, editing, setEditing, choosing, setChoosing }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The buttons of a board: Edit layout, Done while editing, and Panels, which opens the Panels dialog. */
|
||||||
|
export function BoardButtons(props: { board: BoardState }) {
|
||||||
|
const { editing, setEditing, setChoosing } = props.board
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant={editing ? 'contained' : 'outlined'} startIcon={editing ? <Check /> : <Edit />} onClick={() => setEditing(!editing)}>
|
||||||
|
{editing ? 'Done' : 'Edit layout'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" startIcon={<Settings />} onClick={() => setChoosing(true)}>Panels</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A board of panels with its Panels dialog, in the state of useBoard. */
|
||||||
|
export function Board(props: { board: BoardState; panels: BoardPanel[] }) {
|
||||||
|
const { layout, setLayout, editing, choosing, setChoosing } = props.board
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PanelBoard panels={props.panels} layout={layout} onLayout={setLayout} editing={editing} />
|
||||||
|
<PanelsDialog open={choosing} panels={props.panels} layout={layout} onLayout={setLayout} onClose={() => setChoosing(false)} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField } from '@mui/material'
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { CODE_PATTERN, PASSKEY_BEGUN_SHAPE, USER_SHAPE, request, type PasskeyBegun, type User } from './api.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog that registers a passkey of the signed-in user: a label, the current password and, for a user enrolled for
|
||||||
|
* one-time passwords, a code. The router begins the registration with them, the browser creates the passkey, and the
|
||||||
|
* router stores it; onSaved gets the user with the new passkey. A failure shows its message.
|
||||||
|
*/
|
||||||
|
export default function PasskeyDialog(props: { otp: boolean; onSaved: (user: User) => void; onCancel: () => void }) {
|
||||||
|
const [label, setLabel] = useState('')
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [currentCode, setCurrentCode] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const blocked = currentPassword === '' || (props.otp && !CODE_PATTERN.test(currentCode))
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (blocked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const proof = props.otp ? { current_password: currentPassword, current_code: currentCode } : { current_password: currentPassword }
|
||||||
|
const begun = await request<PasskeyBegun<PublicKeyCredentialCreationOptionsJSON>>('POST', '/account/passkeys', PASSKEY_BEGUN_SHAPE, proof)
|
||||||
|
const credential = await navigator.credentials.create({ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(begun.options.publicKey) })
|
||||||
|
if (!(credential instanceof PublicKeyCredential)) {
|
||||||
|
throw new Error('The browser created no passkey.')
|
||||||
|
}
|
||||||
|
props.onSaved(await request<User>('PUT', '/account/passkeys', USER_SHAPE, { id: begun.id, credential: credential.toJSON(), label }))
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof DOMException && caught.name === 'NotAllowedError' ? 'The passkey was not created.' : (caught as Error).message)
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={props.onCancel} fullWidth maxWidth="xs">
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<DialogTitle>Add a passkey</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, pt: 1 }}>
|
||||||
|
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
|
||||||
|
<TextField label="Label" value={label} placeholder="Passkey" autoFocus slotProps={{ htmlInput: { maxLength: 64 } }} onChange={(event) => setLabel(event.target.value)} />
|
||||||
|
<TextField label="Current password" type="password" value={currentPassword} autoComplete="current-password" onChange={(event) => setCurrentPassword(event.target.value)} />
|
||||||
|
{props.otp && (
|
||||||
|
<TextField
|
||||||
|
label="Code"
|
||||||
|
value={currentCode}
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
slotProps={{ htmlInput: { inputMode: 'numeric', maxLength: 6 } }}
|
||||||
|
onChange={(event) => setCurrentCode(event.target.value.trim())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={props.onCancel}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={saving || blocked}>Add</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user