Skip to content

Commit 2b2ab83

Browse files
onekiloparsecclaude
andcommitted
hosting: random Postgres password on fresh installs
Replaces the historical default `POSTGRES_PASSWORD=arcsecond_docker` with a per-install URL-safe-base64 value (~256 bits of entropy) generated at first `arcsecond hosting setup`. Existing `.env` files are never overwritten — the value Postgres baked in at first container boot must stay in sync with `.env`, so write_env_file preserves any pre-existing key as it always has. Defense in depth: bind the db (5432) and broker (6379) services to 127.0.0.1 only. Operators can still `psql -h localhost` from the host; the backend reaches both over the internal Docker network. Anything on the LAN is firewalled out. Includes docs/rotate-postgres-password.md for the three pioneer installs already running with the old default — Postgres only reads POSTGRES_PASSWORD on first init, so rotating requires both an ALTER USER and an .env edit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4748506 commit 2b2ab83

5 files changed

Lines changed: 139 additions & 6 deletions

File tree

arcsecond/hosting/docker/docker-compose.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ services:
88
image: postgres:16
99
container_name: arcsecond-db
1010
restart: unless-stopped
11+
# Bind to localhost only — the backend container reaches the DB over the
12+
# internal Docker network (hostname `arcsecond-db`), and operators can
13+
# still run `psql -h localhost` / `pg_dump` from the host. Anything on the
14+
# LAN is firewalled out.
1115
ports:
12-
- "5432:5432"
16+
- "127.0.0.1:5432:5432"
1317
volumes:
1418
- arcsecond_postgres_data:/var/lib/postgresql/data
1519
env_file:
@@ -21,8 +25,10 @@ services:
2125
image: redis:7.4
2226
container_name: arcsecond-broker
2327
restart: unless-stopped
28+
# Localhost-only as well — Redis with no auth on a LAN-exposed port is a
29+
# well-known foot-gun. Backend reaches it over the Docker network.
2430
ports:
25-
- "6379:6379"
31+
- "127.0.0.1:6379:6379"
2632

2733
# Arcsecond backend (REST APIs). Can be used for API calls and external pipelines, scripts etc.
2834
backend:

arcsecond/hosting/local.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
import click
66

77
from arcsecond.options import basic_options
8-
from .utils import _get_encryption_key, _get_random_secret_key
8+
from .utils import _get_encryption_key, _get_random_postgres_password, _get_random_secret_key
99

1010
ENV_FILENAME = ".env"
1111

12+
# Stable across installs — operators connect with this username when running
13+
# manual psql / pg_dump commands. The actual security boundary is the password
14+
# (generated per-install) and the network exposure (localhost-only).
1215
POSTGRES_USER = "arcsecond_docker"
13-
POSTGRES_PASSWORD = "arcsecond_docker"
1416
POSTGRES_DB = "arcsecond_docker"
1517

1618

@@ -39,7 +41,11 @@ def _required_env_values():
3941
"FIELD_ENCRYPTION_KEY": _get_encryption_key(),
4042
"SHARED_DATA_PATH": prompt_shared_data_path(),
4143
"POSTGRES_USER": POSTGRES_USER,
42-
"POSTGRES_PASSWORD": POSTGRES_PASSWORD,
44+
# Per-install random; never overwritten on repeat runs (see write_env_file).
45+
# Postgres only reads this on first container boot to bootstrap the role,
46+
# so the .env value and the live DB password must stay in sync — that's
47+
# why we never regenerate it after the .env exists.
48+
"POSTGRES_PASSWORD": _get_random_postgres_password(),
4349
"POSTGRES_DB": POSTGRES_DB,
4450
}
4551

arcsecond/hosting/utils.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,14 @@ def _get_random_secret_key():
1111

1212
def _get_encryption_key():
1313
return base64.urlsafe_b64encode(os.urandom(32)).decode('UTF8')
14+
15+
16+
def _get_random_postgres_password():
17+
"""Generate a strong, shell- and URL-safe Postgres password.
18+
19+
Uses URL-safe base64 (``[A-Za-z0-9_-]``) so the value never needs
20+
quoting in the .env file or any future connection string. 32 random
21+
bytes → 43 chars, ~256 bits of entropy. Trailing '=' padding is
22+
stripped because it triggers shell parsing weirdness in some setups.
23+
"""
24+
return base64.urlsafe_b64encode(os.urandom(32)).decode('UTF8').rstrip('=')

