feature: the router serves S3-compatible object storage with versitygw, with one S3 account per user and buckets owned by users.
Release / release (push) Successful in 14s
Release / release (push) Successful in 14s
This commit is contained in:
1 parent
d99e4ea425
commit
1eac15e640
20 files changed
+1114
-24
No files matched your search
@@ -1278,3 +1278,44 @@ method.
|
||||
Consequences: A change to one family of paths touches one module and the routing in `handler`.
|
||||
State shared between requests, such as the open shells, lives in the module that owns it; other
|
||||
modules change it and never rebind it.
|
||||
|
||||
## ADR-48: Object storage is versitygw with its POSIX backend, one S3 account per user
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The router's users and services want S3-compatible storage for backups of other
|
||||
systems, Terraform state and artifacts. Ubuntu 24.04 has no S3 server; its rclone 1.60 predates
|
||||
`rclone serve s3`. versitygw (Apache-2.0) is an S3 gateway that keeps buckets as plain folders
|
||||
(POSIX backend) and has its own IAM. It publishes `.deb` files for amd64 and arm64, and an Admin
|
||||
API, signed like S3, to manage accounts and bucket owners. Its LDAP IAM needs each secret stored in
|
||||
plain text in the directory. S3 signs with a shared secret, so the directory's passwords cannot
|
||||
serve as S3 credentials.
|
||||
|
||||
Decision:
|
||||
|
||||
- **Installation:** versitygw is pinned to a version and the SHA-256 of each architecture's
|
||||
`.deb` (`vpnrouter_s3`). It is downloaded and installed on demand by
|
||||
`vpn-router-install@s3.service`.
|
||||
- **Service:** `vpn-router-s3.service` runs it with the internal IAM in
|
||||
`/var/lib/vpn-router/s3/iam` and the buckets in `/var/lib/vpn-router/s3/data`. Its S3 API and
|
||||
its Admin API are on loopback ports, and Caddy serves the S3 API at
|
||||
`https://<wan.local_fqdn>:<s3.port>` with path-style addresses, which need no wildcard name.
|
||||
- **Root keys:** setup makes them once, in `/etc/vpn-router/s3.env`.
|
||||
- **Calls:** the router calls the Admin API and S3's DeleteBucket with its own Signature Version 4
|
||||
signer, from the standard library, so no secret appears on a command line.
|
||||
- **Accounts:**
|
||||
- Each user or service account has at most one S3 account, of the role `user`, so a bucket's
|
||||
owner is a user.
|
||||
- `/etc/vpn-router/s3-accounts.json` records the owner, the key and the secret.
|
||||
- The account follows its user: an unknown secret while the user is disabled, its own secret
|
||||
when the user is enabled, and removal with the user.
|
||||
|
||||
Consequences:
|
||||
|
||||
- **Accounts:** a user has one key pair; replacing its secret replaces it for every client.
|
||||
- **Secrets:** they are kept by the router in two root-only files, versitygw's IAM and the
|
||||
record, as S3 needs them to check signatures.
|
||||
- **Ownership:** a removed user's buckets keep the account as their owner until an admin gives
|
||||
them to another user.
|
||||
- **Backups and upgrades:** the buckets are in backups only with `backup.s3`. A new versitygw
|
||||
comes with a new vpn-router release, not from apt.
|
||||
@@ -147,6 +147,9 @@ 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` | `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 |
|
||||
| `s3` | `enabled` | boolean | `false` | S3-compatible object storage, which needs `https.enabled` and `wan.local_fqdn` (see Object storage) |
|
||||
| `s3` | `port` | integer | `7447` | HTTPS port of the object storage |
|
||||
| `s3` | `region` | string | `us-east-1` | The region S3 clients sign their requests with |
|
||||
| `dns` | `enabled` | boolean | `false` | The primary name server of the router's domains, which needs `dns.nameserver` and `dns.addresses` (see Name server) |
|
||||
| `dns` | `nameserver` | string | | The name server's host name, the primary NS of every domain |
|
||||
| `dns` | `addresses` | list | | The public addresses of the name server, the domains' apexes and the sites' host names |
|
||||
@@ -188,6 +191,7 @@ must have the type shown below; an unknown section or setting is an error.
|
||||
| `backup` | `keep_monthly` | integer | `12` | Monthly local archives kept |
|
||||
| `backup` | `keep_yearly` | integer | `0` | Yearly local archives kept |
|
||||
| `backup` | `metrics` | boolean | `false` | The backup holds the database of the metrics and the refresh tokens |
|
||||
| `backup` | `s3` | boolean | `false` | The backup holds the buckets of the object storage too; its accounts are always held |
|
||||
| `backup` | `rclone_remote` | string | | `remote:path` of `/etc/vpn-router/rclone.conf` the local archives are copied to |
|
||||
| `backup` | `rclone_mode` | string | `sync` | `sync` mirrors the archives, `copy` deletes nothing in the cloud |
|
||||
| `backup` | `pbs_repository` | string | | `user@realm!token@host[:port]:datastore` |
|
||||
@@ -1674,6 +1678,85 @@ vpn-config-edit set dns.addresses 203.0.113.10
|
||||
vpn-config-edit set --restart dns.enabled true
|
||||
```
|
||||
|
||||
## Object storage
|
||||
|
||||
While `s3.enabled` is `true` and HTTPS is ready, the router serves S3-compatible object storage
|
||||
at `https://<wan.local_fqdn>:<s3.port>` (7447 by default), with path-style addresses, for clients
|
||||
such as `aws s3`, rclone, restic or Terraform's `s3` backend. The firewall opens the port where it
|
||||
opens the other HTTPS ports.
|
||||
|
||||
```sh
|
||||
vpn-config-edit set --restart s3.enabled true
|
||||
```
|
||||
|
||||
**The service**
|
||||
|
||||
- **The server:** versitygw, pinned to one release, which a release of vpn-router raises.
|
||||
`vpn-router-install@s3.service` downloads its `.deb` for amd64 or arm64 from GitHub, checks its
|
||||
SHA-256 and installs it with apt-get, when another version or none is installed.
|
||||
- **How it runs:** `vpn-router-s3.service` runs it as root, sandboxed. Its S3 API is on
|
||||
`127.0.0.1:7448`, which Caddy serves, and its Admin API on `127.0.0.1:7449`.
|
||||
- **Storage:** with the POSIX backend, each folder of `/var/lib/vpn-router/s3/data` is a bucket
|
||||
and the files below it are its objects.
|
||||
- **Settings:** `/etc/vpn-router/s3.env` (0600, written by setup) holds the root keys, the ports,
|
||||
the IAM folder `/var/lib/vpn-router/s3/iam` and `s3.region`, which clients sign with.
|
||||
|
||||
**Accounts**
|
||||
|
||||
- **One account per user:** a user or service account of the directory has at most one S3
|
||||
account, an access key and its secret.
|
||||
- **The secret is shown once,** when the account is made or its secret replaced.
|
||||
`/etc/vpn-router/s3-accounts.json` (0600) records the accounts with their owners.
|
||||
- **Following the user:**
|
||||
- while the user is disabled, the account's secret is replaced by an unknown one;
|
||||
- once the user is enabled, the account has its own secret again;
|
||||
- removing the user removes the account.
|
||||
|
||||
Setup brings every account in line at each run.
|
||||
|
||||
**Buckets**
|
||||
|
||||
- A bucket belongs to one account, which alone may use it.
|
||||
- Buckets are made, given to another user and removed (when empty) by an admin.
|
||||
- A user's buckets keep the account as their owner after the account is removed, until another
|
||||
owner is chosen.
|
||||
|
||||
```sh
|
||||
vpn-router-s3 account add alice # prints the access key and the secret once
|
||||
vpn-router-s3 bucket add tfstate --owner alice
|
||||
aws --endpoint-url https://router.example.com:7447 s3 ls s3://tfstate
|
||||
```
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `vpn-router-s3 status` | Whether storage is on, the installed and the pinned versitygw, whether it runs, the endpoint and the region |
|
||||
| `vpn-router-s3 account list [--owner U]` | The accounts with their owners and the time each was made |
|
||||
| `vpn-router-s3 account add\|rotate\|remove U` | Make the user's account, replace its secret, or remove it |
|
||||
| `vpn-router-s3 bucket list` | The buckets with their owners, sizes and numbers of objects |
|
||||
| `vpn-router-s3 bucket add NAME --owner U` | Make a bucket of the user's account |
|
||||
| `vpn-router-s3 bucket owner NAME U` | Give the bucket to another user's account; versitygw resets its ACL and policy |
|
||||
| `vpn-router-s3 bucket remove NAME` | Remove the bucket, which must be empty |
|
||||
|
||||
`vpn-router-s3` answers JSON with `--json`, as `vpn-router-site` does.
|
||||
|
||||
**Web service API** (`/s3` for admins, `/account/s3` for the signed-in user)
|
||||
|
||||
| Request | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `GET /s3` | | `{"settings", "endpoint", "version", "installed", "running", "buckets", "accounts", "problem"}` |
|
||||
| `PUT /s3` | any of the `s3` settings | `{"settings"}`, applied by setup |
|
||||
| `POST /s3/buckets` | `{"name", "owner"}` | `{"name", "owner"}` |
|
||||
| `PUT /s3/buckets/<name>` | `{"owner"}` | `{"name", "owner"}` |
|
||||
| `DELETE /s3/buckets/<name>` | | `{"name", "removed": true}`; 409 while it is not empty |
|
||||
| `POST /s3/accounts` | `{"owner"}` | `{"owner", "access_key", "secret_key", "created"}` |
|
||||
| `PUT /s3/accounts/<owner>` | | the same, with a new `secret_key` |
|
||||
| `DELETE /s3/accounts/<owner>` | | `{"owner", "access_key", "removed": true}` |
|
||||
| `GET /account/s3` | | `{"enabled", "endpoint", "region", "account"}`, `account` null without one |
|
||||
| `POST`, `PUT`, `DELETE /account/s3` | | Make, replace the secret of, or remove the user's own account |
|
||||
|
||||
Backups always hold the IAM folder and `/etc/vpn-router`; with `backup.s3` they also hold the
|
||||
buckets, which may be large.
|
||||
|
||||
## Metrics
|
||||
|
||||
While `metrics.enabled` is `true`, `vpn-router-setup` runs `vpn-router-metrics`, which every minute
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
vpn-router (0.36.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the router serves S3-compatible object storage with versitygw, with one S3 account per user and buckets owned by users.
|
||||
|
||||
* s3.enabled, s3.port (7447) and s3.region: while storage is on and
|
||||
HTTPS is ready, Caddy serves versitygw's S3 API at
|
||||
https://<wan.local_fqdn>:<s3.port>, with path-style addresses.
|
||||
* vpn-router-install@s3 downloads the pinned versitygw 1.10.0 .deb for
|
||||
amd64 or arm64, checks its SHA-256 and installs it;
|
||||
vpn-router-s3.service runs it with the buckets in
|
||||
/var/lib/vpn-router/s3/data.
|
||||
* vpn-router-s3 makes, replaces the secret of and removes a user's S3
|
||||
account, and makes, gives and removes buckets; GET, PUT /s3,
|
||||
/s3/buckets and /s3/accounts do the same for admins, and /account/s3
|
||||
for the signed-in user's own account.
|
||||
* A user's S3 account follows the user: it stops working while the user
|
||||
is disabled and is removed with the user.
|
||||
* Backups hold the S3 accounts; with backup.s3 also the buckets.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 19:16:59 +0100
|
||||
|
||||
vpn-router (0.35.2-1) unstable; urgency=medium
|
||||
|
||||
fix: the web service answers /, /status and /status/full again while IKEv2 runs.
|
||||
|
||||
@@ -57,8 +57,10 @@ Description: Linux VPN router with IPSec, IKEv2, OpenVPN and WireGuard support
|
||||
certificates and CRLs. Caddy, installed by the package from the Ubuntu
|
||||
archive or from Caddy's repository, serves HTTPS and static web sites, whose
|
||||
files are uploaded through the web service or WebDAV, an optional PowerDNS
|
||||
name server answers for a zone of the sites, and an optional OpenID
|
||||
Provider signs the directory's users in to other systems. It keeps metrics of the
|
||||
name server answers for a zone of the sites, an optional OpenID
|
||||
Provider signs the directory's users in to other systems, and optional
|
||||
S3-compatible object storage, versitygw installed on demand, keeps buckets
|
||||
of the directory's users. It keeps metrics of the
|
||||
router in PostgreSQL and serves them for an OpenTelemetry Collector, and
|
||||
makes scheduled backups to a local folder, copied to the cloud with rclone,
|
||||
or to a Proxmox Backup Server. Nothing in the package is specific to a cloud
|
||||
|
||||
@@ -13,6 +13,7 @@ src/usr/lib/vpn-router/vpnrouter_backup.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_pki.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_sites.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_dns.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_s3.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/install-packages usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/webdav-auth usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/
|
||||
@@ -27,6 +28,7 @@ src/usr/sbin/vpn-router-backup usr/sbin/
|
||||
src/usr/sbin/vpn-router-ca usr/sbin/
|
||||
src/usr/sbin/vpn-router-caddy usr/sbin/
|
||||
src/usr/sbin/vpn-router-site usr/sbin/
|
||||
src/usr/sbin/vpn-router-s3 usr/sbin/
|
||||
src/usr/lib/vpn-router/configure usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/setup usr/lib/vpn-router/
|
||||
|
||||
@@ -12,5 +12,6 @@ override_dh_installsystemd:
|
||||
dh_installsystemd --name=vpn-router-pki --no-enable --no-start
|
||||
dh_installsystemd --name=vpn-router-caddy --no-enable --no-start
|
||||
dh_installsystemd --name=vpn-router-webdav --no-enable --no-start
|
||||
dh_installsystemd --name=vpn-router-s3 --no-enable --no-start
|
||||
dh_installsystemd --name=vpn-router-resolver --no-enable --no-start
|
||||
dh_installsystemd --name=vpn-router-install@ --no-enable --no-start
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=VPN Router S3-compatible object storage (versitygw)
|
||||
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionFileIsExecutable=/usr/bin/versitygw
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/vpn-router/s3.env
|
||||
ExecStart=/usr/bin/versitygw posix /var/lib/vpn-router/s3/data
|
||||
Restart=on-failure
|
||||
RestartSec=15
|
||||
LimitNOFILE=65536
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/lib/vpn-router/s3
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,33 +1,83 @@
|
||||
#!/usr/bin/python3
|
||||
"""Install the packages of a feature from the Ubuntu archive, then restart vpn-router-setup without waiting.
|
||||
"""Install the packages of a feature, then restart vpn-router-setup without waiting.
|
||||
|
||||
Run by vpn-router-install@<feature>.service, which vpn-router-setup starts when the feature is on and its packages are
|
||||
missing: webdav (rclone) and dns (PowerDNS with its gpgsql backend). Recommended packages are left out, so PowerDNS
|
||||
comes without its bind backend. A failure is a message on standard error and the exit status 1.
|
||||
missing: webdav (rclone) and dns (PowerDNS with its gpgsql backend) from the Ubuntu archive, and s3 (versitygw), whose
|
||||
pinned release (vpnrouter_s3.VERSION) is downloaded for this host's architecture into a new folder, checked against
|
||||
its pinned SHA-256 and installed with apt-get. Recommended packages are left out, so PowerDNS comes without its bind
|
||||
backend. A failure is a message on standard error and the exit status 1.
|
||||
|
||||
install-packages FEATURE
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
|
||||
#: The packages of each feature.
|
||||
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||
|
||||
import vpnrouter_s3
|
||||
|
||||
#: The packages of each feature from the archive.
|
||||
FEATURES = {'webdav': ['rclone'], 'dns': ['pdns-server', 'pdns-backend-pgsql']}
|
||||
|
||||
#: The features installed from a downloaded package.
|
||||
DOWNLOADS = ('s3',)
|
||||
|
||||
#: apt-get, waiting up to ten minutes for dpkg's lock, which a package installation that runs setup holds.
|
||||
APT = ['apt-get', '-o', 'DPkg::Lock::Timeout=600']
|
||||
|
||||
#: The options of apt-get install: no questions, no recommended packages, and the installed configuration files kept.
|
||||
INSTALL = ['-y', '--no-install-recommends', '-o', 'Dpkg::Options::=--force-confdef', '-o',
|
||||
'Dpkg::Options::=--force-confold', 'install']
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2 or sys.argv[1] not in FEATURES:
|
||||
sys.exit(f'usage: install-packages {"|".join(FEATURES)}')
|
||||
environment = dict(os.environ, DEBIAN_FRONTEND='noninteractive')
|
||||
for args in ([*APT, 'update'], [*APT, '-y', '--no-install-recommends', '-o', 'Dpkg::Options::=--force-confdef',
|
||||
'-o', 'Dpkg::Options::=--force-confold', 'install', *FEATURES[sys.argv[1]]]):
|
||||
#: Seconds a download may take.
|
||||
DOWNLOAD_TIMEOUT = 300
|
||||
|
||||
|
||||
def command(args, environment):
|
||||
print(f'$ {" ".join(args)}', flush=True)
|
||||
if subprocess.run(args, env=environment).returncode != 0:
|
||||
sys.exit(f'install-packages: {args[0]} failed')
|
||||
|
||||
|
||||
def download_versitygw(folder):
|
||||
"""Download the pinned versitygw .deb of this host's architecture into the folder and check it; its path."""
|
||||
arch = vpnrouter_s3.architecture()
|
||||
if arch not in vpnrouter_s3.SHA256:
|
||||
sys.exit(f'install-packages: versitygw has no package for {arch}; only {", ".join(vpnrouter_s3.SHA256)}')
|
||||
url = vpnrouter_s3.DEB_URL.format(version=vpnrouter_s3.VERSION, arch=arch)
|
||||
path = os.path.join(folder, os.path.basename(url))
|
||||
print(f'Downloading {url}', flush=True)
|
||||
digest = hashlib.sha256()
|
||||
with urllib.request.urlopen(url, timeout=DOWNLOAD_TIMEOUT) as response, open(path, 'wb') as handle:
|
||||
while chunk := response.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
handle.write(chunk)
|
||||
if digest.hexdigest() != vpnrouter_s3.SHA256[arch]:
|
||||
sys.exit(f'install-packages: {url} has the SHA-256 {digest.hexdigest()}, not {vpnrouter_s3.SHA256[arch]}')
|
||||
os.chmod(folder, 0o755)
|
||||
os.chmod(path, 0o644)
|
||||
return path
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2 or sys.argv[1] not in (*FEATURES, *DOWNLOADS):
|
||||
sys.exit(f'usage: install-packages {"|".join((*FEATURES, *DOWNLOADS))}')
|
||||
environment = dict(os.environ, DEBIAN_FRONTEND='noninteractive')
|
||||
if sys.argv[1] in FEATURES:
|
||||
command([*APT, 'update'], environment)
|
||||
command([*APT, *INSTALL, *FEATURES[sys.argv[1]]], environment)
|
||||
else:
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
try:
|
||||
path = download_versitygw(folder)
|
||||
except OSError as exc:
|
||||
sys.exit(f'install-packages: the download failed: {exc}')
|
||||
command([*APT, *INSTALL, path], environment)
|
||||
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'])
|
||||
return 0
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import vpnrouter_auth
|
||||
import vpnrouter_dns
|
||||
import vpnrouter_ldap
|
||||
import vpnrouter_pki
|
||||
import vpnrouter_s3
|
||||
import vpnrouter_sites
|
||||
import vpnrouter_users
|
||||
from vpnrouter import fail, warn
|
||||
@@ -301,6 +302,7 @@ def filter_block(ctx):
|
||||
(vpnrouter.MANAGER.exists(), ctx['https_manager_port']),
|
||||
(vpnrouter.metrics_ready(ctx), ctx['metrics_https_port']),
|
||||
(vpnrouter.webdav_ready(ctx), ctx['webdav_port']),
|
||||
(vpnrouter.s3_ready(ctx), ctx['s3_port']),
|
||||
('http-80' in publish, vpnrouter.ACME_HTTP_PORT),
|
||||
('https-default' in publish, vpnrouter.HTTPS_DEFAULT_PORT)) if wanted]
|
||||
lines += [
|
||||
@@ -698,8 +700,9 @@ def manage_caddy(cfg, ctx):
|
||||
stop it when not.
|
||||
|
||||
While HTTPS is ready, CADDYFILE serves the web service on https.web_port, while the manager is installed the
|
||||
manager on 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
|
||||
manager on https.manager_port, while metrics are enabled the metrics service on metrics.https_port, while
|
||||
WebDAV is ready WebDAV on sites.webdav_port, and while the object storage is ready and versitygw is installed its
|
||||
S3 API on s3.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
|
||||
the endpoints of pki.publish that are Caddy's, and an ACME server at /acme/<name>/ on https.web_port for each
|
||||
issuing CA with acme and a certificate.
|
||||
@@ -785,6 +788,8 @@ def manage_caddy(cfg, ctx):
|
||||
'key': str(CADDY_PKI_DIR / name / 'key.pem')} for name in authorities],
|
||||
acme_cas=[name for name in authorities if https and name in ctx['pki_acme']],
|
||||
webdav=vpnrouter.webdav_ready(ctx), webdav_loopback_port=vpnrouter.WEBDAV_LOOPBACK_PORT,
|
||||
s3=vpnrouter.s3_ready(ctx) and vpnrouter_s3.installed() == vpnrouter_s3.VERSION,
|
||||
s3_loopback_port=vpnrouter_s3.S3_PORT,
|
||||
sites=[{'name': site['name'], 'tls': site['tls'], 'ca': site['ca'], 'spa': site['spa'],
|
||||
'index': site['index_file'], 'root': str(vpnrouter_sites.SITES_DIR / site['name']),
|
||||
'addresses': [caddy_address(host, site['port'], site['tls']) for host in site['hosts']]
|
||||
@@ -820,6 +825,34 @@ def manage_webdav(ctx):
|
||||
run(['systemctl', 'enable', '--now', WEBDAV_UNIT], check=False)
|
||||
|
||||
|
||||
def manage_s3(ctx):
|
||||
"""Run versitygw, the S3-compatible object storage (vpnrouter_s3), while it is ready, and stop it when not.
|
||||
|
||||
The pinned versitygw is installed by vpn-router-install@s3 when another version or none is installed. ROOT_ENV is
|
||||
written with the root keys it has, or new ones, and s3.region; a change restarts the service. Once it runs, the
|
||||
S3 accounts are made to match their users (vpnrouter_s3.sync_all).
|
||||
"""
|
||||
if not vpnrouter.s3_ready(ctx):
|
||||
if unit_is_active(vpnrouter_s3.UNIT):
|
||||
run(['systemctl', 'disable', '--now', vpnrouter_s3.UNIT], check=False)
|
||||
return
|
||||
if vpnrouter_s3.installed() != vpnrouter_s3.VERSION:
|
||||
install_packages('s3')
|
||||
return
|
||||
vpnrouter_s3.ensure_folders()
|
||||
changed = vpnrouter_s3.write_env(ctx['s3_region'])
|
||||
if not unit_is_active(vpnrouter_s3.UNIT):
|
||||
run(['systemctl', 'enable', '--now', vpnrouter_s3.UNIT], check=False)
|
||||
elif changed:
|
||||
run(['systemctl', 'restart', vpnrouter_s3.UNIT], check=False)
|
||||
for _ in range(10):
|
||||
if unit_is_active(vpnrouter_s3.UNIT):
|
||||
break
|
||||
time.sleep(1)
|
||||
for problem in vpnrouter_s3.sync_all():
|
||||
warn(problem)
|
||||
|
||||
|
||||
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
|
||||
is enabled, and stop it when not; its packages are installed when they are missing.
|
||||
@@ -1072,6 +1105,8 @@ def main():
|
||||
manage_caddy(cfg, ctx)
|
||||
progress('running', 'WebDAV')
|
||||
manage_webdav(ctx)
|
||||
progress('running', 'object storage')
|
||||
manage_s3(ctx)
|
||||
progress('running', 'name server')
|
||||
manage_dns(cfg, ctx)
|
||||
progress('running', 'PKI timer')
|
||||
|
||||
@@ -69,6 +69,7 @@ SCHEMA = {
|
||||
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'},
|
||||
'oidc': {'enabled': False},
|
||||
'sites': {'enabled': False, 'upload_max_mb': 512, 'webdav': False, 'webdav_port': 7445},
|
||||
's3': {'enabled': False, 'port': 7447, 'region': 'us-east-1'},
|
||||
'dns': {'enabled': False, 'nameserver': '', 'addresses': [], 'listen': 'external', 'ttl': 300},
|
||||
'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',
|
||||
@@ -80,7 +81,7 @@ SCHEMA = {
|
||||
'keep_monthly': '1 year', 'keep_yearly': '10 years'},
|
||||
'backup': {'enabled': False, 'schedule': 'daily', 'target': 'local', 'path': '/var/backups/vpn-router',
|
||||
'keep_hourly': 24, 'keep_daily': 7, 'keep_weekly': 4, 'keep_monthly': 12, 'keep_yearly': 0,
|
||||
'metrics': False, 'rclone_remote': '', 'rclone_mode': 'sync',
|
||||
'metrics': False, 's3': False, 'rclone_remote': '', 'rclone_mode': 'sync',
|
||||
'pbs_repository': '', 'pbs_password': '', 'pbs_fingerprint': '', 'pbs_namespace': '',
|
||||
'pbs_keyfile': ''},
|
||||
}
|
||||
@@ -140,6 +141,9 @@ LOG_FILES_MAX = 32
|
||||
#: The port rclone serves WebDAV on, on the loopback address, behind Caddy.
|
||||
WEBDAV_LOOPBACK_PORT = 7446
|
||||
|
||||
#: A region name of s3.region: lowercase letters, digits and hyphens.
|
||||
S3_REGION = re.compile(r'^[a-z0-9][a-z0-9-]{0,31}$')
|
||||
|
||||
#: The largest sites.upload_max_mb, 10 GiB.
|
||||
UPLOAD_MAX_MB = 10240
|
||||
|
||||
@@ -576,6 +580,11 @@ def validate(cfg):
|
||||
if _get(cfg, 'sites', 'webdav') and not (_get(cfg, 'https', 'enabled') and _get(cfg, 'wan', 'local_fqdn')):
|
||||
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')
|
||||
if _get(cfg, 's3', 'enabled') and not (_get(cfg, 'https', 'enabled') and _get(cfg, 'wan', 'local_fqdn')):
|
||||
raise ConfigError('s3.enabled: the object storage is served by Caddy at https://<wan.local_fqdn>:<s3.port>, '
|
||||
'which needs https.enabled and wan.local_fqdn')
|
||||
if not S3_REGION.match(_get(cfg, 's3', 'region')):
|
||||
raise ConfigError(f's3.region: {_get(cfg, "s3", "region")!r} is not a region name, such as us-east-1')
|
||||
|
||||
dns = cfg['dns']
|
||||
if dns['enabled']:
|
||||
@@ -695,7 +704,8 @@ def validate(cfg):
|
||||
if _get(cfg, 'openvpn', 'protocol') == 'tcp' and _get(cfg, 'openvpn', 'port') == HTTPS_DEFAULT_PORT:
|
||||
raise ConfigError(f'pki.publish: https-default takes port {HTTPS_DEFAULT_PORT}, the port of openvpn.port')
|
||||
taken[HTTPS_DEFAULT_PORT] = 'pki.publish https-default'
|
||||
for section_name, option in (('metrics', 'port'), ('metrics', 'https_port'), ('sites', 'webdav_port')):
|
||||
for section_name, option in (('metrics', 'port'), ('metrics', 'https_port'), ('sites', 'webdav_port'),
|
||||
('s3', 'port')):
|
||||
port = _get(cfg, section_name, option)
|
||||
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
|
||||
raise ConfigError(f'{section_name}.{option}: {port!r} is not a valid port')
|
||||
@@ -1045,6 +1055,9 @@ def build_context(cfg, public_ip=None):
|
||||
'sites_enabled': _get(cfg, 'sites', 'enabled'),
|
||||
'webdav_enabled': _get(cfg, 'sites', 'webdav'),
|
||||
'webdav_port': _get(cfg, 'sites', 'webdav_port'),
|
||||
's3_enabled': _get(cfg, 's3', 'enabled'),
|
||||
's3_port': _get(cfg, 's3', 'port'),
|
||||
's3_region': _get(cfg, 's3', 'region'),
|
||||
'dns_enabled': _get(cfg, 'dns', 'enabled'),
|
||||
'dns_listen': _get(cfg, 'dns', 'listen'),
|
||||
'loopback': LOOPBACK,
|
||||
@@ -1091,6 +1104,11 @@ def webdav_ready(ctx):
|
||||
return bool(ctx['webdav_enabled'] and https_ready(ctx))
|
||||
|
||||
|
||||
def s3_ready(ctx):
|
||||
"""The object storage is served by Caddy, on the router's own name, while it is on and HTTPS is ready."""
|
||||
return bool(ctx['s3_enabled'] and https_ready(ctx))
|
||||
|
||||
|
||||
def dns_ready(ctx):
|
||||
"""The name server answers for its domains while dns.enabled is true."""
|
||||
return bool(ctx['dns_enabled'])
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""The backups of the router, for vpn-router-backup and the web service.
|
||||
|
||||
A backup is a consistent copy of the state that setup cannot make again, staged in STAGING: the
|
||||
trees of TREES, the folders of the web sites among them, the files of site_to_site psk_file outside them under files/,
|
||||
trees of TREES, the folders of the web sites and the accounts of the object storage among them, with backup.s3 its
|
||||
buckets, the files of site_to_site psk_file outside them under files/,
|
||||
the directory as slapcat writes it (ldap/vpn-router.ldif), with backup.metrics the database as pg_dump writes it
|
||||
(postgresql/vpn-router.dump), the databases of the web sites and of the name server (DUMPS) once they exist, and
|
||||
manifest.json. The trees and the directory are copied while
|
||||
@@ -36,6 +37,7 @@ import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_dns
|
||||
import vpnrouter_ldap
|
||||
import vpnrouter_s3
|
||||
import vpnrouter_sites
|
||||
import vpnrouter_users
|
||||
|
||||
@@ -51,9 +53,10 @@ PBS_CLIENT = pathlib.Path('/usr/bin/proxmox-backup-client')
|
||||
UNIT = 'vpn-router-backup.service'
|
||||
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; with backup.s3 also the buckets
|
||||
#: of the object storage (vpnrouter_s3.DATA_DIR).
|
||||
TREES = ('/etc/vpn-router', '/etc/wireguard', '/etc/ufw', '/etc/default/ufw', '/etc/vpn-router-manager',
|
||||
str(vpnrouter_sites.SITES_DIR))
|
||||
str(vpnrouter_sites.SITES_DIR), str(vpnrouter_s3.IAM_DIR))
|
||||
|
||||
#: 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),
|
||||
@@ -73,7 +76,7 @@ PERIODS = {'keep_hourly': '%Y-%m-%d %H', 'keep_daily': '%Y-%m-%d', 'keep_weekly'
|
||||
|
||||
#: The units a restore stops while it replaces the directory and the database, and the ones it restarts after setup.
|
||||
RESTORE_STOPS = ('vpn-router-web.service', 'vpn-router-metrics.service', 'vpn-router-webdav.service', 'pdns.service',
|
||||
'slapd.service')
|
||||
'vpn-router-s3.service', 'slapd.service')
|
||||
RESTORE_RESTARTS = ('wg-quick@wg0.service', 'openvpn-server@vpn-router.service', 'strongswan.service',
|
||||
'vpn-router-manager.service')
|
||||
|
||||
@@ -220,7 +223,8 @@ def stage(cfg):
|
||||
"""Copy what a backup holds to STAGING, emptied first, and return the manifest."""
|
||||
shutil.rmtree(STAGING, ignore_errors=True)
|
||||
STAGING.mkdir(parents=True, mode=0o700)
|
||||
trees = [tree for tree in TREES if os.path.exists(tree)]
|
||||
trees = [tree for tree in (*TREES, *((str(vpnrouter_s3.DATA_DIR),) if cfg['backup']['s3'] else ()))
|
||||
if os.path.exists(tree)]
|
||||
files = psk_files(cfg)
|
||||
vpnrouter_users.LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with vpnrouter.config_lock(), open(vpnrouter_users.LOCK_FILE, 'w') as users:
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
"""The router's S3-compatible object storage, served by versitygw, for setup, vpn-router-s3 and the web service.
|
||||
|
||||
versitygw runs as vpn-router-s3.service with its POSIX backend: each folder of DATA_DIR is a bucket and the files
|
||||
below it are its objects. Its internal IAM in IAM_DIR holds an account per access key; the root keys in ROOT_ENV are
|
||||
not stored there. Its S3 API listens on S3_PORT and its Admin API on ADMIN_PORT, both on the loopback address; Caddy
|
||||
serves the S3 API at https://<wan.local_fqdn>:<s3.port>.
|
||||
|
||||
Each user or service account of the router's directory has at most one S3 account, an access key and its secret,
|
||||
recorded in ACCOUNTS with the time it was made. The secret is shown once, when the account is made or its secret
|
||||
replaced; ACCOUNTS keeps it, as the IAM does, so the account can be switched off and on with its user (sync). A
|
||||
bucket belongs to one S3 account, which alone may use it; the root keys reach every bucket.
|
||||
|
||||
The Admin API and the S3 API are called with AWS Signature Version 4, signed with the root keys for the region
|
||||
s3.region. A request that cannot be done raises Failure, whose status says why: 2 invalid, 3 no such user, account or
|
||||
bucket, 4 it exists or the state does not allow it, 1 other.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import datetime
|
||||
import fcntl
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ElementTree
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_users
|
||||
|
||||
#: The pinned release of versitygw: its version and the SHA-256 of its .deb for each Debian architecture.
|
||||
VERSION = '1.10.0'
|
||||
SHA256 = {'amd64': 'd8a39e9cc7da6f17467e60795cb703cfcb29b3fe5db8227e341d90bc64727140',
|
||||
'arm64': '5f81e0dec09d8f1f8ca3159de209543ceb03714dc0066cf7d353b8ef064a0136'}
|
||||
DEB_URL = 'https://github.com/versity/versitygw/releases/download/v{version}/versitygw_{version}_linux_{arch}.deb'
|
||||
|
||||
#: The versitygw binary the .deb installs.
|
||||
BINARY = pathlib.Path('/usr/bin/versitygw')
|
||||
|
||||
UNIT = 'vpn-router-s3.service'
|
||||
INSTALL_UNIT = 'vpn-router-install@s3.service'
|
||||
|
||||
#: The buckets, one folder each, and the accounts of versitygw's internal IAM.
|
||||
S3_DIR = pathlib.Path('/var/lib/vpn-router/s3')
|
||||
DATA_DIR = S3_DIR / 'data'
|
||||
IAM_DIR = S3_DIR / 'iam'
|
||||
|
||||
#: The environment of vpn-router-s3.service, which setup writes: the root keys, the ports, the IAM and the region.
|
||||
ROOT_ENV = pathlib.Path('/etc/vpn-router/s3.env')
|
||||
|
||||
#: The S3 account of each user: name -> {"access_key", "secret_key", "created"}.
|
||||
ACCOUNTS = pathlib.Path('/etc/vpn-router/s3-accounts.json')
|
||||
ACCOUNTS_LOCK = pathlib.Path('/run/vpn-router/s3-accounts.lock')
|
||||
|
||||
S3_PORT = 7448
|
||||
ADMIN_PORT = 7449
|
||||
|
||||
#: The folder versitygw keeps the parts of multipart uploads in, inside each bucket.
|
||||
TEMPORARY = '.sgwtmp'
|
||||
|
||||
#: A bucket name versitygw and S3 accept: 3 to 63 lowercase letters, digits, dots and hyphens, from and to a letter or
|
||||
#: a digit, and not an IPv4 address.
|
||||
BUCKET_NAME = re.compile(r'^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$')
|
||||
|
||||
#: Seconds a call of versitygw may take.
|
||||
TIMEOUT = 30
|
||||
|
||||
#: The characters of the access keys and the secrets made here.
|
||||
KEY_CHARACTERS = string.ascii_uppercase + string.digits
|
||||
SECRET_CHARACTERS = string.ascii_letters + string.digits
|
||||
|
||||
|
||||
class Failure(Exception):
|
||||
"""A request that cannot be done; status is 2 invalid, 3 no such user, account or bucket, 4 exists or not allowed,
|
||||
1 other."""
|
||||
|
||||
def __init__(self, message, status=1):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
def architecture():
|
||||
"""The Debian architecture of this host, such as amd64."""
|
||||
return subprocess.run(['dpkg', '--print-architecture'], capture_output=True, text=True, check=True).stdout.strip()
|
||||
|
||||
|
||||
def installed():
|
||||
"""The installed version of versitygw, or None when it is not installed."""
|
||||
result = subprocess.run(['dpkg-query', '-W', '-f=${Status}\t${Version}', 'versitygw'], capture_output=True, text=True)
|
||||
status, _, version = result.stdout.partition('\t')
|
||||
return version if result.returncode == 0 and status == 'install ok installed' else None
|
||||
|
||||
|
||||
def running():
|
||||
"""Whether vpn-router-s3.service is active."""
|
||||
result = subprocess.run(['systemctl', 'is-active', UNIT], capture_output=True, text=True)
|
||||
return result.stdout.strip() == 'active'
|
||||
|
||||
|
||||
def read_env():
|
||||
"""The settings of ROOT_ENV, {} while it does not exist."""
|
||||
try:
|
||||
text = ROOT_ENV.read_text(encoding='utf-8')
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
return dict(line.split('=', 1) for line in text.splitlines() if '=' in line and not line.startswith('#'))
|
||||
|
||||
|
||||
def write_env(region):
|
||||
"""Write ROOT_ENV with the root keys it has, or new ones, and the region; whether it changed."""
|
||||
current = read_env()
|
||||
values = {'ROOT_ACCESS_KEY_ID': current.get('ROOT_ACCESS_KEY_ID') or 'VRROOT' + random_text(KEY_CHARACTERS, 14),
|
||||
'ROOT_SECRET_ACCESS_KEY': current.get('ROOT_SECRET_ACCESS_KEY') or random_text(SECRET_CHARACTERS, 40),
|
||||
'VGW_PORT': f'{vpnrouter.LOOPBACK}:{S3_PORT}', 'VGW_ADMIN_PORT': f'{vpnrouter.LOOPBACK}:{ADMIN_PORT}',
|
||||
'VGW_IAM_DIR': str(IAM_DIR), 'VGW_REGION': region}
|
||||
if values == current:
|
||||
return False
|
||||
ROOT_ENV.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = ROOT_ENV.with_name(ROOT_ENV.name + '.new')
|
||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(descriptor, 'w', encoding='utf-8') as handle:
|
||||
handle.write('# Written by vpn-router-setup for vpn-router-s3.service; edits are overwritten.\n')
|
||||
handle.writelines(f'{key}={value}\n' for key, value in values.items())
|
||||
os.replace(temporary, ROOT_ENV)
|
||||
return True
|
||||
|
||||
|
||||
def random_text(characters, length):
|
||||
return ''.join(secrets.choice(characters) for _ in range(length))
|
||||
|
||||
|
||||
def ensure_folders():
|
||||
"""Make DATA_DIR and IAM_DIR, readable by root only."""
|
||||
for path in (S3_DIR, DATA_DIR, IAM_DIR):
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def accounts_lock():
|
||||
"""Hold the lock that serializes the changes of ACCOUNTS."""
|
||||
ACCOUNTS_LOCK.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(ACCOUNTS_LOCK, 'w') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
yield
|
||||
|
||||
|
||||
def accounts():
|
||||
"""The S3 accounts of ACCOUNTS by user name, {} while it does not exist."""
|
||||
try:
|
||||
data = json.loads(ACCOUNTS.read_text(encoding='utf-8'))
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except ValueError as exc:
|
||||
raise Failure(f'{ACCOUNTS} cannot be read: {exc}')
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def save_accounts(data):
|
||||
"""Write ACCOUNTS by a rename, readable by root only."""
|
||||
ACCOUNTS.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = ACCOUNTS.with_name(ACCOUNTS.name + '.new')
|
||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(descriptor, 'w', encoding='utf-8') as handle:
|
||||
json.dump(data, handle, indent=2)
|
||||
handle.write('\n')
|
||||
os.replace(temporary, ACCOUNTS)
|
||||
|
||||
|
||||
def uri_encode(text, slash=True):
|
||||
"""Text encoded as Signature Version 4 encodes it: every byte but A-Z, a-z, 0-9, -, ., _ and ~, and / when slash."""
|
||||
return urllib.parse.quote(text, safe='-._~' + ('' if slash else '/'))
|
||||
|
||||
|
||||
def signed_headers(method, host, path, query, body, headers, access, secret, region, now):
|
||||
"""The headers of a request signed with AWS Signature Version 4 for the service s3 at the UTC time now: host,
|
||||
x-amz-content-sha256, x-amz-date and the headers given, all signed, and Authorization."""
|
||||
stamp, day = now.strftime('%Y%m%dT%H%M%SZ'), now.strftime('%Y%m%d')
|
||||
digest = hashlib.sha256(body).hexdigest()
|
||||
signed = {'host': host, 'x-amz-content-sha256': digest, 'x-amz-date': stamp,
|
||||
**{key.lower(): value for key, value in headers.items()}}
|
||||
names = sorted(signed)
|
||||
canonical = '\n'.join([method, uri_encode(path, slash=False),
|
||||
'&'.join(f'{uri_encode(key)}={uri_encode(value)}' for key, value in sorted(query.items())),
|
||||
''.join(f'{name}:{" ".join(str(signed[name]).split())}\n' for name in names),
|
||||
';'.join(names), digest])
|
||||
scope = f'{day}/{region}/s3/aws4_request'
|
||||
to_sign = '\n'.join(['AWS4-HMAC-SHA256', stamp, scope, hashlib.sha256(canonical.encode()).hexdigest()])
|
||||
key = ('AWS4' + secret).encode()
|
||||
for part in (day, region, 's3', 'aws4_request'):
|
||||
key = hmac.new(key, part.encode(), hashlib.sha256).digest()
|
||||
signature = hmac.new(key, to_sign.encode(), hashlib.sha256).hexdigest()
|
||||
return {**signed, 'Authorization': f'AWS4-HMAC-SHA256 Credential={access}/{scope}, '
|
||||
f'SignedHeaders={";".join(names)}, Signature={signature}'}
|
||||
|
||||
|
||||
def call(method, port, path, query=None, body=b'', headers=None):
|
||||
"""Make a request of versitygw on the port, signed with the root keys; the status and the body of the answer.
|
||||
|
||||
Failure with versitygw's message when it answers an error, and when it cannot be reached.
|
||||
"""
|
||||
env = read_env()
|
||||
access, secret, region = env.get('ROOT_ACCESS_KEY_ID'), env.get('ROOT_SECRET_ACCESS_KEY'), env.get('VGW_REGION')
|
||||
if not access or not secret or not region:
|
||||
raise Failure('the object storage is not set up; setup writes its keys once s3.enabled is on', 4)
|
||||
host = f'{vpnrouter.LOOPBACK}:{port}'
|
||||
signed = signed_headers(method, host, path, query or {}, body, headers or {}, access, secret, region,
|
||||
datetime.datetime.now(datetime.timezone.utc))
|
||||
canonical_query = '&'.join(f'{uri_encode(key)}={uri_encode(value)}' for key, value in sorted((query or {}).items()))
|
||||
url = f'http://{host}{uri_encode(path, slash=False)}' + (f'?{canonical_query}' if canonical_query else '')
|
||||
request = urllib.request.Request(url, data=body if body else None, method=method,
|
||||
headers={key: value for key, value in signed.items() if key != 'host'})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
|
||||
return response.status, response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
answer = exc.read()
|
||||
code, message = error_of(answer)
|
||||
status = 3 if exc.code == 404 or code in ('NoSuchBucket', 'XAdminUserNotFound') else \
|
||||
4 if exc.code == 409 or code in ('XAdminUserExists', 'BucketAlreadyExists', 'BucketNotEmpty') else \
|
||||
2 if exc.code == 400 else 1
|
||||
raise Failure(f'versitygw refused {method} {path}: {message or code or exc.reason}', status)
|
||||
except OSError as exc:
|
||||
raise Failure(f'versitygw cannot be reached on {host}: {exc}; is {UNIT} running?')
|
||||
|
||||
|
||||
def local_name(element):
|
||||
return element.tag.rsplit('}', 1)[-1]
|
||||
|
||||
|
||||
def error_of(body):
|
||||
"""The code and the message of an S3 or Admin API error document, empty strings when there is none."""
|
||||
try:
|
||||
root = ElementTree.fromstring(body)
|
||||
except ElementTree.ParseError:
|
||||
return '', body.decode(errors='replace').strip()
|
||||
fields = {local_name(child): (child.text or '').strip() for child in root}
|
||||
return fields.get('Code', ''), fields.get('Message', '')
|
||||
|
||||
|
||||
def admin(path, query=None, body=b''):
|
||||
"""Call the Admin API: PATCH of the path; the body of the answer."""
|
||||
headers = {'Content-Type': 'application/xml'} if body else None
|
||||
return call('PATCH', ADMIN_PORT, path, query, body, headers)[1]
|
||||
|
||||
|
||||
def account_xml(root, **fields):
|
||||
"""An XML document of the root element with the fields as its children."""
|
||||
element = ElementTree.Element(root)
|
||||
for name, value in fields.items():
|
||||
ElementTree.SubElement(element, name).text = value
|
||||
return ElementTree.tostring(element)
|
||||
|
||||
|
||||
def user_state(connection, name):
|
||||
"""Whether the user or service account exists and is enabled: None when it does not exist, else True or False."""
|
||||
try:
|
||||
return vpnrouter_users.summary(vpnrouter_users.entry(connection, name))['enabled']
|
||||
except vpnrouter_users.Failure as failure:
|
||||
if failure.status == 3:
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def shown(name, account):
|
||||
"""An account as the answers show it, without its secret."""
|
||||
return {'owner': name, 'access_key': account['access_key'], 'created': account['created']}
|
||||
|
||||
|
||||
def list_accounts(owner=None):
|
||||
"""The accounts, each {owner, access_key, created}, sorted by owner; with owner, only that user's."""
|
||||
return [shown(name, account) for name, account in sorted(accounts().items()) if owner in (None, name)]
|
||||
|
||||
|
||||
def account_add(name):
|
||||
"""Make the S3 account of the user, who must exist and have none: {owner, access_key, secret_key, created}."""
|
||||
with vpnrouter_users.session() as connection:
|
||||
if user_state(connection, name) is None:
|
||||
raise Failure(f'user {name} does not exist', 3)
|
||||
with accounts_lock():
|
||||
data = accounts()
|
||||
if name in data:
|
||||
raise Failure(f'{name} has an S3 account, {data[name]["access_key"]}; replace its secret or remove it', 4)
|
||||
account = {'access_key': 'VR' + random_text(KEY_CHARACTERS, 18),
|
||||
'secret_key': random_text(SECRET_CHARACTERS, 40),
|
||||
'created': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}
|
||||
admin('/create-user', body=account_xml('Account', Access=account['access_key'], Secret=account['secret_key'],
|
||||
Role='user'))
|
||||
data[name] = account
|
||||
save_accounts(data)
|
||||
return {**shown(name, account), 'secret_key': account['secret_key']}
|
||||
|
||||
|
||||
def account_rotate(name):
|
||||
"""Replace the secret of the user's S3 account: {owner, access_key, secret_key, created}."""
|
||||
with accounts_lock():
|
||||
data = accounts()
|
||||
if name not in data:
|
||||
raise Failure(f'{name} has no S3 account', 3)
|
||||
account = dict(data[name], secret_key=random_text(SECRET_CHARACTERS, 40))
|
||||
admin('/update-user', {'access': account['access_key']},
|
||||
account_xml('MutableProps', Secret=account['secret_key']))
|
||||
data[name] = account
|
||||
save_accounts(data)
|
||||
sync(name)
|
||||
return {**shown(name, account), 'secret_key': account['secret_key']}
|
||||
|
||||
|
||||
def account_remove(name):
|
||||
"""Remove the user's S3 account; its buckets keep it as their owner until another is chosen."""
|
||||
with accounts_lock():
|
||||
data = accounts()
|
||||
if name not in data:
|
||||
raise Failure(f'{name} has no S3 account', 3)
|
||||
try:
|
||||
admin('/delete-user', {'access': data[name]['access_key']})
|
||||
except Failure as failure:
|
||||
if failure.status != 3:
|
||||
raise
|
||||
account = data.pop(name)
|
||||
save_accounts(data)
|
||||
return {**shown(name, account), 'removed': True}
|
||||
|
||||
|
||||
def sync(name):
|
||||
"""Bring the user's S3 account in line with the user: removed with a user that no longer exists, its secret
|
||||
replaced by an unknown one while the user is disabled, and its own secret while it is enabled. Nothing for a user
|
||||
without an account, or while versitygw does not run."""
|
||||
if name not in accounts() or not running():
|
||||
return
|
||||
with vpnrouter_users.session() as connection:
|
||||
state = user_state(connection, name)
|
||||
if state is None:
|
||||
account_remove(name)
|
||||
return
|
||||
secret = accounts()[name]['secret_key'] if state else random_text(SECRET_CHARACTERS, 40)
|
||||
admin('/update-user', {'access': accounts()[name]['access_key']}, account_xml('MutableProps', Secret=secret))
|
||||
|
||||
|
||||
#: The vpnrouter_users operations after which the user's S3 account follows the user (follow).
|
||||
FOLLOWING = ('disable', 'enable', 'remove')
|
||||
|
||||
|
||||
def follow(name):
|
||||
"""sync() the user's account after a vpnrouter_users operation of FOLLOWING; why it failed, or None. A failure
|
||||
leaves the account as it is until setup's next run synchronizes it."""
|
||||
try:
|
||||
sync(name)
|
||||
except (Failure, vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc:
|
||||
return f'the S3 account of {name} cannot follow the user: {exc}; setup synchronizes it at its next run'
|
||||
return None
|
||||
|
||||
|
||||
def sync_all():
|
||||
"""sync() each account, as setup does once versitygw runs; the accounts versitygw lacks, as after a restore of the
|
||||
configuration only, are made again. The failures are answered as messages."""
|
||||
problems = []
|
||||
try:
|
||||
root = ElementTree.fromstring(admin('/list-users'))
|
||||
known = {(child.findtext('Access') or '').strip() for child in root if local_name(child) == 'Account'}
|
||||
for name, account in accounts().items():
|
||||
if account['access_key'] not in known:
|
||||
admin('/create-user', body=account_xml('Account', Access=account['access_key'],
|
||||
Secret=account['secret_key'], Role='user'))
|
||||
except (Failure, ElementTree.ParseError) as exc:
|
||||
return [f'the S3 accounts cannot be checked: {exc}']
|
||||
for name in list(accounts()):
|
||||
try:
|
||||
sync(name)
|
||||
except (Failure, vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc:
|
||||
problems.append(f'the S3 account of {name} cannot be synchronized: {exc}')
|
||||
return problems
|
||||
|
||||
|
||||
def check_bucket(name):
|
||||
if not isinstance(name, str) or not BUCKET_NAME.match(name) or '..' in name or \
|
||||
re.fullmatch(r'\d+\.\d+\.\d+\.\d+', name):
|
||||
raise Failure(f'{name!r} is not a bucket name: 3 to 63 lowercase letters, digits, dots and hyphens, from and to '
|
||||
'a letter or a digit', 2)
|
||||
|
||||
|
||||
def usage(bucket):
|
||||
"""The size in bytes and the number of objects of the bucket, from its folder, without the uploads in progress."""
|
||||
size = count = 0
|
||||
for folder, folders, files in os.walk(DATA_DIR / bucket):
|
||||
if folder == str(DATA_DIR / bucket):
|
||||
folders[:] = [item for item in folders if item != TEMPORARY]
|
||||
for item in files:
|
||||
try:
|
||||
size += os.lstat(os.path.join(folder, item)).st_size
|
||||
count += 1
|
||||
except OSError:
|
||||
pass
|
||||
return size, count
|
||||
|
||||
|
||||
def buckets():
|
||||
"""The buckets, each {name, owner, access_key, size, objects}, sorted by name; owner is the user of the account
|
||||
that owns it, None for an account no user has."""
|
||||
root = ElementTree.fromstring(admin('/list-buckets'))
|
||||
owners = {account['access_key']: name for name, account in accounts().items()}
|
||||
found = []
|
||||
for child in root:
|
||||
if local_name(child) != 'Bucket':
|
||||
continue
|
||||
name, access = (child.findtext('Name') or '').strip(), (child.findtext('Owner') or '').strip()
|
||||
size, count = usage(name)
|
||||
found.append({'name': name, 'owner': owners.get(access), 'access_key': access, 'size': size, 'objects': count})
|
||||
return sorted(found, key=lambda bucket: bucket['name'])
|
||||
|
||||
|
||||
def owner_key(owner):
|
||||
"""The access key of the S3 account of the user; Failure when the user has none."""
|
||||
account = accounts().get(owner)
|
||||
if account is None:
|
||||
raise Failure(f'{owner} has no S3 account; make one first', 3)
|
||||
return account['access_key']
|
||||
|
||||
|
||||
def bucket_add(name, owner):
|
||||
"""Make the bucket, owned by the user's S3 account."""
|
||||
check_bucket(name)
|
||||
call('PATCH', ADMIN_PORT, f'/{name}/create', headers={'x-vgw-owner': owner_key(owner)})
|
||||
return {'name': name, 'owner': owner}
|
||||
|
||||
|
||||
def bucket_owner(name, owner):
|
||||
"""Give the bucket to the user's S3 account; versitygw replaces its ACL and policy with the default ones."""
|
||||
check_bucket(name)
|
||||
admin('/change-bucket-owner', {'bucket': name, 'owner': owner_key(owner)})
|
||||
return {'name': name, 'owner': owner}
|
||||
|
||||
|
||||
def bucket_remove(name):
|
||||
"""Remove the bucket, which must be empty."""
|
||||
check_bucket(name)
|
||||
call('DELETE', S3_PORT, f'/{name}')
|
||||
return {'name': name, 'removed': True}
|
||||
@@ -55,6 +55,8 @@ Pages:
|
||||
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
|
||||
remove, each with its secondaries, the records it serves, and its manual records (see dns())
|
||||
/s3 the S3-compatible object storage (vpnrouter_s3): its settings, its buckets and the users' S3 accounts;
|
||||
/account/s3 is the signed-in user's own account (see s3() and account_s3())
|
||||
/postgresql
|
||||
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
|
||||
postgresql_databases()); /config has the clusters' settings
|
||||
@@ -63,7 +65,7 @@ Pages:
|
||||
loopback address, where Caddy passes the requests of the HTTPS site; POST /oidc/login counts
|
||||
towards the limit of /auth calls
|
||||
|
||||
/status/full, /config, /metrics/history, /metrics/settings, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /remote-access, /ca, /logs, /sites, /dns, /shell and /account need an HS256 JWT in an Authorization: Bearer header signed
|
||||
/status/full, /config, /metrics/history, /metrics/settings, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /remote-access, /ca, /logs, /sites, /dns, /s3, /shell and /account need an HS256 JWT in an Authorization: Bearer header signed
|
||||
with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub;
|
||||
/account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A
|
||||
token of vpn-router-token has the admin claim and names no user; it may call every path but
|
||||
@@ -95,6 +97,7 @@ Modules, one per family of paths, with what they share in common:
|
||||
logs /logs
|
||||
sites /sites, with the files of each site
|
||||
dns /dns
|
||||
s3 /s3 and /account/s3
|
||||
metrics /metrics/history and /metrics/settings
|
||||
shell /shell, its WebSocket, and the editor of the shell with its socket
|
||||
handler Handler, which reads a request, checks its token and passes it to the module of its path
|
||||
|
||||
@@ -24,6 +24,7 @@ from .identities import account, groups, service_accounts, users
|
||||
from .logs import logs
|
||||
from .metrics import metrics_history, metrics_settings
|
||||
from .pki import PUBLISHED, ca
|
||||
from .s3 import account_s3, s3
|
||||
from .shell import EDITOR_BODY_LIMIT, shell, shell_editor, shell_ticket
|
||||
from .sites import SITE_FILES, site_files, sites
|
||||
from .status import cached, details, html_of, postgresql_databases, shown, text_of
|
||||
@@ -64,7 +65,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if self.command not in ('GET', 'HEAD'):
|
||||
self.close_connection = True
|
||||
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', 's3')
|
||||
if path == f'/{name}' or path.startswith(f'/{name}/')), None)
|
||||
protected = path in ('/status/full', '/config', '/metrics/history', '/metrics/settings', '/postgresql') or \
|
||||
directory is not None
|
||||
@@ -124,6 +125,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if code != 200:
|
||||
log(f'shell ticket of {claims.get("sub")} from {self.client()} refused: {document["error"]}')
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'account' and path == '/account/s3':
|
||||
code, document = account_s3(claims['sub'], self.command)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'account':
|
||||
code, document = account(claims['sub'], self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
@@ -154,6 +158,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif directory == 'dns':
|
||||
code, document = dns(self.command, path, query, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 's3':
|
||||
code, document = s3(self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'service-accounts':
|
||||
code, document = service_accounts(self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
|
||||
@@ -7,9 +7,13 @@ import ldap
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_passkeys
|
||||
import vpnrouter_s3
|
||||
import vpnrouter_users
|
||||
|
||||
from .common import USER_STATUS
|
||||
from .common import USER_STATUS, log
|
||||
|
||||
#: The routes of /users and /service-accounts after which the S3 account of the user follows it (vpnrouter_s3.follow).
|
||||
FOLLOWING = (('POST', 3, 'disable'), ('POST', 3, 'enable'), ('DELETE', 2, None))
|
||||
|
||||
|
||||
def users(state, method, path, body):
|
||||
@@ -77,6 +81,8 @@ def users(state, method, path, body):
|
||||
document = actions[route](connection)
|
||||
if operations.get(route) in vpnrouter_auth.REVOKING:
|
||||
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
|
||||
if route in FOLLOWING and (problem := vpnrouter_s3.follow(name)):
|
||||
log(problem)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
@@ -146,6 +152,8 @@ def service_accounts(method, path, body):
|
||||
if name is not None and not vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
|
||||
raise vpnrouter_users.Failure(f'{name} is not a service account', 3)
|
||||
document = actions[route](connection)
|
||||
if route in FOLLOWING and (problem := vpnrouter_s3.follow(name)):
|
||||
log(problem)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""/s3 and /account/s3: the S3-compatible object storage of the router (vpnrouter_s3), its buckets and its accounts.
|
||||
|
||||
GET /s3 {"settings", "endpoint", "version", "installed", "running", "buckets", "accounts",
|
||||
"problem"}: the s3 section, the URL clients call, the pinned and the installed versions
|
||||
of versitygw, whether it runs, the buckets, each {name, owner, access_key, size,
|
||||
objects}, while it runs, the accounts, each {owner, access_key, created}, and why the
|
||||
buckets cannot be read, null when they can
|
||||
PUT /s3 change any of the settings of the s3 section, applied by vpn-router-setup
|
||||
POST /s3/buckets make a bucket: {"name", "owner"}, owner a user with an S3 account
|
||||
PUT /s3/buckets/<name> {"owner"} gives the bucket to another user's account
|
||||
DELETE /s3/buckets/<name> remove the bucket, which must be empty
|
||||
POST /s3/accounts make the S3 account of a user: {"owner"}; answers its secret_key, shown only here
|
||||
PUT /s3/accounts/<owner> replace the account's secret; answers the new secret_key
|
||||
DELETE /s3/accounts/<owner> remove the account
|
||||
|
||||
GET /account/s3 {"enabled", "endpoint", "region", "account"}: the signed-in user's account, or null
|
||||
POST /account/s3 make the signed-in user's account; answers its secret_key
|
||||
PUT /account/s3 replace its secret; answers the new secret_key
|
||||
DELETE /account/s3 remove it
|
||||
|
||||
A change of the settings is applied by restarting vpn-router-setup without waiting for it.
|
||||
"""
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_s3
|
||||
import vpnrouter_users
|
||||
|
||||
from .common import USER_STATUS, section_change
|
||||
|
||||
#: The HTTP status for each status of a vpnrouter_s3.Failure; any other is 500.
|
||||
S3_STATUS = {2: 400, 3: 404, 4: 409}
|
||||
|
||||
|
||||
def endpoint(cfg):
|
||||
"""The URL S3 clients call: Caddy's site of the object storage on wan.local_fqdn."""
|
||||
return f'https://{cfg["wan"]["local_fqdn"].strip()}:{cfg["s3"]["port"]}'
|
||||
|
||||
|
||||
def answered(work):
|
||||
"""The HTTP status and the JSON document of work(), a function that answers a document; its failures as errors."""
|
||||
try:
|
||||
return 200, work()
|
||||
except vpnrouter_s3.Failure as failure:
|
||||
return S3_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the object storage failed: {exc}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
|
||||
|
||||
def overview(cfg):
|
||||
"""The document of GET /s3."""
|
||||
running = vpnrouter_s3.running()
|
||||
found, problem = [], None
|
||||
if running:
|
||||
try:
|
||||
found = vpnrouter_s3.buckets()
|
||||
except vpnrouter_s3.Failure as failure:
|
||||
problem = str(failure)
|
||||
elif cfg['s3']['enabled']:
|
||||
problem = f'{vpnrouter_s3.UNIT} does not run'
|
||||
return {'settings': cfg['s3'], 'endpoint': endpoint(cfg), 'version': vpnrouter_s3.VERSION,
|
||||
'installed': vpnrouter_s3.installed(), 'running': running, 'buckets': found,
|
||||
'accounts': vpnrouter_s3.list_accounts(), 'problem': problem}
|
||||
|
||||
|
||||
def s3(method, path, body):
|
||||
"""Answer a /s3 request: the HTTP status and the JSON document."""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
route = (method, *parts[1:2], len(parts))
|
||||
try:
|
||||
cfg = vpnrouter.load_config()
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
if route == ('GET', 1):
|
||||
return answered(lambda: overview(cfg))
|
||||
if route == ('PUT', 1):
|
||||
changed = section_change('s3', body)
|
||||
return changed if isinstance(changed, tuple) else (200, {'settings': changed})
|
||||
if route == ('POST', 'buckets', 2):
|
||||
return answered(lambda: vpnrouter_s3.bucket_add(body.get('name'), body.get('owner')))
|
||||
if route == ('PUT', 'buckets', 3):
|
||||
return answered(lambda: vpnrouter_s3.bucket_owner(parts[2], body.get('owner')))
|
||||
if route == ('DELETE', 'buckets', 3):
|
||||
return answered(lambda: vpnrouter_s3.bucket_remove(parts[2]))
|
||||
if route == ('POST', 'accounts', 2):
|
||||
return answered(lambda: vpnrouter_s3.account_add(body.get('owner')))
|
||||
if route == ('PUT', 'accounts', 3):
|
||||
return answered(lambda: vpnrouter_s3.account_rotate(parts[2]))
|
||||
if route == ('DELETE', 'accounts', 3):
|
||||
return answered(lambda: vpnrouter_s3.account_remove(parts[2]))
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
|
||||
|
||||
def account_s3(name, method):
|
||||
"""Answer a request of /account/s3 for the signed-in user: the HTTP status and the JSON document."""
|
||||
try:
|
||||
cfg = vpnrouter.load_config()
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
if method == 'GET':
|
||||
return answered(lambda: {'enabled': cfg['s3']['enabled'], 'endpoint': endpoint(cfg),
|
||||
'region': cfg['s3']['region'],
|
||||
'account': next(iter(vpnrouter_s3.list_accounts(name)), None)})
|
||||
if not cfg['s3']['enabled']:
|
||||
return 409, {'error': 'the object storage is off; vpn-config-edit set s3.enabled true switches it on'}
|
||||
actions = {'POST': vpnrouter_s3.account_add, 'PUT': vpnrouter_s3.account_rotate,
|
||||
'DELETE': vpnrouter_s3.account_remove}
|
||||
if method not in actions:
|
||||
return 405, {'error': f'{method} is not allowed on /account/s3'}
|
||||
return answered(lambda: actions[method](name))
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/python3
|
||||
"""Manage the router's S3-compatible object storage from the command line, with vpnrouter_s3.
|
||||
|
||||
The storage is versitygw, which runs while s3.enabled is true (vpn-config-edit set --restart s3.enabled true). Each
|
||||
user or service account of the directory has at most one S3 account, whose secret is shown once, when it is made or
|
||||
replaced; a bucket belongs to one account. 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 is {"error": ...} there
|
||||
too. The exit status says what failed: 2 the request is invalid, 3 the user, account or bucket does not exist, 4 it
|
||||
exists or the state does not allow it, 1 anything else. -h or --help shows the commands and their options.
|
||||
|
||||
vpn-router-s3 [--json] status
|
||||
vpn-router-s3 [--json] account list [--owner USER]
|
||||
vpn-router-s3 [--json] account add USER
|
||||
vpn-router-s3 [--json] account rotate USER
|
||||
vpn-router-s3 [--json] account remove USER
|
||||
vpn-router-s3 [--json] bucket list
|
||||
vpn-router-s3 [--json] bucket add NAME --owner USER
|
||||
vpn-router-s3 [--json] bucket owner NAME USER
|
||||
vpn-router-s3 [--json] bucket remove NAME
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_s3
|
||||
import vpnrouter_users
|
||||
from vpnrouter_s3 import Failure
|
||||
|
||||
|
||||
class Parser(argparse.ArgumentParser):
|
||||
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
|
||||
|
||||
def error(self, message):
|
||||
raise Failure(f'{message}; see {self.prog} --help', 2)
|
||||
|
||||
|
||||
def parse(argv):
|
||||
parser = Parser(prog='vpn-router-s3', description="Manage the router's object storage.")
|
||||
parser.add_argument('--json', action='store_true', help='answer in JSON')
|
||||
commands = parser.add_subparsers(dest='command', required=True, metavar='command', parser_class=Parser)
|
||||
commands.add_parser('status', help='show whether the storage is on, installed and running, and its endpoint')
|
||||
account = commands.add_parser('account', help="the users' S3 accounts")
|
||||
accounts = account.add_subparsers(dest='action', required=True, metavar='action', parser_class=Parser)
|
||||
accounts.add_parser('list', help='list the accounts').add_argument('--owner', metavar='USER', help="only this user's")
|
||||
for action, text in (('add', "make the user's account and show its secret once"),
|
||||
('rotate', "replace the secret of the user's account and show it once"),
|
||||
('remove', "remove the user's account; its buckets keep it as their owner")):
|
||||
accounts.add_parser(action, help=text).add_argument('user', help='the user or service account')
|
||||
bucket = commands.add_parser('bucket', help='the buckets')
|
||||
buckets = bucket.add_subparsers(dest='action', required=True, metavar='action', parser_class=Parser)
|
||||
buckets.add_parser('list', help='list the buckets with their owners, sizes and objects')
|
||||
adder = buckets.add_parser('add', help='make a bucket owned by a user with an S3 account')
|
||||
adder.add_argument('name', help='the bucket name')
|
||||
adder.add_argument('--owner', required=True, metavar='USER', help='the owner')
|
||||
owner = buckets.add_parser('owner', help='give the bucket to another user')
|
||||
owner.add_argument('name', help='the bucket name')
|
||||
owner.add_argument('user', help='the new owner')
|
||||
buckets.add_parser('remove', help='remove the bucket, which must be empty').add_argument('name', help='the bucket name')
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def run(options):
|
||||
"""The answer of the command the options name."""
|
||||
if options.command == 'status':
|
||||
cfg = vpnrouter.load_config()
|
||||
return {'enabled': cfg['s3']['enabled'], 'version': vpnrouter_s3.VERSION, 'installed': vpnrouter_s3.installed(),
|
||||
'running': vpnrouter_s3.running(), 'endpoint': f'https://{cfg["wan"]["local_fqdn"]}:{cfg["s3"]["port"]}',
|
||||
'region': cfg['s3']['region']}
|
||||
if options.command == 'account':
|
||||
if options.action == 'list':
|
||||
return {'accounts': vpnrouter_s3.list_accounts(options.owner)}
|
||||
return {'add': vpnrouter_s3.account_add, 'rotate': vpnrouter_s3.account_rotate,
|
||||
'remove': vpnrouter_s3.account_remove}[options.action](options.user)
|
||||
if options.action == 'list':
|
||||
return {'buckets': vpnrouter_s3.buckets()}
|
||||
if options.action == 'add':
|
||||
return vpnrouter_s3.bucket_add(options.name, options.owner)
|
||||
if options.action == 'owner':
|
||||
return vpnrouter_s3.bucket_owner(options.name, options.user)
|
||||
return vpnrouter_s3.bucket_remove(options.name)
|
||||
|
||||
|
||||
def table(head, rows):
|
||||
"""Rows as text for people, in columns under the 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))
|
||||
|
||||
|
||||
def text_of(answer):
|
||||
"""The answer as text for people."""
|
||||
if 'endpoint' in answer:
|
||||
state = 'running' if answer['running'] else 'not running'
|
||||
return (f'Enabled: {"yes" if answer["enabled"] else "no"}\nversitygw: {answer["installed"] or "not installed"}'
|
||||
f' (pinned {answer["version"]}), {state}\nEndpoint: {answer["endpoint"]}\nRegion: {answer["region"]}')
|
||||
if 'secret_key' in answer:
|
||||
return f'owner {answer["owner"]}\naccess key {answer["access_key"]}\nsecret key {answer["secret_key"]}'
|
||||
if 'accounts' in answer:
|
||||
rows = [(item['owner'], item['access_key'], item['created']) for item in answer['accounts']]
|
||||
return table(('OWNER', 'ACCESS KEY', 'CREATED'), rows) if rows else 'no accounts'
|
||||
if 'buckets' in answer:
|
||||
rows = [(item['name'], item['owner'] or f'({item["access_key"]})', str(item['size']), str(item['objects']))
|
||||
for item in answer['buckets']]
|
||||
return table(('BUCKET', 'OWNER', 'BYTES', 'OBJECTS'), rows) if rows else 'no buckets'
|
||||
if answer.get('removed'):
|
||||
return f'{answer.get("name") or answer.get("owner")} removed'
|
||||
return f'bucket {answer["name"]} owned by {answer["owner"]}'
|
||||
|
||||
|
||||
def main(argv):
|
||||
# A request that cannot be parsed is answered in JSON when --json is among the arguments.
|
||||
as_json = '--json' in argv
|
||||
try:
|
||||
options = parse(argv)
|
||||
as_json = options.json
|
||||
answer = run(options)
|
||||
except (Failure, vpnrouter_users.Failure) as failure:
|
||||
message, status = str(failure), failure.status
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
message, status = f'the object storage failed: {exc}', 1
|
||||
else:
|
||||
print(json.dumps(answer) if as_json else text_of(answer))
|
||||
return 0
|
||||
if as_json:
|
||||
print(json.dumps({'error': message}))
|
||||
else:
|
||||
print(f'vpn-router-s3: {message}', file=sys.stderr)
|
||||
return status
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -45,6 +45,7 @@ import ldap
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_passkeys
|
||||
import vpnrouter_s3
|
||||
import vpnrouter_users
|
||||
from vpnrouter_users import Failure
|
||||
|
||||
@@ -201,6 +202,8 @@ def main(argv):
|
||||
answer = run(connection, options, secret)
|
||||
if options.command in vpnrouter_auth.REVOKING:
|
||||
vpnrouter_auth.revoke(vpnrouter.load_config(), options.name)
|
||||
if options.command in vpnrouter_s3.FOLLOWING and (problem := vpnrouter_s3.follow(options.name)):
|
||||
print(f'vpn-router-user: {problem}', file=sys.stderr)
|
||||
except Failure as failure:
|
||||
message, status = str(failure), failure.status
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# bash completion for vpn-router-s3 -*- shell-script -*-
|
||||
|
||||
# The buckets and the users with or without an S3 account, read as root; nothing for other users.
|
||||
_vpn_router_s3_buckets()
|
||||
{
|
||||
vpn-router-s3 --json bucket list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(item["name"] for item in json.load(sys.stdin)["buckets"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_s3_owners()
|
||||
{
|
||||
vpn-router-s3 --json account list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(item["owner"] for item in json.load(sys.stdin)["accounts"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_s3_users()
|
||||
{
|
||||
vpn-router-user --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(user["name"] for user in json.load(sys.stdin)["users"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_s3()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command and its action are the first two words that are not options; the arguments are the words after them.
|
||||
local i command='' action='' args=0
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
case ${words[i]} in
|
||||
--owner) ((i++)) ;;
|
||||
-*) ;;
|
||||
*)
|
||||
if [[ -z $command ]]; then
|
||||
command=${words[i]}
|
||||
elif [[ -z $action && $command != status ]]; then
|
||||
action=${words[i]}
|
||||
else
|
||||
((args++))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'status account bucket' -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
[[ $command == status ]] && return
|
||||
if [[ -z $action ]]; then
|
||||
case $command in
|
||||
account) COMPREPLY=($(compgen -W 'list add rotate remove' -- "$cur")) ;;
|
||||
bucket) COMPREPLY=($(compgen -W 'list add owner remove' -- "$cur")) ;;
|
||||
esac
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ $prev == --owner ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_s3_owners)" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
$split && return
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
local options='--help'
|
||||
[[ $command/$action == @(account/list|bucket/add) ]] && options+=' --owner'
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
case $command/$action/$args in
|
||||
account/add/0) COMPREPLY=($(compgen -W "$(_vpn_router_s3_users)" -- "$cur")) ;;
|
||||
account/@(rotate|remove)/0) COMPREPLY=($(compgen -W "$(_vpn_router_s3_owners)" -- "$cur")) ;;
|
||||
bucket/@(owner|remove)/0) COMPREPLY=($(compgen -W "$(_vpn_router_s3_buckets)" -- "$cur")) ;;
|
||||
bucket/owner/1) COMPREPLY=($(compgen -W "$(_vpn_router_s3_owners)" -- "$cur")) ;;
|
||||
esac
|
||||
} &&
|
||||
complete -F _vpn_router_s3 vpn-router-s3
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -84,6 +84,11 @@ http://{{ local_fqdn }} {
|
||||
reverse_proxy {{ loopback }}:{{ webdav_loopback_port }}
|
||||
}
|
||||
{% endif %}
|
||||
{%- if s3 %}
|
||||
{{ local_fqdn }}:{{ s3_port }} {
|
||||
reverse_proxy {{ loopback }}:{{ s3_loopback_port }}
|
||||
}
|
||||
{% endif %}
|
||||
{%- for site in sites %}
|
||||
# Site {{ site.name }}
|
||||
{{ site.addresses | join(', ') }} {
|
||||
|
||||
Reference in new issue
Block a user