{"id":49821146,"url":"https://github.com/bindreams/postern","last_synced_at":"2026-05-13T11:05:00.083Z","repository":{"id":353999452,"uuid":"1221624534","full_name":"bindreams/postern","owner":"bindreams","description":null,"archived":false,"fork":false,"pushed_at":"2026-05-08T22:21:18.000Z","size":1160,"stargazers_count":0,"open_issues_count":19,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-08T22:24:24.430Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/bindreams.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE.md","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-26T13:21:21.000Z","updated_at":"2026-04-29T02:30:35.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/bindreams/postern","commit_stats":null,"previous_names":["bindreams/postern"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/bindreams/postern","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bindreams%2Fpostern","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bindreams%2Fpostern/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bindreams%2Fpostern/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bindreams%2Fpostern/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/bindreams","download_url":"https://codeload.github.com/bindreams/postern/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bindreams%2Fpostern/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32979315,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-13T06:31:55.726Z","status":"ssl_error","status_checked_at":"2026-05-13T06:31:51.336Z","response_time":115,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-05-13T11:04:55.657Z","updated_at":"2026-05-13T11:05:00.067Z","avatar_url":"https://github.com/bindreams.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Postern VPN\n\nPostern VPN is a self-hosted, multi-user Shadowsocks portal. It pairs a small FastAPI web portal with an Nginx reverse proxy and a dynamic fleet of Shadowsocks-rust + v2ray-plugin containers (one per connection). Users sign in with an email one-time code, then download a client config for their tunnel. An internal reconciliation loop keeps running containers in sync with the portal database.\n\n## Architecture\n\n```\n                         ┌──────────────┐\n                         │   operator   │\n                         │   (CLI +     │\n internet                │    docker    │\n ──────►  nginx :443 ────┤    compose)  │\n           │    │        └──────────────┘\n           │    │\n           │    └─► portal :8000 ──(Docker API via docker-proxy)──► creates/removes\n           │                                                        ss-{token} containers\n           │\n           └─► ss-{token} :80 ──► v2ray-plugin ──► Shadowsocks\n```\n\n- **nginx** — TLS termination, HTTP→HTTPS redirect, path-based WebSocket routing, security headers, rate limiting on `/login*`. Periodically self-reloads to pick up renewed Let's Encrypt certs.\n- **portal** — Python 3.13 / FastAPI. OTP email login, dashboard, JSON config download, admin CLI, and a background reconciliation loop that manages per-connection Shadowsocks containers via the Docker API.\n- **ss-{token} containers** — one Shadowsocks-rust instance per enabled connection, fronted by v2ray-plugin in WebSocket mode. `{token}` is a 24-hex-char path token; Nginx proxies `wss://\u003cdomain\u003e/t/{token}` to `ss-{token}:80`.\n- **docker-proxy** — [tecnativa/docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy), a restricted Docker API exposed to the portal over TCP. The portal never sees the raw `/var/run/docker.sock`.\n\n## Prerequisites\n\n- Docker Engine and Docker Compose v2\n- A public domain you control, with Let's Encrypt certificates at `/etc/letsencrypt/live/\u003cdomain\u003e/` (bind-mounted into the Nginx container)\n- A free [Docker Hub](https://hub.docker.com) account with a Personal Access Token. The base images used by Nginx and the portal come from [Docker Hardened Images](https://docs.docker.com/dhi/) (`dhi.io`); the catalog is free under Apache 2.0 but pulls require authentication. Run `docker login dhi.io` with your Docker Hub username + PAT before the first build.\n\n**SMTP — pick one:**\n\n- **Built-in MTA (default).** Postern ships a self-hosted Postfix + opendkim + Unbound + postsrsd + mta-sts-resolver stack as the default `with-mta` Compose profile. Eliminates the third-party metadata leak (no provider sees who your users are or when they log in). Additional prerequisites:\n  - Public IPv4 with **port 25 outbound allowed**. Many cloud providers block it by default (AWS, GCP, DigitalOcean new accounts); Hetzner-class VPS providers usually allow it. Without port 25 outbound, the built-in MTA cannot deliver mail.\n  - Reverse DNS (PTR) on the IP set to `mail.\u003cdomain\u003e`. Configured at the VPS provider's panel; cannot be automated.\n  - Three Let's Encrypt certs at `/etc/letsencrypt/live/\u003cdomain\u003e/`, `/etc/letsencrypt/live/mail.\u003cdomain\u003e/`, `/etc/letsencrypt/live/mta-sts.\u003cdomain\u003e/`. A multi-SAN cert covering all three works too: `certbot certonly --standalone -d \u003cdomain\u003e -d mail.\u003cdomain\u003e -d mta-sts.\u003cdomain\u003e`.\n  - DNS records published as listed by `docker compose exec portal postern mta show-dns`. Includes MX, SPF, DMARC `p=reject` strict, MTA-STS, TLS-RPT, DKIM. The DKIM TXT is auto-managed when `DNS_PROVIDER` is set to a libdns-supported provider (Cloudflare, Route53, Gandi, DigitalOcean, OVH, Hetzner, Linode, Namecheap); otherwise published manually after first run.\n  - **Strongly recommended: DNSSEC enabled at your TLD/registrar.** Without it, MTA-STS and DKIM records can be silently tampered with by anyone with upstream-DNS access. Most modern registrars (Cloudflare Registrar, Gandi, Namecheap, Porkbun, Hover) support this; verify with `dig +dnssec DS \u003cyourdomain\u003e` returning a signed RRset. DNSSEC is auto-detected at MTA startup (`MTA_REQUIRE_DNSSEC=auto` default); set explicitly to `true` for fail-closed production.\n  - An external mailbox you read for technical reports (postmaster, abuse, tls-rpt, bounces). Set `MTA_ADMIN_EMAIL=` in `.env`. Postern forwards there; it does not host an inbox.\n  - See [docs/mta.md](docs/mta.md) for a full deployer walkthrough.\n- **Third-party SMTP relay** (Resend, SES, Mailgun, Postmark, etc.). Comment `COMPOSE_PROFILES=with-mta` in `.env` and set `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` to your provider. TLS mode is derived from the port: 465 → implicit TLS, 587 → STARTTLS.\n\n## Quick start\n\n```bash\n# 1. Clone\ngit clone \u003cthis repo\u003e\ncd hole-server\n\n# 2. Create your environment file\ncp .env.example .env\n\n# 3. Generate a SECRET_KEY and paste it into .env\npython -c \"import secrets; print(secrets.token_hex(32))\"\n\n# 4. Fill in SMTP credentials in .env (SMTP_HOST / PORT / USER / PASSWORD / FROM)\n\n# 5. (If your domain is not postern.example.com, see \"Re-hosting\" below.)\n\n# 6. Build the per-connection tunnel image. Compose does not build this one —\n#    the reconciler spawns it at runtime, so it must exist first.\ndocker build -f shadowsocks/Dockerfile -t local/shadowsocks-server .\n\n# 7. Build and start the rest of the stack\ndocker compose up -d --build\n```\n\nThe portal is served from `https://\u003cyour-domain\u003e/`. First login requires that you add yourself as a user (see [Admin workflow](#admin-workflow)).\n\n## Configuration\n\nEnvironment variables are loaded from `.env` (copied from `.env.example`) into the `portal` container only. All settings are read by [portal/src/postern/settings.py](portal/src/postern/settings.py) via pydantic-settings (env vars are case-insensitive: `SECRET_KEY` in `.env` ↔ `settings.secret_key` in code).\n\n| Variable                      | Default                    | Purpose                                                                                                                                                 |\n| ----------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `SECRET_KEY`                  | _(required)_               | Server secret. Portal fails to start without it. Generate with `python -c \"import secrets; print(secrets.token_hex(32))\"`.                              |\n| `DATABASE_PATH`               | `/data/postern.db`         | SQLite path inside the portal container. Lives on the `postern-data` named volume, not `./data/`.                                                       |\n| `SMTP_HOST`                   | `localhost`                | Outbound SMTP server.                                                                                                                                   |\n| `SMTP_PORT`                   | `465`                      | `465` → implicit TLS; `587` → STARTTLS; anything else → plaintext.                                                                                      |\n| `SMTP_USER`                   | _(empty)_                  | SMTP auth username.                                                                                                                                     |\n| `SMTP_PASSWORD`               | _(empty)_                  | SMTP auth password.                                                                                                                                     |\n| `SMTP_FROM`                   | `noreply@example.com`      | `From:` header for OTP emails.                                                                                                                          |\n| `OTP_EXPIRY_SECONDS`          | `600`                      | OTP lifetime (10 min).                                                                                                                                  |\n| `OTP_MAX_ATTEMPTS`            | `5`                        | Wrong-code attempts before the OTP is invalidated.                                                                                                      |\n| `OTP_MAX_REQUESTS_PER_WINDOW` | `3`                        | Max active OTPs per email in the rate window.                                                                                                           |\n| `OTP_RATE_WINDOW_SECONDS`     | `900`                      | OTP rate-limit window (15 min).                                                                                                                         |\n| `SESSION_EXPIRY_DAYS`         | `7`                        | Browser session lifetime.                                                                                                                               |\n| `RECONCILE_INTERVAL_SECONDS`  | `60`                       | How often the reconciler syncs DB → containers.                                                                                                         |\n| `SHADOWSOCKS_IMAGE`           | `local/shadowsocks-server` | Image the reconciler spawns per connection.                                                                                                             |\n| `SHADOWSOCKS_NETWORK`         | `shadowsocks`              | Docker bridge network `ss-*` containers join; Nginx attaches to the same one.                                                                           |\n| `DOMAIN`                      | `postern.example.com`      | Public domain. Used in client configs and server `plugin_opts`.                                                                                         |\n| `COMPOSE_PROFILES`            | `with-mta`                 | Compose profiles to activate. Built-in MTA default-on; comment to opt out and set `SMTP_HOST` to a third-party relay.                                   |\n| `MTA_VERIFY_DNS`              | `true`                     | Built-in MTA refuses to start if any required DNS record is missing or wrong. Set `false` for dev/CI only.                                              |\n| `MTA_REQUIRE_DNSSEC`          | `auto`                     | Tri-state. `auto` (default) probes DNSSEC at startup and enforces if signed. `true` always enforces (fail-closed). `false` skips.                       |\n| `MTA_ADMIN_EMAIL`             | _(empty)_                  | **Required when `MTA_VERIFY_DNS=true`.** External mailbox where postmaster/abuse/tls-rpt/bounces are forwarded.                                         |\n| `MTA_DKIM_SELECTOR_PREFIX`    | `postern`                  | DKIM selectors take the form `\u003cprefix\u003e-\u003cYYYY-MM\u003e` (date-suffixed for rotation).                                                                         |\n| `MTA_DKIM_ROTATION_DAYS`      | `180`                      | How often the provisioner rotates DKIM keys (when auto-rotation is enabled).                                                                            |\n| `DNS_PROVIDER`                | `none`                     | libdns provider name for DKIM auto-rotation and (optional) TLS cert renewal (`cloudflare`, `route53`, `gandi`, `digitalocean`, `ovh`, `hetzner`, etc.). |\n\n## Re-hosting to a different domain\n\nSet in `.env`:\n\n```ini\nDOMAIN=your.domain.example\nSMTP_FROM=Postern VPN \u003cnoreply@your.domain.example\u003e\nMTA_ADMIN_EMAIL=ops@your.domain.example   # required when using the built-in MTA\n# PRODUCT_NAME=YourBrand                  # optional: cosmetic display name (UI titles, OTP subject)\n```\n\nThat's it — no source edits. The nginx container renders its config templates from `DOMAIN` at start (see [nginx/nginx-entrypoint.sh](nginx/nginx-entrypoint.sh)); the portal reads `DOMAIN` and `PRODUCT_NAME` from env directly.\n\nIf you want to run the test suite against your domain, two test fixtures reference `postern.example.com`:\n\n- [portal/tests/test_reconciler.py](portal/tests/test_reconciler.py)\n- [portal/tests/test_ss_config.py](portal/tests/test_ss_config.py)\n\n([portal/tests/test_routes.py](portal/tests/test_routes.py) reads `settings.product_name` and `settings.domain` and adapts to whatever you set, so it doesn't need editing.)\n\nTo rebuild the nginx image after pulling a new `nginx/etc/*.tmpl`:\n\n```bash\ndocker compose up -d --build nginx\n```\n\nFor deployments that put postern behind an external reverse proxy doing TCP+SNI passthrough (Traefik, HAProxy, etc.) — see [docs/gateway.md](docs/gateway.md).\n\n## Admin workflow\n\nPostern has no self-serve signup. Users and their connections are created by the operator via the `postern` CLI, which ships inside the portal image:\n\n```bash\n# Add a user\ndocker compose exec portal postern user add \"Alice\" alice@example.com\n\n# Give them a connection (creates a 24-hex-char path token + random password)\ndocker compose exec portal postern connection add alice@example.com \"laptop\"\n\n# Inspect\ndocker compose exec portal postern user list\ndocker compose exec portal postern connection list --user-email alice@example.com\n\n# Disable / enable / delete\ndocker compose exec portal postern connection disable \u003cconnection_id\u003e\ndocker compose exec portal postern connection enable  \u003cconnection_id\u003e\ndocker compose exec portal postern user disable alice@example.com\ndocker compose exec portal postern user delete  alice@example.com\n```\n\nCLI commands that change connection state (`connection add/enable/disable`, `user disable/delete`) create `/data/.reconcile-now` to wake the reconciler; the corresponding container appears (or disappears) within a few seconds. Pure reads (`list`) and `user add` do not trigger a reconcile — a user with no connections doesn't need any container.\n\nFrom the user's side:\n\n1. Visit `https://\u003cyour-domain\u003e/login`, enter their email.\n1. Receive a 6-digit OTP by email, submit it.\n1. On the dashboard, click their connection to download a JSON config (file name `postern-\u003clabel\u003e.json`).\n1. Import that JSON into a Shadowsocks-rust client. It points at `wss://\u003cyour-domain\u003e:443` with `plugin_opts=tls;fast-open;path=/t/\u003ctoken\u003e;host=\u003cyour-domain\u003e`.\n\n## How the VPN tunnel works\n\nA client connects to `wss://\u003cyour-domain\u003e:443/t/\u003ctoken\u003e` (v2ray-plugin in TLS + WebSocket mode). Nginx matches the path with `^/t/([a-f0-9]{24})$` and proxies the upgraded connection to `http://ss-\u003ctoken\u003e:80`, resolved on the `shadowsocks` Docker network via Docker's embedded DNS. The `ss-\u003ctoken\u003e` container runs v2ray-plugin → Shadowsocks-rust, decrypts the tunnel, and forwards traffic to the destination.\n\n## Operations\n\n- **Logs.** Nginx logs are on the host at [nginx/log/](nginx/log/) (`access.log`, `error.log`). Portal logs go to `docker compose logs -f portal`. `ss-*` containers run with `LogConfig(type=\"none\")` — they're deliberately logless.\n- **Reconciliation.** The portal runs a background loop every `RECONCILE_INTERVAL_SECONDS` (default 60s). To trigger it immediately after a DB mutation: `docker compose exec portal postern reconcile`. It also restarts exited `ss-*` containers and recreates them when the `local/shadowsocks-server` image ID changes.\n- **Cert renewal.** Nginx self-reloads every 6 hours via a background shell loop injected by [nginx/Dockerfile](nginx/Dockerfile). This picks up certbot-renewed certificates from the bind-mounted `/etc/letsencrypt` without restarting the container. `inotifywait` is not used — it does not reliably observe Let's Encrypt's symlink-target updates across Docker bind mounts.\n- **Portal restarts stop all tunnels.** When the `portal` container's lifespan ends, it calls `cleanup_all_containers()`, which stops and removes every `ss-*` container. They come back on the next reconciliation pass (a few seconds later), but connections are interrupted. Cleanup is best-effort — if the docker-proxy is unavailable during shutdown, containers can survive into the next portal start; the reconciler adopts them by their `postern.managed=true` label on the following pass.\n- **Data.** The SQLite database lives only in the `postern-data` named Docker volume. `./data/` is gitignored ([`.gitignore`](.gitignore)) and not otherwise used by the project.\n\n## Project layout\n\n```\ncompose.yaml                    # Orchestration (nginx + portal + docker-proxy + optional mta + provisioner)\nnginx/                          # Reverse proxy\n    Dockerfile\n    etc/nginx.conf\n    etc/conf.d/                 # ssl.conf, cert include, mta-sts vhost\n    log/                        # Bind-mounted; nginx writes access/error logs here\nportal/                         # FastAPI management service (Python 3.13)\n    Dockerfile\n    pyproject.toml\n    src/postern/                # app.py, auth.py, db.py, reconciler.py, cli.py, mta/...\n    tests/\nmta/                            # Built-in MTA (Postfix + opendkim + Unbound + postsrsd + mta-sts-resolver)\n    Dockerfile\n    entrypoint.py\n    etc/                        # string.Template config templates\nprovisioner/                    # DKIM rotation + (planned) ACME DNS-01 cert renewal\n    Dockerfile\n    entrypoint.py\n    postern-dns/                # Go module: txt-set/txt-delete via libdns\nshadowsocks/                    # Per-connection tunnel image (Go + Rust multi-stage)\n    Dockerfile\ndocs/                           # Deployer guides\n    mta.md\nexternal/                       # Vendored upstreams, managed as git-subrepos\n    shadowsocks-rust/\n    v2ray-plugin/\nscripts/                        # Prek (pre-commit) helpers\n.github/workflows/              # subrepo-pull.yaml: Renovate-driven subrepo updates\n```\n\n## See also\n\n- [CONTRIBUTING.md](CONTRIBUTING.md) — dev setup, tests, prek, subrepo workflow\n- [CLAUDE.md](CLAUDE.md) — guide for AI coding agents working on this repo\n\n## License\n\n\u003cimg align=\"right\" src=\"https://www.gnu.org/graphics/agplv3-with-text-162x68.png\"\u003e\n\nCopyright (C) 2026, Anna Zhukova\n\nThis project is licensed under the [GNU AGPL version 3.0](/LICENSE.md), which means it is free for you to use. Some files in this repository are external and are licensed under their own terms, conveyed in an in-file license header.\n\n## About\n\nA _postern_ is a small, hidden door set in the wall of a medieval fortification. Where the main gate was the formally guarded entrance, the postern let inhabitants slip in and out unnoticed — to launch a sortie, smuggle in supplies, or quietly retreat. Postern VPN takes the same shape: a discreet way through a wall.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbindreams%2Fpostern","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fbindreams%2Fpostern","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbindreams%2Fpostern/lists"}