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
5 changes: 4 additions & 1 deletion QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ pipx run --spec turnstone turnstone-doctor --dir ~/turnstone
provider and key so it can still help.
3. **Version check** — reports the installed version, version drift across your
cluster's nodes, and the latest upstream stable/experimental releases.
4. **Interactive diagnosis** — it reads logs, `/health`, `docker compose ps`,
4. **Caddy check** — on a Docker install, confirms Caddy is running the
Caddyfile on disk. Caddy reads the file only when it starts, so after a `git
pull` or an edit the report gives you the restart command.
5. **Interactive diagnosis** — it reads logs, `/health`, `docker compose ps`,
`systemctl`, config, and ports to pin down problems like a node not joining
the console, an unreachable database, a down model backend, port conflicts,
or a JWT-secret mismatch — then hands you the precise remediation commands.
Expand Down
2 changes: 1 addition & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ services:
- "127.0.0.1:${SEARXNG_HTTPS_PORT:-8444}:8444"
volumes:
- ./turnstone/deploy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data # persist Caddy's local CA across restarts
- caddy-data:/data # persist Caddy's local CA and certs across restarts
- caddy-config:/config
networks:
- turnstone-net
Expand Down
5 changes: 3 additions & 2 deletions docs/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ Caddy, the channel gateway, and **10 server nodes** (`node-1`…`node-10`). No

