Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
# Domain Configuration
DOMAIN=ssh.example.com
# Domain Configuration: the regional tunnel endpoint this box serves,
# e.g. uswest1.tunnels.cde.glueopshosted.com
DOMAIN=uswest1.tunnels.cde.glueopshosted.com

# Source-IP allowlist for TUNNEL CREATION, comma-separated CIDRs (single
# IPs as /32). Enforced by the authenticator on SSH auth only — browsers
# and CloudFront (:80/:443) are never affected. Default: all RFC 1918
# private ranges plus the CGNAT range 100.64.0.0/10 (Tailscale), i.e. only
# VMs on private networks or the tailnet can register/connect tunnels. If
# VMs arrive from public addresses, add those. Empty value = no source
# restriction. A malformed CIDR fails the authenticator at startup rather
# than silently allowing.
TUNNEL_ALLOWED_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10

# Let's Encrypt ACME Email (required for certificate notifications)
ACME_EMAIL=admin@example.com

# AWS IAM Credentials for Route53 DNS01 Challenge
# Required permissions: route53:ListHostedZones, route53:GetChange, route53:ChangeResourceRecordSets
# Use the acme-dns01-cde.glueopshosted.com user — the cde_acme_* outputs of
# glueops-opentofu-workspaces/aws-cloud-development-environment-assets-production.
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
127 changes: 0 additions & 127 deletions MIGRATION.md

This file was deleted.

52 changes: 44 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,42 @@ sish watches that directory and reloads automatically. Renewal is checked every
The IAM credentials need: `route53:ListHostedZones`, `route53:GetChange`,
`route53:ChangeResourceRecordSets`.

## Deployment model: one box per datacenter region

This repo deploys **regional** tunnel servers only: one instance per
datacenter, each serving `DOMAIN=<region>.tunnels.cde.glueopshosted.com`.
Subdomains are bound literally (`--force-requested-subdomains`): a codespace
VM binds its own hostname, so URLs are
`https://<hostname>.<region>.tunnels.cde.glueopshosted.com` with no prefix,
and a taken name fails the bind rather than silently going random. AWS creds
come from the `acme-dns01-cde.glueopshosted.com` IAM user (the `cde_acme_*`
outputs of the CDE assets workspace).

### Restricting who can create tunnels

`TUNNEL_ALLOWED_CIDRS` (comma-separated CIDRs, default: all RFC 1918 private
ranges plus `100.64.0.0/10` — the CGNAT range Tailscale uses) is enforced by
the **authenticator** on every SSH auth attempt using the client address sish
reports — so it restricts tunnel creation only. Browsers and CloudFront
(:80/:443) never touch the authenticator and are unaffected. Outside-the-list
clients get a logged 403 (`denied (source ... not allowlisted)`) and can
never register or connect a tunnel. Empty value disables the restriction; a
malformed CIDR fails the authenticator at startup.

Deliberately NOT used: sish's own `--whitelisted-ips`, which is global across
SSH *and* HTTP/S and would block public browsers. The :2222 publish is
IPv4-only because docker-proxy rewrites IPv6 clients' sources to the bridge
gateway (a 172.x address inside the allowlist) — an unqualified publish would
let external IPv6 clients through the check.

Regional boxes sit behind a per-region CloudFront distribution: browsers hit
`https://<hostname>.<region>.tunnels.cde.glueopshosted.com` via the CDN, which
origin-fetches this box as `origin.<region>.tunnels.cde.glueopshosted.com` —
covered by the same wildcard cert, no extra config here. The box's 443 stays
directly reachable on purpose (CloudFront is an accelerator, not the security
boundary). SSH (:2222) is always direct, never through the CDN. The full
region rollout runbook lives in GlueOps/slackbot-developer-workspaces#499.

## Quick start

