Skip to content

Tunnel hosting

A ProxiPort tunnel is a server-side listener that forwards traffic to a TCP port on an agent. Everything in this document is about how operators publish those listeners — on what port, on what hostname, with what protocol on top — so users can reach them in a browser or an SSH client.

ProxiPort can present a tunnel as:

  • a raw TCP port on the server itself (the default, fine for SSH, databases, anything that already has its own client);
  • a subdomain on a wildcard hostname routed by an embedded Caddy instance (HTTPS-on-443 access to internal web UIs, RDP-via-browser, VNC-via-browser);
  • a NoVNC-wrapped VNC tunnel that renders a remote VNC server inside an HTML5 browser tab;
  • an RDP-via-browser tunnel that proxies through an Apache Guacamole guacd daemon;
  • a target for inbound file pushes that uses the same chisel channel to drop files onto the agent host.

This page covers all five. For tunnel ACLs, timeouts, and the basic SPA flow, see operator runbook — tunnels.

Tunnels on random subdomains

The default tunnel scheme allocates a random port from [server] used_ports (default 20000-30000). That works fine until you have to dial through a firewall that blocks outbound traffic to non-default ports.

The subdomain hosting feature lets you publish each HTTP-based tunnel on https://<random>.tunnels.example.com:443 alongside the random port. Same tunnel, two access paths. The browser uses 443, which gets through most strict egress filters.

Architecture

proxiportd launches and supervises a Caddy process. Caddy listens on the configured port (typically 443), terminates TLS, and routes the random subdomain to the right tunnel's internal port. All Caddy configuration is generated and pushed by proxiportd over a Unix socket — no manual Caddyfile.

Prerequisites

  1. Caddy installed locally. Either via the package manager or as a static binary in /usr/local/bin/caddy. A Caddy in a separate container is not supported — proxiportd needs to spawn and manage the process directly.
sudo curl -L "https://caddyserver.com/api/download?os=linux&arch=amd64" \
  -o /usr/local/bin/caddy
sudo chmod +x /usr/local/bin/caddy
sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/caddy

setcap is what lets the unprivileged proxiport user start a listener on 443. Without it, Caddy will fail to bind.

If Caddy is installed via the package manager, the unit usually auto-starts on boot. Disable it (sudo systemctl disable caddy) so the package's Caddy does not fight proxiportd's child Caddy.

  1. Wildcard DNS record. Create *.tunnels.example.com pointing at the server's public IP. If you would rather not use a subdomain prefix, a wildcard at the apex (*.example.com) works too.

  2. Wildcard TLS certificate. A Let's Encrypt DNS-01 wildcard:

sudo certbot certonly --manual --preferred-challenges dns \
  -d '*.tunnels.example.com'

Make the cert readable by the proxiport user:

sudo find /etc/letsencrypt/{archive,live} -type d -exec chmod o+rx {} \;
sudo find /etc/letsencrypt/{archive,live} -type f -exec chmod o+r  {} \;

You can also let the built-in ACME manage the wildcard:

[server]
  tunnel_host = "tunnels.proxiport.example.com"
  tunnel_enable_acme = true

Server configuration

Two deployment shapes, "split port" and "shared port".

Split port keeps the API/SPA on one TCP port (say 3000) and lets Caddy run on its own port (typically 443) just for tunnels. proxiportd.conf:

[caddy-integration]
  caddy            = "/usr/local/bin/caddy"
  address          = "0.0.0.0:443"
  subdomain_prefix = "tunnels.proxiport.example.com"
  cert_file        = "/etc/letsencrypt/live/tunnels.proxiport.example.com/fullchain.pem"
  key_file         = "/etc/letsencrypt/live/tunnels.proxiport.example.com/privkey.pem"

The subdomain_prefix is the bare domain — do not include the leading *..

The [caddy-integration] address port must be different from [api] address.

Shared port runs the API and the tunnel subdomains on one TCP port (443). Caddy fronts everything; the API runs as a plain-HTTP backend behind it:

[api]
  address = "127.0.0.1:3000"
  # cert_file and key_file MUST be commented out — Caddy terminates TLS.

[caddy-integration]
  caddy            = "/usr/local/bin/caddy"
  address          = "0.0.0.0:443"
  subdomain_prefix = "tunnels.proxiport.example.com"
  cert_file        = "/etc/letsencrypt/live/tunnels.proxiport.example.com/fullchain.pem"
  key_file         = "/etc/letsencrypt/live/tunnels.proxiport.example.com/privkey.pem"

  api_hostname = "proxiport-api.example.com"
  api_cert_file = "/etc/letsencrypt/live/proxiport-api.example.com/fullchain.pem"
  api_key_file  = "/etc/letsencrypt/live/proxiport-api.example.com/privkey.pem"
  api_port = "3000"

