User Guide

Operator & reference guide

Self-hosting the relay

DNS setup (one-time, manual)

Point a wildcard and the app host at your server's static IP:

*.tun.example.com   A   <static-ip>
app.example.com     A   <static-ip>

For request routing, Ztpr tracks subdomain reservations internally and routes purely by the Host header — it never needs your DNS provider at request time. For HTTPS certificate issuance it can optionally manage the DNS-01 TXT records for you via Amazon Route 53 (see HTTPS & TLS); otherwise you add them by hand.

Quick install (from a release)

Each tagged release publishes a self-contained Linux bundle — no .NET runtime required on the box.

# Replace OWNER/REPO with your GitHub repository
curl -fsSL https://github.com/OWNER/REPO/releases/latest/download/ztpr-linux-x64.tar.gz | tar xz
sudo ./ztpr/install.sh

deploy/install.sh creates the ztpr system user, installs to /opt/ztpr, writes the systemd unit, and starts the service. You then finish setup — domains and HTTPS — from the admin UI (see Admin settings), so there's no config file to hand-edit.

sudo systemctl status ztpr
journalctl -u ztpr -f

Build a bundle locally (no CI)

To deploy without GitHub Actions, build the same self-contained bundle on your own machine (requires the .NET 9 SDK), then copy it over and install:

pwsh deploy/build-release.ps1            # produces ztpr-linux-x64.tar.gz
scp ztpr-linux-x64.tar.gz user@host:~
ssh user@host
tar xzf ztpr-linux-x64.tar.gz && cd ztpr
chmod +x install.sh update.sh && sudo ./install.sh
The chmod +x is needed once because tarballs built on Windows don't carry the Unix executable bit.

Manual install

dotnet publish src/Ztpr.Server -c Release -r linux-x64 --self-contained true -o /opt/ztpr
sudo useradd --system --home /opt/ztpr ztpr
sudo chown -R ztpr:ztpr /opt/ztpr
sudo cp deploy/ztpr.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now ztpr

The SQLite database (ztpr.db) and issued certificates live in the working directory and persist across restarts.

HTTPS / Let's Encrypt (wildcard via DNS-01)

TLS uses a single wildcard certificate (*.tun.example.com) from Let's Encrypt, obtained with the Certes ACME client. One cert covers every random tunnel subdomain — no per-subdomain issuance and no rate-limit worries. SSL terminates at the relay; your local service only ever sees plain HTTP.

Wildcards are validated with a DNS-01 challenge (a TXT record). There are two ways to satisfy it:

  1. In Admin → Settings → General, toggle HTTPS on and restart. The relay listens on 443 (and 80 for the app-host redirect). Port 443 must be reachable.
  2. (Optional) On the DNS & Route 53 tab, enter the AWS access key/secret, hosted zone ID, and region, and save.
  3. On the HTTPS certificate tab, run the wizard: contact email, agree to the Terms of Service, pick staging/production.
    • With Route 53 connected: click Issue certificate automatically and watch the progress log finish.
    • Otherwise: click Start, add the shown _acme-challenge.* TXT records, wait for propagation (dig TXT _acme-challenge.tun.example.com +short), then click Verify & issue.
Do a first run with staging on to confirm the flow (staging certs are untrusted by browsers), then switch staging off and re-run for the real certificate.
Certs last ~90 days, so re-run the wizard before expiry — the TXT values change each time. With Route 53 connected this is one click; otherwise add the new records by hand. The Settings page shows the current cert and days remaining.

Admin settings (runtime config)

Operational settings live in the database and are edited from Admin → Settings — no need to touch appsettings.json. Changes apply at runtime.

SettingMeaning
Base domainWildcard base for tunnels. Tunnels become <sub>.<base>.
App hostThe management/UI host. Requests to this host are not treated as ingress.
HTTPS enabledTurns on TLS termination (ports 443/80) and the certificate wizard.
Require invite codeOn by default. When off, anyone can self-register; when on, new users need a single-use invite.
Route 53 (DNS & Route 53 tab)Optional AWS access key, secret (stored encrypted), hosted zone ID, and region. When set, DNS-01 TXT records are created and verified automatically during certificate issuance/renewal.
Email (Email tab)Optional Mailjet API key, secret (stored encrypted), and verified from address/name. Enables email verification and password reset. See Email.
Require email confirmationWhen on (and email is configured), self-registered users must confirm their address before signing in. The first admin is exempt.
Max session duration (hours)Hard cap on a tunnel's lifetime. 0 = no lifetime cap.
Idle timeout (min)Release a tunnel after this long without traffic. 0 = never release on idle.
Max tunnels per keyMax concurrent live tunnels per API key. 0 = unlimited.
Reaper interval (sec)How often expired/idle tunnels are swept (minimum 5).
First boot uses safe defaults (BaseDomain=lvh.me, HTTPS off, invites required). Set your real domain and enable HTTPS from the UI after the first admin signs in.

