Files
pad/.env.example
T
xarmian 10309fc599 fix(config): read PUBLIC_URL for emailed link generation (BUG-899) (#318)
* fix(config): read PUBLIC_URL for emailed link generation (BUG-899)

The Pad Cloud deployment binds pad to 0.0.0.0 (Dockerfile, k8s configmap,
pad-cloud's docker-compose) and never set PAD_URL on the pad service, so
cfg.BaseURL() fell through to "http://0.0.0.0:7777" — that string ended
up in password-reset (and invite + share-link + admin-invitation) emails
and was unreachable to recipients.

Adds a PUBLIC_URL env var read by the server only (does not flip CLI to
remote mode the way PAD_URL does — PUBLIC_URL is a generic env var name
commonly set in unrelated deployment contexts). Stored in a separate
Config.PublicURL field consulted by BaseURL() as a fallback after URL.

Resolution order in BaseURL(): PAD_URL > PUBLIC_URL > host:port.

Also logs a WARN at server startup if the resolved base URL has an
unspecified bind-all host (0.0.0.0, ::, [::]) — a backstop that would
have caught BUG-899 the first time email went out.

Tests cover the precedence ladder, mode-not-flipping, PAD_URL-beats-
PUBLIC_URL, and the BUG-899 repro shape (Host=0.0.0.0 with no URL set
yields the broken http://0.0.0.0 URL).

Companion change in pad-cloud/docker-compose.yml passes PUBLIC_URL
through to the pad service so the Cloud deployment stops shipping
broken email links.

Parent: BUG-899 (TASK-908).

* fix(config): keep PUBLIC_URL out of IsConfigured() per Codex review (round 2)

PUBLIC_URL was setting LoadedFromEnv = true, which IsConfigured() consults
to decide whether the CLI has explicit configuration. A generic PUBLIC_URL
in the environment (very common name) would have made any host appear
"configured" to the CLI and skipped the not-configured / setup branch —
the exact footgun the separate-field design was supposed to avoid.

PUBLIC_URL is purely a server-side fact; LoadedFromEnv is purely a CLI
affordance. Stop conflating them. Adds a focused regression test pinning
the IsConfigured() invariant.

* fix(config): split PublicLinkBaseURL from BaseURL per Codex review (round 3)

Round 2's BaseURL fall-through to PublicURL leaked PUBLIC_URL into ~20
CLI-client call sites (cli.NewClientFromURL(cfg.BaseURL()) patterns
across cmd/pad/main.go, init.go, server_info.go, configure.go) — same
footgun the separate-field design was meant to avoid: a developer with
a host-level PUBLIC_URL set for unrelated reasons would have their CLI
silently route requests to that URL instead of the local server.

Restore BaseURL() to its original CLI-only contract (URL > host:port).
Add PublicLinkBaseURL() with the URL > PublicURL > host:port ladder
that's used at exactly the two server-side call sites that build
emailed-link targets:

  - cmd/pad/main.go:279  srv.SetBaseURL(cfg.PublicLinkBaseURL())
  - cmd/pad/main.go:464  email.NewSender(..., cfg.PublicLinkBaseURL())

Tests pin both contracts: BaseURL() ignores PublicURL even when set;
PublicLinkBaseURL() honors the precedence ladder. PAD_URL still wins
in both, preserving back-compat.

* fix(config): drop public_url toml tag to prevent CLI persistence per Codex review (round 4)

Round 3 left PublicURL serializable to ~/.pad/config.toml via toml:
"public_url". A CLI user who runs `pad init` or `pad configure` on a
host where PUBLIC_URL is set for unrelated reasons would end up with
that URL persisted into their config file, surviving any later unset
of the env var and contaminating server-side emailed link generation
indefinitely (server reads ~/.pad/config.toml on the next boot).

Switch the field to toml:"-". PUBLIC_URL is a deployment-time fact
(env var / docker-compose / k8s); operators who want a config-file
equivalent already have `url` (the PAD_URL path), which serializes
properly. Adds a regression test pinning that Save() never writes
PublicURL to the file.
2026-04-30 08:40:33 -04:00

149 lines
7.7 KiB
Bash

# Pad — Docker Compose environment example
#
# Copy to .env, fill in values, then run `docker compose up -d`.
#
# If you don't already have a POSTGRES_PASSWORD, generate one with:
# openssl rand -base64 32
# …and paste it below. Docker Compose WILL refuse to start with the password
# missing; this is deliberate so a fresh deploy can never inherit a known-weak
# default credential.
# REQUIRED — PostgreSQL password for the `pad` user.
POSTGRES_PASSWORD=
# REQUIRED on Postgres — AES-256 encryption key for TOTP seeds and other
# sensitive fields. 64-character hex string (32 bytes). Pad will auto-
# generate one on SQLite deployments but refuses to on Postgres because
# multi-replica deployments would generate divergent keys and fail cross-
# instance decryption. Generate with:
# openssl rand -hex 32
PAD_ENCRYPTION_KEY=
# OPTIONAL — Which host interface the Pad web UI publishes to.
# Default (loopback only) is the safest choice on a fresh install, because
# the bootstrap endpoint is reachable until the first admin is created.
# 127.0.0.1 → localhost only (default)
# 0.0.0.0 → every interface (LAN + internet if no firewall)
# 10.0.0.5 → bind to a specific LAN IP
# PAD_BIND_ADDR=127.0.0.1
# OPTIONAL — Redis auth (docker-compose.prod.yml only).
# If set, Redis starts with `--requirepass` and Pad connects with the password.
# REDIS_PASSWORD=
# OPTIONAL — Pad Cloud sidecar shared secret (cloud mode only).
# Comma-separated list supports rotation for INBOUND calls: pad accepts
# any listed secret when pad-cloud calls pad's admin endpoints.
# PAD_CLOUD_SECRET=
# OPTIONAL — Base URL pad uses to call pad-cloud back (reverse direction,
# e.g. Stripe cancel-customer when a user deletes their account). Required
# in cloud mode to cascade account deletion through to Stripe billing;
# without it, pad deletes local rows but never cancels the subscription.
# PAD_CLOUD_SIDECAR_URL=http://pad-cloud:7778
# OPTIONAL — Exact outbound secret pad sends on reverse calls. Use this
# during a cloud-secret rotation when pad-cloud is still validating against
# the OLD value: pin the outbound here so the reverse call matches, then
# roll pad-cloud, then unpin. When unset, pad falls back to the LAST entry
# of PAD_CLOUD_SECRET (the older rotation value).
# PAD_CLOUD_OUTBOUND_SECRET=
# OPTIONAL — Encryption key for sensitive fields (32-byte hex string).
# If unset, Pad stores 2FA seeds in plaintext — set this for production.
# openssl rand -hex 32
# PAD_ENCRYPTION_KEY=
# OPTIONAL — Trusted reverse-proxy CIDRs. Only peers in these CIDRs may set
# X-Forwarded-For / X-Real-IP. Leave unset for direct-exposed deploys.
# PAD_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12
# OPTIONAL — Bearer token required to scrape /metrics. When unset, /metrics
# is reachable only from loopback (so a Prometheus on the same host keeps
# working without config); when set, every scrape must send
# "Authorization: Bearer $PAD_METRICS_TOKEN".
# openssl rand -hex 32
# PAD_METRICS_TOKEN=
# OPTIONAL — Session IP-change policy.
# Default (unset) logs an ActionSessionIPChanged audit entry when a session
# presents a new client IP and updates the stored IP. "strict" additionally
# destroys the session and forces re-login. Strict mode breaks legitimate
# mobility (mobile roaming, VPN toggles, carrier NAT); enable only for
# high-sensitivity deployments.
# PAD_IP_CHANGE_ENFORCE=strict
# ─────────────────────────────────────────────────────────────────────
# Core server — see docs/deployment.md for full reference.
# ─────────────────────────────────────────────────────────────────────
# Listen address. 0.0.0.0 for containers, 127.0.0.1 for loopback-only.
# PAD_HOST=0.0.0.0
# Listen port.
# PAD_PORT=7777
# Public-facing base URL. Used to build invitation + password-reset links.
# Required on deployments that bind to 0.0.0.0 — without it, emailed links
# point at "http://0.0.0.0:7777" which recipients cannot reach (BUG-899).
# PAD_URL=https://pad.example.com
# Alternative to PAD_URL using the generic env-var convention. Read by the
# server only (does not flip the CLI to remote mode, does not contaminate
# the CLI's BaseURL, and is intentionally NOT persisted to config.toml on
# `pad init` / `pad configure` saves), so it's safe to set on hosts that
# already use PUBLIC_URL for other tooling. Pad-cloud sets it on its
# sidecar and forwards it to the pad service in the same compose.
# Precedence: PAD_URL > PUBLIC_URL > constructed http://host:port.
# PUBLIC_URL=https://pad.example.com
# Writable data directory — SQLite DB, config, logs. Default: ~/.pad
# PAD_DATA_DIR=/data
# Log level: debug | info | warn | error
# PAD_LOG_LEVEL=info
# Operating mode: local | remote | cloud. Controls how the CLI
# discovers the daemon and which credential keying rules apply.
# PAD_MODE=local
# ─────────────────────────────────────────────────────────────────────
# Database — SQLite default; set PAD_DB_DRIVER=postgres for Postgres.
# ─────────────────────────────────────────────────────────────────────
# PAD_DB_DRIVER=postgres
# PAD_DB_PATH=/data/pad.db # SQLite path, ignored on Postgres
# PAD_DATABASE_URL=postgres://pad:secret@postgres:5432/pad?sslmode=disable
# ─────────────────────────────────────────────────────────────────────
# Real-time events — cross-instance SSE fan-out via Redis pub/sub.
# Without this, SSE stays in-process (fine for single-node).
# ─────────────────────────────────────────────────────────────────────
# PAD_REDIS_URL=redis://redis:6379
# Global cap on concurrent SSE connections (default: 1000).
# PAD_SSE_MAX_CONNECTIONS=1000
# Per-workspace cap on concurrent SSE connections (default: 100).
# PAD_SSE_MAX_PER_WORKSPACE=100
# ─────────────────────────────────────────────────────────────────────
# Security hardening for production behind TLS.
# ─────────────────────────────────────────────────────────────────────
# Set Secure flag on session cookies — requires TLS.
# PAD_SECURE_COOKIES=true
# Comma-separated list of allowed CORS origins. Leave unset to disable CORS.
# PAD_CORS_ORIGINS=https://pad.example.com,https://app.example.com
# ─────────────────────────────────────────────────────────────────────
# Transactional email via Maileroo. Optional — without it, workspace
# invites fall back to CLI-copyable join codes.
# ─────────────────────────────────────────────────────────────────────
# PAD_MAILEROO_API_KEY=your-sending-key
# PAD_EMAIL_FROM=noreply@example.com
# PAD_EMAIL_FROM_NAME=Pad