Portunus
Deployment

Deploy with systemd

Hardened systemd units, FHS layout, the install.sh that creates service users and directories.

Portunus ships hardened systemd units under deploy/systemd/ that are ready to drop in. Both units run as a dedicated system user with ProtectSystem=strict, a @system-service syscall filter, a restricted address-family set, and LimitNOFILE sized to the workload.

One-shot install

On the edge host, install the client binary and the hardened unit in one step:

curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/Portunus/main/scripts/install.sh \
  | sudo sh -s -- client --systemd

This downloads + checksum-verifies the release binary, creates the portunus-client service user, lays out /etc/portunus (mode 0750), and installs the hardened portunus-client.service. For the server, use … | sudo sh -s -- server --systemd (creates portunus-server and /var/lib/portunus, mode 0750). Binary acquisition and flags are documented once in Client Configuration.

From a source checkout you can instead install only the units with cd deploy/systemd && sudo ./install.sh client (the binary must already be on PATH).

server.service

[Unit]
Description=Portunus control plane
Documentation=https://github.com/ZingerLittleBee/Portunus
After=network-online.target
Wants=network-online.target

[Service]
Type=exec
User=portunus-server
Group=portunus-server
ExecStart=/usr/local/bin/portunus-server --data-dir /var/lib/portunus serve
Restart=on-failure
RestartSec=5
TimeoutStopSec=60
LimitNOFILE=4096

# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictNamespaces=true
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictAddressFamilies=AF_INET AF_INET6
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
ReadWritePaths=/var/lib/portunus

[Install]
WantedBy=multi-user.target

TimeoutStopSec=60 is kept larger than the configured drain (shutdown_drain_timeout_secs, default 30 s) so the server's own drain runs before SIGKILL. The server does not bind forwarding ports (the client does), so it needs no CAP_NET_BIND_SERVICE and LimitNOFILE=4096 covers gRPC streams, operator HTTP, metrics, and persistence for hundreds of clients. ReadWritePaths=/var/lib/portunus is the only writable path: SQLite state, generated TLS material, and the optional server.toml.

client.service

[Unit]
Description=Portunus edge client
Documentation=https://github.com/ZingerLittleBee/Portunus
After=network-online.target
Wants=network-online.target

[Service]
Type=exec
User=portunus-client
Group=portunus-client
ExecStart=/usr/local/bin/portunus-client --bundle /etc/portunus/client.bundle.json
Restart=on-failure
RestartSec=5
TimeoutStopSec=60
LimitNOFILE=16384

AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE

# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictNamespaces=true
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictAddressFamilies=AF_INET AF_INET6
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
ReadOnlyPaths=/etc/portunus/client.bundle.json

[Install]
WantedBy=multi-user.target

The client binds the listen ports configured by pushed rules, so it carries CAP_NET_BIND_SERVICE for privileged ports (those below 1024). LimitNOFILE=16384 covers the default range cap of 1024 ports plus in-flight connections (each port = one listener fd; each connection = two fds). Operators raising range_rule_max_ports in server.toml must scale this proportionally. The bundle is mounted read-only.

First-boot procedure

After install:

# 1. Optional: override defaults
sudo cp deploy/server.toml.example /var/lib/portunus/server.toml
sudo nano /var/lib/portunus/server.toml

# 2. Enable and start the server
sudo systemctl enable --now portunus-server
sudo systemctl status portunus-server

# 3. Read the setup token printed by serve
sudo journalctl -u portunus-server -n 100 --no-pager \
  | grep 'Portunus onboarding setup token'

# 4. Open http://127.0.0.1:7080/ over SSH tunnel or local browser,
#    paste the setup token, and create the first superadmin password.

# 5. Create the first client (Web UI Clients page, or the CLI):
portunus-server --data-dir /var/lib/portunus enroll-client edge-01

# 6. On the edge host: install + enroll, then place the bundle
curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/Portunus/main/scripts/install.sh \
  | sudo sh -s -- client --systemd
portunus-client enroll 'portunus://...' --out ./client.bundle.json
sudo install -o root -g portunus-client -m 0640 \
  ./client.bundle.json /etc/portunus/client.bundle.json

# 7. Enable the client unit on the edge host
sudo systemctl enable --now portunus-client

The setup token expires after 30 minutes and rotates on every server start until onboarding completes.

Smoke check

curl -H "Authorization: Bearer $PORTUNUS_OPERATOR_TOKEN" \
     http://127.0.0.1:7080/v1/users/me

Password recovery

If the last superadmin forgets the Web password, stop the service, reset the existing account locally, then start the service again. Use the actual superadmin user ID (_superadmin for bootstrap-superadmin, or the ID chosen during Web onboarding):

sudo systemctl stop portunus-server
sudo -u portunus-server portunus-server \
  --data-dir /var/lib/portunus \
  reset-password admin --temporary
sudo systemctl start portunus-server

The command prints the temporary password once and requires a password change after login. It opens the SQLite store directly, so do not run it while portunus-server.service is active.

Hardening notes

  • Ports < 1024 require CAP_NET_BIND_SERVICE, present in the client unit (the client binds rule listen ports; the server does not). The forwarder only ever binds the listen ports configured by rules, so the capability is scoped tightly.
  • NFS / tmpfs / ramfs for --data-dir is rejected at startup with event=startup.unsupported_filesystem. Use a local filesystem.
  • Two server processes against the same data-dir: the second exits with event=startup.store_in_use (exit 75). Clustering is out of scope.

Backup cron

# /etc/cron.daily/portunus-backup
#!/bin/sh
set -eu
DEST=/var/backups/portunus/$(date +%F).db
mkdir -p "$(dirname "$DEST")"
sudo -u portunus-server portunus-server \
  --data-dir /var/lib/portunus \
  backup --out "$DEST"
find /var/backups/portunus -name '*.db' -mtime +30 -delete

The online backup uses the SQLite Online Backup API — no quiescing needed, safe to run while the server is serving traffic.

On this page