docs/rotate-postgres-password.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Rotating the Postgres password on an existing self-hosted install
2+
3+
Until version 3.10, `arcsecond hosting setup` wrote a default Postgres password
4+
(`arcsecond_docker`) into `.env`. New installs now generate a per-install
5+
random password automatically, but **existing installs keep the original weak
6+
password until you rotate it manually** — Postgres only reads
7+
`POSTGRES_PASSWORD` at first container boot to bootstrap the role, so changing
8+
the value in `.env` after the fact does nothing on its own.
9+
10+
This guide rotates the password in three coordinated places: the live database
11+
role, the `.env` file, and the running containers.
12+
13+
## 1. Pick a new password
14+
15+
```bash
16+
NEW_PG_PASSWORD=$(python3 -c "import os, base64; print(base64.urlsafe_b64encode(os.urandom(32)).decode().rstrip('='))")
17+
echo "$NEW_PG_PASSWORD"
18+
```
19+
20+
Keep this in your shell session — you will paste it into two places.
21+
22+
## 2. Change the password in the live DB
23+
24+
```bash
25+
docker exec -it arcsecond-db psql \
26+
-U arcsecond_docker \
27+
-d arcsecond_docker \
28+
-c "ALTER USER arcsecond_docker WITH PASSWORD '$NEW_PG_PASSWORD';"
29+
```
30+
31+
The role's password is now updated in Postgres. The backend container is
32+
holding open connections that authenticated under the old password — they will
33+
keep working until restart, which is fine; we restart in step 4.
34+
35+
## 3. Update `.env`
36+
37+
Replace the `POSTGRES_PASSWORD=...` line in your `.env` (next to
38+
`docker-compose.yml`) with the new value. Keep the same `POSTGRES_USER` and
39+
`POSTGRES_DB` values — only the password changes.
40+
41+
```bash
42+
sed -i.bak "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$NEW_PG_PASSWORD/" .env
43+
```
44+
45+
(macOS `sed` writes a `.env.bak` backup. Inspect, then delete the backup
46+
once you've confirmed the rewrite worked.)
47+
48+
## 4. Restart the stack
49+
50+
```bash
51+
docker compose restart backend worker beat
52+
```
53+
54+
Backend, worker, and beat re-read `.env` and pick up the new password. The
55+
`db` container itself does **not** need a restart — its data was already
56+
updated in step 2, and a `db` restart would not re-read `POSTGRES_PASSWORD`
57+
anyway (it only does so on first init of an empty volume).
58+
59+
## 5. Verify
60+
61+
```bash
62+
docker compose logs --tail 50 backend | grep -i 'database'
63+
curl -fsS http://localhost:8800/healthcheck/
64+
```
65+
66+
If `healthcheck/` returns 200, you're done.
67+
68+
## File permissions
69+
70+
While you're in `.env`, lock it down so other local users can't read it:
71+
72+
```bash
73+
chmod 600 .env
74+
```
75+
76+
The file holds the Postgres password, the Django `SECRET_KEY`, the field
77+
encryption key, and the JWT signing keys. None of them are useful to an
78+
attacker on their own, but defense in depth is cheap.

tests/test_hosting_local.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ def test_write_env_file_includes_jwt_signing_keys(tmp_path, monkeypatch):
77
monkeypatch.chdir(tmp_path)
88
monkeypatch.setattr(local, "_get_random_secret_key", lambda: "test-secret")
99
monkeypatch.setattr(local, "_get_encryption_key", lambda: "test-encryption")
10+
monkeypatch.setattr(local, "_get_random_postgres_password", lambda: "test-pg-password")
1011
monkeypatch.setattr(local, "prompt_shared_data_path", lambda: "/tmp/shared-data")
1112

1213
local.write_env_file()
@@ -17,12 +18,40 @@ def test_write_env_file_includes_jwt_signing_keys(tmp_path, monkeypatch):
1718
assert "AGENT_JWT_SIGNING_KEY=test-secret" in env_contents
1819
assert "FIELD_ENCRYPTION_KEY=test-encryption" in env_contents
1920
assert 'SHARED_DATA_PATH="/tmp/shared-data"' in env_contents
21+
# Fresh installs get a per-install random password, never the historical default.
22+
assert "POSTGRES_PASSWORD=test-pg-password" in env_contents
23+
assert "POSTGRES_PASSWORD=arcsecond_docker" not in env_contents
24+
25+
26+
def test_write_env_file_generates_strong_password_on_fresh_install(tmp_path, monkeypatch):
27+
"""End-to-end: when the helper is not stubbed, the generated value
28+
actually has the entropy / character set we expect."""
29+
monkeypatch.chdir(tmp_path)
30+
monkeypatch.setattr(local, "_get_random_secret_key", lambda: "x")
31+
monkeypatch.setattr(local, "_get_encryption_key", lambda: "x")
32+
monkeypatch.setattr(local, "prompt_shared_data_path", lambda: "/tmp/p")
33+
34+
local.write_env_file()
35+
36+
env_contents = (Path(tmp_path) / ".env").read_text(encoding="utf-8")
37+
pg_line = next(line for line in env_contents.splitlines() if line.startswith("POSTGRES_PASSWORD="))
38+
pg_password = pg_line.split("=", 1)[1]
39+
assert pg_password != "arcsecond_docker"
40+
assert len(pg_password) >= 32, f"too weak: {pg_password!r}"
41+
# URL-safe base64 alphabet: never needs quoting in shells or connection strings.
42+
allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_")
43+
assert set(pg_password) <= allowed
2044

2145

2246
def test_write_env_file_preserves_existing_values_and_adds_missing(tmp_path, monkeypatch):
47+
"""Critical for upgrades: rewriting POSTGRES_PASSWORD in .env after
48+
Postgres has been initialized would lock the operator out — the live
49+
DB still uses the original password baked in at first boot. Existing
50+
keys must never be touched."""
2351
monkeypatch.chdir(tmp_path)
2452
monkeypatch.setattr(local, "_get_random_secret_key", lambda: "generated-secret")
2553
monkeypatch.setattr(local, "_get_encryption_key", lambda: "generated-encryption")
54+
monkeypatch.setattr(local, "_get_random_postgres_password", lambda: "freshly-generated-but-unused")
2655
monkeypatch.setattr(local, "prompt_shared_data_path", lambda: "/tmp/generated-shared")
2756

2857
env_path = Path(tmp_path) / ".env"
@@ -31,6 +60,7 @@ def test_write_env_file_preserves_existing_values_and_adds_missing(tmp_path, mon
3160
[
3261
"SECRET_KEY=existing-secret",
3362
"POSTGRES_USER=existing-user",
63+
"POSTGRES_PASSWORD=existing-pg-password",
3464
"",
3565
]
3666
),
@@ -42,11 +72,13 @@ def test_write_env_file_preserves_existing_values_and_adds_missing(tmp_path, mon
4272

4373
assert "SECRET_KEY=existing-secret" in env_contents
4474
assert "POSTGRES_USER=existing-user" in env_contents
75+
# The pre-existing Postgres password is preserved verbatim — never overwritten.
76+
assert "POSTGRES_PASSWORD=existing-pg-password" in env_contents
77+
assert "POSTGRES_PASSWORD=freshly-generated-but-unused" not in env_contents
4578
assert "AUTH_JWT_SIGNING_KEY=generated-secret" in env_contents
4679
assert "AGENT_JWT_SIGNING_KEY=generated-secret" in env_contents
4780
assert "FIELD_ENCRYPTION_KEY=generated-encryption" in env_contents
4881
assert 'SHARED_DATA_PATH="/tmp/generated-shared"' in env_contents
49-
assert "POSTGRES_PASSWORD=arcsecond_docker" in env_contents
5082
assert "POSTGRES_DB=arcsecond_docker" in env_contents
5183

5284

0 commit comments

Comments
 (0)