1. Configure environment:
Expand All @@ -73,28 +109,28 @@ The IAM credentials need: `route53:ListHostedZones`, `route53:GetChange`,
```

```
DOMAIN=ssh.example.com
DOMAIN=uswest1.tunnels.cde.glueopshosted.com
ACME_EMAIL=admin@example.com
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
```

2. Start the stack:
2. Start the stack (the `cde.glueopshosted.com` NS delegation must be live
for certbot's DNS-01 validation to succeed):

```bash
docker compose up -d
```

3. Create a tunnel from a developer machine:
3. Smoke-test the tunnel and naming mode from any machine:

```bash
ssh -p 2222 -R myapp:80:localhost:3000 ssh.example.com
# → https://<user>-myapp.ssh.example.com
ssh -p 2222 -R smoketest:80:localhost:3000 uswest1.tunnels.cde.glueopshosted.com
# → https://smoketest.uswest1.tunnels.cde.glueopshosted.com
# (no <user>- prefix in the URL sish prints — if there is one, the wrong
# stack version is deployed)
```

> Migrating a server from the old `glueops/sish` fork stack? See
> [`MIGRATION.md`](./MIGRATION.md).

## Upgrading sish

We pin the upstream image by digest in [`docker-compose.yml`](./docker-compose.yml).
Expand Down
6 changes: 5 additions & 1 deletion auth/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
FROM python:3.12-alpine@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df
FROM python:3.14-alpine@sha256:26730869004e2b9c4b9ad09cab8625e81d256d1ce97e72df5520e806b1709f92

# Worker print() to a non-tty stdout is block-buffered without this — the
# auth audit lines would arrive late or vanish if a worker dies.
ENV PYTHONUNBUFFERED=1

WORKDIR /app

Expand Down
122 changes: 110 additions & 12 deletions auth/auth.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import ipaddress
import os
import sys
import tempfile
from pathlib import Path

from flask import Flask, request
Expand All @@ -6,39 +10,133 @@
app = Flask(__name__)
DATA_DIR = Path("/data")

# Source-IP allowlist for tunnel creation: sish reports each SSH client's
# address in the auth payload, so enforcing here scopes the restriction to
# SSH only — HTTP(S) never touches this service. Empty/unset = allow all.
# Parsed at import so a malformed CIDR crashes the worker at startup
# (visible) instead of silently allowing at request time.
ALLOWED_NETWORKS = [
ipaddress.ip_network(cidr.strip())
for cidr in os.environ.get("TUNNEL_ALLOWED_CIDRS", "").split(",")
if cidr.strip()
] or None


def source_ip(remote_addr):
"""IP from sish's remote_addr ("1.2.3.4:56789", "[::1]:2222"), else None."""
host = remote_addr.rsplit(":", 1)[0].strip("[]")
try:
return ipaddress.ip_address(host)
except ValueError:
return None


def audit(message):
# One write syscall per line: with 4 workers sharing stdout, print()'s
# separate message+newline writes can interleave mid-line.
sys.stdout.write(message + "\n")

# sish auth payloads are tiny; anything bigger is not sish.
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024


def read_key(user_file):
"""Stored key, or None if the username is unregistered. Any other OSError
(weird path, unreadable file) propagates — callers must deny, not 500."""
try:
return user_file.read_text().strip()
except FileNotFoundError:
return None


@app.route("/", methods=["POST"])
def auth():
if not request.is_json:
data = request.get_json(silent=True)
if not isinstance(data, dict):
return "Invalid JSON", 400

data = request.json
username = secure_filename(data.get("user", ""))
username = data.get("user")
key = data.get("auth_key")
remote = data.get("remote_addr")
remote = remote if isinstance(remote, str) else "?"

if not isinstance(username, str) or not isinstance(key, str):
return "Missing data", 403
key = key.strip()
if not username or not key:
return "Missing data", 403

# Tunnel-creation allowlist: an unparseable source (including a caller
# that sent no remote_addr) fails closed while the list is active.
if ALLOWED_NETWORKS is not None:
ip = source_ip(remote)
if ip is None or not any(ip in net for net in ALLOWED_NETWORKS):
audit(f"auth: {username!r} denied (source {remote} not allowlisted)")
return "Forbidden", 403

# sish usernames are hostnames (63-char DNS label cap) but arrive as
# attacker-chosen SSH usernames; without this cap an oversized name
# reaches the filesystem and ENAMETOOLONG turns every retry into a 500.
if len(username) > 63:
return "Forbidden", 403

# Reject any name the sanitizer would have to touch: distinct raw
# usernames must never collapse onto one key file ("vm a" vs "vm_a"
# both sanitize to vm_a). VM usernames are machine-generated hostnames
# and always pass unchanged; !r keeps the rejected raw value from
# injecting into the log line.
if secure_filename(username) != username:
audit(f"auth: {username!r} denied (unsafe username) from {remote}")
return "Forbidden", 403

user_file = DATA_DIR / username

if user_file.exists():
stored_key = user_file.read_text().strip()
try:
stored_key = read_key(user_file)
except OSError:
audit(f"auth: {username} denied (stored key unreadable) from {remote}")
return "Forbidden", 403
if stored_key is not None:
if stored_key == key:
print(f"auth: {username} allowed")
audit(f"auth: {username} allowed from {remote}")
return "OK", 200
print(f"auth: {username} denied (key mismatch)")
audit(f"auth: {username} denied (key mismatch) from {remote}")
return "Forbidden", 403

# First-use registration, atomically: write the key to a temp file and
# hard-link it into place, so the key file appears fully written or not
# at all. A create-then-write would expose an empty file to concurrent
# workers — or leave one behind on a crash, permanently locking the
# username out. Usernames can't start with "." (secure_filename strips
# leading dots), so the temp prefix can never collide with a user file.
fd, tmp_path = tempfile.mkstemp(dir=DATA_DIR, prefix=".tmp-")
try:
with open(user_file, "x") as f:
with os.fdopen(fd, "w") as f:
f.write(key)
print(f"auth: {username} registered")
return "OK", 200
os.link(tmp_path, user_file)
except FileExistsError:
print(f"auth: {username} denied (race)")
# Lost the race. If the winner stored the same key (same VM
# connecting twice at boot), it's still an allow.
try:
winner = read_key(user_file)
except OSError:
winner = None
if winner == key:
audit(f"auth: {username} allowed from {remote}")
return "OK", 200
# winner None means the existing path couldn't be read (e.g. a
# dangling symlink someone left in sish_users/) — that's an operator
# problem, not a registration race; don't mislabel it in the audit.
reason = "race" if winner is not None else "stored key unreadable"
audit(f"auth: {username} denied ({reason}) from {remote}")
return "Forbidden", 403
finally:
os.unlink(tmp_path)

audit(f"auth: {username} registered from {remote}")
return "OK", 200


if __name__ == "__main__":
DATA_DIR.mkdir(exist_ok=True)
app.run(host="0.0.0.0", port=5000)
app.run(host="0.0.0.0", port=5000)
Loading
Loading