api_port must equal the port in [api] address. Caddy will SNI-route requests for api_hostname to the API backend; everything else under the wildcard goes to a tunnel.

Using it

When creating a tunnel from the SPA's per-client Tunnels tab, tick one of:

  • Enable HTTP reverse proxy (for HTTP/HTTPS targets);
  • Enable NoVNC (VNC via browser);
  • Enable RDP via browser.

Any of those triggers subdomain allocation. The tunnel record now carries a tunnel_url field, and the SPA's "access tunnel" button points there instead of the random-port URL.

To inspect the live Caddy routes:

curl --unix-socket /var/lib/proxiport/caddy-admin.sock \
  -H "host:unix" \
  http://localhost/config/apps/http/servers/srv0/routes | jq

HTTPS targets and certificate verification

When the reverse proxy fronts an HTTPS target it terminates the browser's TLS and opens a second TLS connection to the target. That second connection's certificate is verified — against the system trust store, or against a private CA bundle if you set one:

[server]
  # PEM bundle of CA certificate(s) to trust for tunnel targets.
  # Empty = use the system trust store.
  tunnel_proxy_target_ca_file = "/var/lib/proxiport/target-ca.pem"

Verification uses the target's real hostname for SNI: the tunnel's host_header when you set one, otherwise the tunnel's remote host. If a particular target presents a self-signed certificate you cannot add to a CA bundle — or one whose SAN does not match the hostname, such as a LAN appliance whose certificate carries no SAN at all — opt that one tunnel out with the skip_tls_verify=true query parameter when creating it. It applies only to that tunnel. In the web UI the same option is a "Do not verify the target's TLS certificate" checkbox that appears under Terminate TLS on the server once the target scheme is https.

A private CA bundle does not help a certificate that has no SAN: modern TLS verification ignores the legacy Common Name and matches only against the SAN, so a SAN-less certificate is rejected however it is trusted. Either reissue the target's certificate with the hostname in its SAN, or use skip_tls_verify for that tunnel.

If you would rather the operator's own browser validate the target certificate end to end, use a plain (non-proxied) tunnel instead: the bytes pass through untouched and your client completes the real TLS handshake with the target.

Protecting a tunnel with HTTP basic auth

A tunnel served through the reverse proxy (http_proxy=true) can require HTTP basic auth. Send the credential in a JSON request body:

curl -X PUT \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"auth_user":"operator","auth_password":"<password>"}' \
  "https://proxiport.example.com/api/v1/clients/<id>/tunnels?remote=0.0.0.0:8080&scheme=https&http_proxy=1&acl=203.0.113.0/24"

auth_user and auth_password are still accepted as query parameters for compatibility, but a credential in a URL is recorded by every access log, proxy cache and browser history between the caller and the API. The body form takes precedence when both are supplied.

The password is stored so the proxy can check incoming requests, but it is redacted from the client API payload and from the audit log.

Plaintext HTTP tunnels are blocked by default

A tunnel whose scheme is http with no TLS in front of it carries the operator's browser ⇆ server traffic in the clear. Anyone able to observe the network path between the ProxiPort server and the browser — other hosts on the LAN, an upstream router, a hostile Wi-Fi access point, a transit provider — can read the request URLs, headers, session cookies and page contents. Because a management UI is exactly the kind of traffic worth protecting, ProxiPort refuses to open such a tunnel by default.

A tunnel is considered safe, and needs no override, when either:

  • the server terminates TLS with its own certificate — tick Terminate TLS on the server in the SPA, or pass http_proxy=true (see the reverse proxy above); or
  • TLS is carried end to end by the target — use the https scheme, and the encrypted bytes pass through the tunnel untouched.

If you genuinely need plaintext http — for instance on a trusted, isolated private network — you must opt in explicitly:

  • In the SPA: tick Allow insecure http:// without TLS on the New Tunnel form. The box appears only for an http tunnel with server TLS disabled, and the tunnel cannot be created until it is ticked.
  • Via the API: add allow_insecure_http=true to the PUT /clients/{client_id}/tunnels query. Without it the request is rejected with 400 and a message explaining the exposure.

This is the same deliberate, one-tick opt-in the UI already requires before opening a tunnel to an unrestricted IP range — an easy-to-hit safe default, with a clear escape hatch when you know what you are doing.

NoVNC proxy

For agents on hosts that expose a VNC server, ProxiPort can bridge to noVNC so the operator opens a browser tab and sees the remote desktop without installing a VNC client.

Prerequisites

  • noVNC JavaScript bundle on the server's filesystem.
  • TLS enabled on the tunnel proxy (tunnel_proxy_cert_file and tunnel_proxy_key_file). NoVNC depends on the same TLS-fronted reverse proxy that the subdomain feature uses.

