callis (n.) — a narrow, beaten track through rough terrain; in Roman law, a granted right of passage through territory not your own.
Callis is a self-hosted SSH jump server (bastion host) with a web UI. It provides secure, audited SSH access to internal infrastructure through a single hardened entry point, with full user management, public key management, and connection logging — all configurable through a browser.
- Hardened OpenSSH jump server (Ed25519 host key; Ed25519 or RSA 4096+ user keys; no passwords, no interactive shell)
- Server-rendered web UI (SvelteKit SSR) backed by a FastAPI JSON API — works without JavaScript, no CDN dependencies
- Per-user OS accounts with instant key revocation
- Mandatory TOTP 2FA for all web UI users, with single-use recovery codes for lost devices
- Role-based access control (admin, operator, readonly)
- Host groups — grant access to sets of hosts as a unit
- Live SSH session view with admin session termination
- Full audit log of every connection attempt and admin action
- Fail2ban sidecar for SSH brute force protection
- Rate limiting on web UI login
- Works on a LAN or behind any reverse proxy
- First-run setup wizard — no
.envconfiguration required - One-command deploy from a pre-built image — no build step required
- Docker Engine 24.0+
- Docker Compose v2.0+
- A server or machine with a static IP or hostname
Create a docker-compose.yml with the following contents and run docker compose up -d:
services:
callis:
image: ghcr.io/pacnpal/callis:latest
restart: unless-stopped
ports:
- "8080:8080"
- "2222:22"
volumes:
- callis_db:/data
- callis_hostkeys:/etc/ssh/host_keys
- callis_sshd_logs:/var/log/callis
volumes:
callis_db:
callis_hostkeys:
callis_sshd_logs:Or with docker run:
docker run -d \
--name callis \
--restart unless-stopped \
-p 8080:8080 \
-p 2222:22 \
-v callis_db:/data \
-v callis_hostkeys:/etc/ssh/host_keys \
-v callis_sshd_logs:/var/log/callis \
ghcr.io/pacnpal/callis:latestNo .env file needed — the setup wizard handles first-run configuration. SECRET_KEY is auto-generated and persisted to the data volume.
If you want to modify Callis or run the development build:
git clone https://github.com/pacnpal/callis.git
cd callis
docker compose up -dRequires Docker Compose v2.24.0+ for the optional
.envfile syntax used indocker-compose.yml. Rundocker compose versionto check.
- Web UI:
http://<your-server-ip>:8080 - SSH jump port:
2222
Security notice: The
/setuppage is publicly accessible until an admin account is created. Complete setup immediately after starting the container for the first time. The container logs will show aFIRST RUNwarning until an admin account is created.
- Start Callis:
docker compose up -d - Open the web UI at
http://<server>:8080— the setup wizard appears automatically - Create your admin account — choose a username and strong password
- Set up TOTP 2FA — scan the QR code with your authenticator app, enter the 6-digit code
- You're automatically logged in to the dashboard
Next steps:
- Go to Users and create accounts for your team
- Each user should log in, set up TOTP, then go to My Profile to upload SSH public keys
- Go to Hosts and add your internal servers as jump targets
- Assign users to hosts — use the assignment dropdown on each host row to grant access (users can only jump to hosts they're assigned to)
- Users can now SSH through Callis to reach their assigned hosts
The default. Good for home labs and internal networks.
docker compose up -dOpen http://<server-ip>:8080 — the setup wizard guides you through admin account creation and TOTP enrollment. SSH available on <server-ip>:2222 after setup.
Callis serves plain HTTP on port 8080. TLS termination is handled by your reverse proxy — Callis does not manage certificates.
1. Set these in .env:
BASE_URL=https://callis.example.com
HTTPS_ENABLED=true
# Required with HTTPS_ENABLED. Set it to the address Callis SEES your reverse
# proxy connect from — not the proxy's public address. See "Choosing
# TRUSTED_PROXIES" below; this example fits the recommended Docker-network setup.
TRUSTED_PROXIES=172.18.0.0/16HTTPS_ENABLED=true enables HSTS headers and sets the Secure flag on session cookies (required when serving over HTTPS).
Choosing TRUSTED_PROXIES. Only the peer named here may supply X-Forwarded-For (the real client IP used for audit logging and login rate limiting); every other client's forwarded headers are ignored. It must be the address Callis observes the proxy connecting from, which is usually not 127.0.0.1:
- Recommended — proxy on the same Docker network, web port not published. Run your reverse proxy as a service alongside Callis and remove the
8080port mapping fromdocker-compose.ymlso the web UI is reachable only through the proxy. Callis then sees the proxy connect from its container address; setTRUSTED_PROXIESto that Docker network's subnet (find it withdocker network inspect <network>→Subnet, e.g.172.18.0.0/16). This is the only topology where Callis can reliably tell your proxy apart from a direct client. - Host-based proxy (Caddy/Nginx on the host) dialing a published
8080. Callis sees these connections from the Docker bridge gateway (e.g.172.17.0.1), not127.0.0.1— setTRUSTED_PROXIESto that gateway. Caveat: because the port is published, a client that reaches8080directly also appears as the gateway, so Callis cannot distinguish it from your proxy. Restrict the published port to the proxy host (firewall it, or bind it to a private interface) or use the Docker-network setup above. - Not sure of the value? Start Callis, attempt a login through the proxy, and read
source_ipfrom the audit log — that is exactly the address to put inTRUSTED_PROXIES. (Loopback127.0.0.1/::1is always trusted, so you only need this when the proxy is a separate host or container — the usual case.)
A wildcard * or a catch-all network (0.0.0.0/0, ::/0) is refused at startup, because trusting it would let any client spoof their source IP — forging audit-log entries and bypassing login rate limiting.
2. Point your reverse proxy at Callis port 8080:
Caddy
callis.example.com {
reverse_proxy localhost:8080
}
Caddy handles TLS automatically via Let's Encrypt.
Nginx
server {
listen 443 ssl;
server_name callis.example.com;
ssl_certificate /etc/letsencrypt/live/callis.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/callis.example.com/privkey.pem;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name callis.example.com;
return 301 https://$host$request_uri;
}Traefik (Docker labels)
Add these labels to the callis service in docker-compose.yml:
labels:
- "traefik.enable=true"
- "traefik.http.routers.callis.rule=Host(`callis.example.com`)"
- "traefik.http.routers.callis.entrypoints=websecure"
- "traefik.http.routers.callis.tls.certresolver=letsencrypt"
- "traefik.http.services.callis.loadbalancer.server.port=8080"3. SSH port forwarding
The SSH jump port (default 2222) is not proxied — it must be forwarded at the network/firewall level:
# Or simply expose it in docker-compose.yml (already the default):
ports:
- "2222:22"Users connect directly to the SSH port; only the web UI goes through the reverse proxy.
The Callis CLI lets you SSH to any assigned host by tag — no manual SSH config needed:
# Install from your Callis server (adds the CLI to your shell rc file)
curl -fsSL http://<your-callis-server>:8080/install.sh | sh
# Or, from a source checkout, add to ~/.bashrc / ~/.zshrc manually:
# source /path/to/callis/api/static/callis.sh
# One-time setup
callis setup
# List your assigned hosts
callis list
# mac-mini 192.168.1.50 22 Mac Mini Server
# web-prod 10.0.1.20 22 Production Web
# Connect by tag
callis mac-mini
# Pass extra SSH options
callis web-prod -L 8080:localhost:8080The CLI resolves host tags over SSH (no HTTP API calls from the client), then connects through the bastion via ProxyJump.
After uploading your public key through the web UI, add this to ~/.ssh/config:
Host callis
HostName <your-callis-server>
Port 2222
User <your-callis-username>
IdentityFile ~/.ssh/your_ed25519_key
Host my-internal-server
HostName 192.168.1.50
User ubuntu
ProxyJump callis
Then connect:
# Direct jump
ssh -J username@callis-host:2222 ubuntu@192.168.1.50
# Or using the config above
ssh my-internal-server| Variable | Required | Default | Description |
|---|---|---|---|
SECRET_KEY |
No | Auto-generated | 32+ byte hex string for JWT signing and TOTP encryption. Auto-generated and persisted to /data/.secret_key if not set. |
DATABASE_URL |
No | sqlite+aiosqlite:////data/callis.db |
Database URL (PostgreSQL supported) |
SSH_PORT |
No | 2222 |
External SSH port |
WEB_PORT |
No | 8080 |
External web UI port |
BASE_URL |
No | http://localhost:8080 |
Public URL (used in SSH config snippets) |
SESSION_IDLE_TIMEOUT |
No | 1800 |
Idle timeout in seconds (30 min) |
SESSION_MAX_LIFETIME |
No | 28800 |
Max session lifetime in seconds (8 hrs) |
MAX_KEYS_PER_USER |
No | 5 |
Maximum SSH keys per user |
AUTH_MODE |
No | local |
Only local is implemented today; OIDC support is planned |
HTTPS_ENABLED |
No | false |
Enable HSTS and Secure cookie flag |
TRUSTED_PROXIES |
When HTTPS_ENABLED=true |
* |
Comma-separated proxy IPs/CIDRs allowed to set X-Forwarded-* headers. Required when HTTPS_ENABLED=true — a wildcard * or empty value is refused at startup because it lets any client spoof X-Forwarded-For (forged audit IPs, rate-limit bypass). Ignored in LAN/HTTP mode. |
DEV_MODE |
No | false |
Enable development mode features (verbose SQL logging) |
LOG_LEVEL |
No | info |
Logging level |
TZ |
No | UTC |
Timezone |
| Port | Service | Exposed to Host |
|---|---|---|
WEB_PORT (8080) |
Web UI | Yes |
SSH_PORT (2222) |
SSH jump server | Yes |
| 8081 | Internal API (keys, resolve, hosts) | No (container-internal only) |
Port 8081 serves the internal API used by the SSH server to fetch authorized keys, resolve host tags, and list assigned hosts. It is bound within the container and never exposed outside. All requests require a valid X-Internal-Secret header (HMAC-SHA256 derived from SECRET_KEY).
docker run --rm -v callis_db:/data -v $(pwd):/backup alpine \
tar czf /backup/callis-db-$(date +%Y%m%d).tar.gz /datadocker run --rm -v callis_hostkeys:/data -v $(pwd):/backup alpine \
tar czf /backup/callis-hostkeys-$(date +%Y%m%d).tar.gz /datadocker run --rm -v callis_db:/data -v $(pwd):/backup alpine \
tar xzf /backup/callis-db-YYYYMMDD.tar.gz -C /docker compose pull
docker compose up -dDatabase tables are created automatically on startup. Back up your database before upgrading.
- SECRET_KEY is auto-generated on first start and persisted to
/data/.secret_keywithchmod 600. In Docker,entrypoint.shusesopenssl rand -hex 32; when running the API directly,api/core.pyusessecrets.token_hex(32). The/datavolume is hardened tochmod 700on every boot. - Private keys are never stored. Callis accepts uploaded public keys only and does not collect, generate, or retain private keys.
- Public key text is write-only. After upload, only the fingerprint, label, type, and dates are shown.
- Port 8081 is internal only. It serves SSH authorized keys within the container and is never exposed. All requests require
X-Internal-Secret(HMAC-SHA256). - TOTP is mandatory. Every user must enroll in 2FA before accessing any protected page. The setup wizard itself is unauthenticated, but the TOTP enrollment step (
/setup/totp) is fully locked down once any enrolled admin exists — there are no backdoors into the wizard after first-run. - Audit log is append-only. No API or UI can delete or modify audit entries.
- Authentication checks are hardened. Constant-time password verification, constant-time TOTP comparison, no user enumeration via timing or error messages.
- File permissions enforced on every boot.
/data(700),.secret_key(600),callis.db(600), SSH host key (600). - sshd is hardened. Ed25519 and RSA (4096+ bit) keys accepted, no passwords, no root login, no interactive shell (ForceCommand allows only
resolveandlist), modern cipher suite.
See the docs/ directory for detailed documentation:
- Requirements — functional and security requirements
- Architecture — system design and component overview
- Security — security model, contracts, and threat model
- Deployment — deployment modes and configuration reference
- Development — local dev setup and contribution guide
- Svelte Migration — proposed plan for a future Svelte frontend
Callis includes a fail2ban sidecar that monitors SSH authentication failures. Default settings:
- 3 failures within 10 minutes triggers a ban
- 24-hour ban duration
- Monitors sshd logs via shared Docker volume
If you change SSH_PORT from the default 2222, update fail2ban/jail.local to match.
MIT