Email (Mailjet)

Email is optional and bring-your-own-key. When configured, Ztpr can confirm new users' addresses and send password-reset links; with email off, those features are simply unavailable and users sign in without confirmation. Ztpr sends through Mailjet (the free tier allows 6,000 emails/month).

  1. Create a Mailjet account and verify a sender address (or domain) you control.
  2. In Admin → Settings → Email, turn Email on and enter your Mailjet API key and secret key, plus the from address and friendly from name. The secret is stored encrypted at rest.
  3. (Optional) Turn on Require email confirmation so self-registered users must verify their address before their first sign-in. The first admin is always exempt.

The Email tab also shows your current Mailjet usage. Because the API doesn't expose your plan's quota, you set the monthly send limit (default 6,000) and optional contact limit yourself; these only drive the usage progress bars.

Use a dedicated sender like noreply@example.com. The address must be verified in Mailjet or messages will bounce.

Accounts: 2FA, passwords & recovery

Two-factor authentication (2FA)

Any signed-in user can enable TOTP-based 2FA from Security (in the sidebar). Scan the QR code with an authenticator app (Google Authenticator, Microsoft Authenticator, 1Password, …) or enter the key by hand, then confirm with a 6-digit code. On enabling, you're shown one-time backup codes — save them; they're the only way in if you lose your authenticator. From the same page you can regenerate backup codes or disable 2FA (a current code is required to disable).

After 5 failed backup-code attempts the account is locked and can only be unlocked by an admin (Admin → Users). This is distinct from an admin block.

Change password

Signed-in users change their password from the Security page (current password required).

Forgot password

If email is configured, the login page offers Forgot password?. Entering an account email sends a time-limited reset link. Without email configured, self-service reset is unavailable — an admin must assist.

Audit log

Admins get a searchable security audit log at Admin → Audit log. It records sign-ins (success and failure), tunnel starts, API-key / user / invite creation and deletion, blocks/unblocks, and email-confirmation and password-reset events — each with the associated user and IP where known. Filter by event type and date range.

Every event is also mirrored to the host journal (journalctl -u ztpr). To stay within the server's limited storage the database log is capped (≈1 GiB); once full, the oldest entries are purged automatically, so export anything you need to retain long-term.

appsettings reference

You rarely need to edit this — domains, TLS, and limits are managed at runtime (above). The file mainly holds the database location and optional bootstrap defaults:

"ConnectionStrings": {
  "Sqlite": "Data Source=ztpr.db"
}

Any value set in Admin → Settings takes precedence over an appsettings bootstrap default once saved.

Client CLI reference

ztpr --server <url> --key <api-key> --target <local-url> [--label <name>]
FlagAliasDescription
--server-sZtpr server base URL (e.g. https://app.example.com)
--key-kYour API key (ztpr_…)
--target-tLocal URL to forward to (e.g. http://localhost:3000)
--label-lFriendly name shown in the dashboard
ztpr -s https://app.example.com -k ztpr_xxx -t http://localhost:3000 -l "my dev box"
Tunneled WebSocket upgrades are not yet forwarded (plain HTTP only); the ingress returns 501 for upgrade requests.

Security notes

Trust model: the operator can see traffic

TLS is terminated at the relay — it decrypts inbound HTTPS with the wildcard certificate and forwards plain HTTP over the tunnel to your client. So whoever runs the relay can, in principle, observe the decrypted contents of every tunnel. Treat the relay operator as trusted.

There is no end-to-end encryption that hides traffic from the operator. That would require TLS to terminate on the client (SNI passthrough, with the client holding the cert/key), which is not implemented. If you need confidentiality from the host, run the relay on infrastructure you control.