Open the dashboard at **https://localhost:8443**. It's served by Caddy with its
own local CA, so trust the root certificate once (or click through the browser
warning):
warning). The certificate is valid for a year and survives restarts (see
[tls.md](tls.md#browser-access-dashboard-https)):

```bash
docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crt
Expand Down Expand Up @@ -465,7 +466,7 @@ docker compose build --no-cache # rebuild from scratch
| `postgres-data` | PostgreSQL data directory |
| `turnstone-data` | `/data` per node (SQLite fallback, local state) |
| `workspace` | `/workspace` (unless `WORKSPACE_MOUNT` is set) |
| `caddy-data` / `caddy-config` | Caddy's local CA and config (dev stack) |
| `caddy-data` / `caddy-config` | Caddy's local CA, the dashboard certificate, and config |

## Working directory

Expand Down
22 changes: 18 additions & 4 deletions docs/tls.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,32 @@ trusted cert, point Caddy at Let's Encrypt by editing `turnstone/deploy/Caddyfil
browser --h2 / HTTPS--> caddy:443 --h1.1 / HTTP--> console:8090
```

Caddy uses its **own local CA** (`tls internal`, see `turnstone/deploy/Caddyfile`), so the
setup is self-contained with no dependency on the console's ACME path. Trust the
local root once to silence the browser warning:
Caddy uses its **own local CA** (`cert_issuer internal`, see `turnstone/deploy/Caddyfile`),
so the setup is self-contained with no dependency on the console's ACME path. The CA and
the dashboard's certificate live in the `caddy-data` volume, so they survive restarts,
and the certificate is valid for a year (Caddy renews it with about a third left).
Trust the local root once to silence the browser warning:

```bash
docker compose exec caddy \
cat /data/caddy/pki/authorities/local/root.crt # import into your OS/browser
```

The certificate names the address you browse to: `localhost` or a host name gets
a certificate for that name. A browser sends no name when it dials an IP address,
so every IP address gets one certificate for `127.0.0.1`. From another machine,
browse by host name if you want the trusted root to silence the warning. Caddy
issues a certificate for any name a client asks for, so anyone who can reach the
port can add certificates to that volume: publish it only to networks you trust.

Caddy reads its Caddyfile only when it starts. After editing it, or after a `git
pull` that changes it, run `docker compose restart caddy`. Re-running `run.sh`
does this for you, and `turnstone-doctor --report` flags a Caddy that is still
running an older config.

**Can Caddy get its cert from the console's internal CA instead?** Not directly.
The console's ACME signing routes require Turnstone's rotating enrollment JWT,
which a standard Caddy ACME issuer does not attach. Keep `tls internal`, or use a
which a standard Caddy ACME issuer does not attach. Keep Caddy's local CA, or use a
public ACME CA for a publicly trusted certificate. An authenticated gateway or
Caddy plugin would be required to use Turnstone's responder.

Expand Down
16 changes: 14 additions & 2 deletions run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
# 5. picks free host ports for Caddy (prefers 443) and PostgreSQL
# 6. writes a .env with a generated JWT secret + Postgres password
# 7. optionally creates shared config.toml for OAuth/SSO and delegation
# 8. pins the choices and runs `docker compose up -d`, then prints how to
# finish setup in the UI
# 8. pins the choices, runs `docker compose up -d` and restarts Caddy so it
# loads the current Caddyfile, then prints how to finish setup in the UI
#
# Re-running is safe: it updates the checkout and keeps existing .env/config.toml.
#
Expand Down Expand Up @@ -535,6 +535,17 @@ PY
fi
}

# -- caddy --------------------------------------------------------------------
# Caddy reads its Caddyfile only when it starts, and `up -d` leaves it running
# when its image and settings are unchanged, so a re-run that pulled a new
# Caddyfile would not apply it. Restarting also remounts the file, which a pull
# replaces rather than edits in place.
restart_caddy() {
info "Restarting Caddy so it loads the current Caddyfile."
( cd "$INSTALL_DIR" && $DOCKER compose restart caddy ) \
|| warn "Caddy did not restart, so Caddyfile changes are not live yet. Run: cd $(printf '%q' "$INSTALL_DIR") && $DOCKER compose restart caddy"
}

# -- summary ------------------------------------------------------------------
print_done() {
local url scale
Expand Down Expand Up @@ -615,6 +626,7 @@ main() {
if ! ( cd "$INSTALL_DIR" && $DOCKER compose up -d --remove-orphans ); then
die "the stack failed to start (see output above). Inspect logs: cd $INSTALL_DIR && $DOCKER compose logs"
fi
restart_caddy

print_done
}
Expand Down
51 changes: 51 additions & 0 deletions tests/test_docker_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import json
import os
import queue
import re
import shutil
import subprocess
import threading
Expand Down Expand Up @@ -385,3 +386,53 @@ def test_installer_upgrades_legacy_node_override(tmp_path):
result = run_installer_functions(tmp_path, "NODE_COUNT=3; write_compose_override")
assert result.returncode == 0, result.stderr
assert len(yaml.safe_load(path.read_text())["services"]) == 7


@pytest.mark.parametrize("restarted", [True, False])
def test_installer_restarts_caddy_to_load_pulled_caddyfile(tmp_path, restarted):
# `up -d` leaves an unchanged caddy container on the Caddyfile it started with.
status = 0 if restarted else 1
result = run_installer_functions(
tmp_path,
f'docker_mock() {{ echo "$PWD|$*" >>calls.log; return {status}; }}; restart_caddy',
)
assert result.returncode == 0, result.stderr
assert (tmp_path / "calls.log").read_text().splitlines() == [
f"{tmp_path}|compose restart caddy"
]
assert ("compose restart caddy" in result.stderr) is not restarted


def caddyfile_blocks(text):
"""Map each top-level Caddyfile block's header ('' for global options) to its lines."""
blocks, depth, header, body = {}, 0, "", []
for raw in text.splitlines():
line = re.sub(r"(^|\s)#.*", "", raw).strip()
if not line:
continue
if depth == 0:
header, body = line.rstrip("{").strip(), []
else:
body.append(line)
depth += line.count("{") - line.count("}")
if depth == 0:
blocks[header] = body[:-1]
return blocks


def test_caddyfile_issues_every_site_a_year_long_cert():
blocks = caddyfile_blocks((ROOT / "turnstone/deploy/Caddyfile").read_text())
options = blocks.pop("")
# An IP-address visitor sends no SNI; without a fixed name the cert follows
# the container's Docker IP and changes on every recreate.
assert "default_sni 127.0.0.1" in options
# Without sign_with_root, certs are cut to the intermediate's 7 days.
assert "cert_issuer internal {" in options and "sign_with_root" in options
days = [int(m[1]) for line in options if (m := re.fullmatch(r"lifetime (\d+)d", line))]
assert len(days) == 1 and 300 <= days[0] < 825 # some clients reject certs over 825 days
grace = [int(m[1]) for line in options if (m := re.fullmatch(r"grace_period (\d+)s", line))]
assert len(grace) == 1 and grace[0] < 10 # under Docker's stop timeout
assert ":443" in blocks
for body in blocks.values():
assert "on_demand" in body
assert not any("issuer" in line or "lifetime" in line for line in body)
Loading
Loading