Install noVNC:

sudo curl -L \
  https://github.com/novnc/noVNC/archive/refs/tags/v1.3.0.zip \
  -o /tmp/novnc.zip
sudo unzip -d /var/lib/proxiport /tmp/novnc.zip
sudo rm -f /tmp/novnc.zip

Point ProxiPort at the extracted directory:

[server]
  novnc_root = "/var/lib/proxiport/noVNC-1.3.0"
  tunnel_proxy_cert_file = "/var/lib/proxiport/server.crt"
  tunnel_proxy_key_file  = "/var/lib/proxiport/server.key"

Restart proxiportd. The tunnel-create form now exposes the "Enable NoVNC" option for tunnels targeting a VNC port.

RDP proxy via guacd

For RDP access from a browser, ProxiPort proxies the chisel tunnel through an Apache Guacamole guacd daemon on the same host:

[server]
  guacd_address = "127.0.0.1:4822"
  tunnel_proxy_cert_file = "/var/lib/proxiport/server.crt"
  tunnel_proxy_key_file  = "/var/lib/proxiport/server.key"

You do not need a full Guacamole web stack — only guacd. Run it in a container:

docker run -d --name guacd --net=host --restart unless-stopped \
  lscr.io/linuxserver/guacd

--net=host is required so guacd can reach the tunnel listener on 127.0.0.1. Once running, tunnels created with "Enable RDP via browser" expose a URL like https://proxiport.example.com:<port>/ that loads the Guacamole client in the browser.

You can pass through Guacamole session parameters as query strings:

/?username=Administrator&width=1280&height=800&security=nla&keyboard=en-us-qwerty

For security reasons the password cannot be injected — the operator types it into the Guacamole login form. Available keyboard values are the standard Guacamole layout codes (e.g. en-us-qwerty, de-de-qwertz, fr-fr-azerty).

File reception

A separate but related capability: pushing a file from the server's disk to one or many agent hosts over the same chisel channel that carries tunnel traffic.

Flow

  1. The operator uploads the file to the server via the multipart POST /api/v1/files endpoint, specifying target client IDs or group IDs, the destination path, and optional mode/owner/group.
  2. The server saves the file to {data_dir}/filepush/<uuid>.
  3. Each addressed agent opens an SFTP-over-SSH session inside its existing chisel channel, downloads the file to its own {data_dir}/filepush/<uuid>, verifies the MD5, applies any chmod/chown, and moves the file to the destination.
  4. The server tracks per-agent success/failure and surfaces it on the API and on a WebSocket subscriber stream.

A minimal upload:

TOKEN=$(curl -s -u admin:password \
  https://proxiport.example.com/api/v1/login | jq -r .data.token)

curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -F 'upload=@./hosts.allow' \
  -F 'client_id=alpha-prod' \
  -F 'client_id=bravo-staging' \
  -F 'dest=/etc/hosts.allow' \
  -F 'force=true' \
  -F 'mode=0644' \
  https://proxiport.example.com/api/v1/files

Useful form fields:

Field Notes
upload (required) The file body.
client_id / group_id Repeatable. At least one of either is required.
dest (required) Absolute destination path on the agent. Relative paths are rejected — see below.
force Overwrite if the file already exists.
sync Compare MD5; overwrite only if changed. Apply mode/owner if requested.
mode Unix mode bits, e.g. 0644. Default 0764.
user, group Unix owner / group. Requires sudo on the agent for non-default owners.

File-size limit

Server side, [api] max_filepush_size caps the single-file size in bytes (default 10 MiB). Increase it under [api] if you need to push larger payloads. The general max_request_bytes cap does not apply to this endpoint.

The destination must be absolute

dest is required to be an absolute path: /etc/motd on a Unix agent, C:\ProgramData\app\config.ini or \\fileserver\share\file.txt on Windows. A relative path such as etc/motd is refused by the server and, independently, by the agent.

This matters for more than tidiness. Every entry in the server's refusal list and in [file-reception] protected is written as an absolute path, so a relative destination matches none of them — while the agent still resolves it, against the agent process's working directory. Under the shipped systemd unit that directory is /, so a relative etc/cron.d/job would have arrived at /etc/cron.d/job with every protection bypassed. Requiring an absolute path is what makes the lists below mean what they say.

A Windows path that is rooted but has no drive (\Windows\...) is also refused: it resolves against whichever drive is current, which is the same surprise in a different form.

Restricted destinations

By default the agent refuses to write anywhere a pushed file could become code that runs as root. The built-in list covers whole subtrees (/bin/**, /sbin/**, /usr/bin/**, /usr/local/bin/**, /lib*/**, /usr/lib*/**, /boot/**, /dev/**, /run/**, /proc/**, /sys/**), everything that runs on a schedule or at boot (/etc/cron*, /var/spool/cron/**, /etc/systemd/**, /etc/init.d/**, the launchd directories on macOS), files sourced into every shell or process (/etc/profile*, /etc/environment, /etc/ld.so.*), and the account and authorization files (/etc/passwd, /etc/shadow, /etc/sudoers*, /etc/pam.d/**, /etc/security/**). On Windows it covers C:\Windows, C:\ProgramData, both Program Files trees and the per-user Startup folder. The full list is in the agent source (client/upload_nix.go, client/upload_win.go).

[file-reception] protected replaces that list rather than adding to it, so an override must carry the built-in entries too:

[file-reception]
  enabled = true
  protected = ['/bin/**', '/sbin/**', '/usr/bin/**', '/usr/sbin/**', '/etc/systemd/**', '/srv/deploy/**']

These are glob patterns matched against both the target directory and the target file path; ** matches across path separators, a bare directory name does not. To disable reception entirely, set enabled = false.

Protected destinations

[file-reception] protected is the agent's own refusal list, enforced on the agent because it is the agent's filesystem at stake. The default covers the routes a written file turns into code execution — anything that runs on a schedule, at boot, or as part of a login:

  • cron (/etc/cron.d, /etc/cron.*, /var/spool/cron) and systemd unit directories
  • shell and loader initialisation (/etc/profile, /etc/profile.d, /etc/environment, /etc/ld.so.*)
  • accounts and authorization (/etc/passwd, /etc/shadow, /etc/sudoers, /etc/sudoers.d, /etc/pam.d, /etc/ssh)
  • SSH keys — /root, /home/*/.ssh — since an authorized_keys file is a login
  • package-manager hook directories, which run as root on the next update
  • executable search paths, pseudo-filesystems, and the agent's own configuration

A pattern ending in /** protects that directory and everything below it. Any other pattern is a shell-style glob that matches a single path element, so /etc/systemd/* covers /etc/systemd/system but not /etc/systemd/system/foo.service.d/override.conf — use the /** form for a directory tree.

File reception crosses the command permission boundary

A push carries a mode and an owner, and the agent will chown as root, so an operator who can write to one of these locations has code execution on the host without needing the commands or scripts permission. Treat the uploads permission as equivalent to command execution unless you have narrowed protected to an allowed directory of your own.

Setting protected replaces the default rather than adding to it, so include the defaults you still want. The server refuses an obviously dangerous destination too, before transferring the file, but that check is a convenience — the agent's list is the enforcement.

Privileged writes

The agent runs as an unprivileged user. To let it chown a pushed file or write into a root-owned directory, add narrow sudoers rules:

# /etc/sudoers.d/proxiport-filereception
proxiport-agent ALL=NOPASSWD: /usr/bin/chown * /var/lib/proxiport-agent/filepush/*_proxiport_filepush
proxiport-agent ALL=NOPASSWD: /usr/bin/mv     /var/lib/proxiport-agent/filepush/*_proxiport_filepush *

The rules name the agent's own account (proxiport-agent since 0.10.0; proxiport is the server's) and the agent's data directory. A rule that still names proxiport grants the server, not the agent.

Limit the wildcards as much as your operational shape allows.

Live progress

WS /ws/uploads streams per-file results as they happen. Subscribers get JSON frames like:

{
  "client_id": "alpha-prod",
  "uuid": "482ae29e-d372-4d21-8cb4-58d75482b7e1",
  "filepath": "/etc/hosts.allow",
  "size": 17118,
  "message": "file successfully copied to destination",
  "status": "success"
}

The test UI at /api/v1/test/uploads/ui (enable with enable_ws_test_endpoints = true under [api]) is the easiest way to see this in action.

For long-term auditing, the result is also written to auditlog.db and surfaces on the Audit page.

Troubleshooting

  • Caddy fails to bind 443. Re-run sudo setcap 'cap_net_bind_service=+ep' $(which caddy) after every Caddy upgrade. The capability is on the binary and is lost when the binary is replaced.
  • Tunnel URL returns a Caddy 404. The subdomain has not been registered with Caddy yet — either the tunnel hasn't finished creating, or proxiportd and Caddy lost sync. Check journalctl -u proxiportd -n 100 --no-pager for ACME errors.
  • NoVNC loads but the canvas is blank. The agent could not reach the VNC port on the target host. The tunnel itself is fine; the agent-side TCP connect is failing.
  • File push reports "permission denied" on rename. The agent user can't write to dest. Either change the destination or add a sudoers rule and retry with the correct owner.

See also: HTTPS for the underlying TLS setup, operator runbook — tunnels for ACLs and the per-tunnel SPA flow, and architecture for the chisel transport details.