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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ htmlcov/
playwright-report/
test-results/
.env
/driving-log-runtime/
*.sqlite3
*.sqlite3-wal
*.sqlite3-shm
Expand Down
43 changes: 31 additions & 12 deletions deploy/README.md
Original file line number Diff line number Diff line change
@@ -1,33 +1,52 @@
# Rocky deployment

The application listens only on `127.0.0.1:8766`. Tailscale Serve is the
network authorization boundary; there is deliberately no application login.
The application listens only on `127.0.0.1:8766`. When it is exposed through
Tailscale Funnel, its application login is the access boundary. It has two
accounts: Sean Ahern and Jen Ahern. The account that starts or saves a drive is
recorded as that drive's supervising driver.

Install the checked-out stack tip:

```sh
sudo dnf install -y epel-release
sudo dnf install -y python3.13 python3.13-pip sqlite poppler-utils
./driving-log bootstrap
./driving-log install --public-host HOSTNAME:8443 --public-scheme http
./driving-log install --public-scheme https
loginctl enable-linger "$USER"
```

`install` finds this machine's Tailscale DNS name, creates the git-ignored
`driving-log-runtime/` directory, copies a legacy home-directory log there if
one exists, writes the private environment file, and installs/reloads the user
systemd units. It does not start the service yet.

Before starting, set a password for each account. Each command prompts twice;
the tool salts and hashes the password directly into the private environment
file, so nothing needs to be copied or pasted:

```sh
./driving-log --set-password Sean
./driving-log --set-password Jen
./driving-log start
tailscale serve --bg --http=8443 http://127.0.0.1:8766
tailscale funnel --bg --https=8443 http://127.0.0.1:8766
```

Re-running `./driving-log install` after an update reloads the units and restarts
the web service and archive timer when they are already active. This keeps the
running application schema support in step with migrations performed by
one-shot commands.

Before changing Serve, save `tailscale serve status --json` and verify the
existing HTTP port 80 handler still forwards to Wordle on
`127.0.0.1:8765`. Never use Funnel and never bind this application to a LAN or
tailnet address.

The environment file is mode `0600` at
`~/.config/driving-log/environment`. Mutable state is mode `0700` beneath
`~/.local/state/driving-log`; the database and archives are mode `0600`.
Before changing Funnel, save `tailscale serve status --json` and verify the
existing HTTP port 80 handler still forwards to Wordle on `127.0.0.1:8765`.
Never bind this application to a LAN or tailnet address. The service refuses to
start unless both password hashes are configured; the installer creates the
separate session-signing secret automatically.

The git-ignored `driving-log-runtime/` directory holds the mode-`0600`
environment file and database, plus mode-`0700` archive and restore-request
directories. The installer safely copies an existing legacy home-directory
database and its archives there once; it leaves the original intact as a
fallback.
Configure `DRIVING_LOG_EXTERNAL_ARCHIVE_DIR` to protect against loss of the
Rocky disk.

Expand Down
2 changes: 1 addition & 1 deletion deploy/systemd/driving-log-archive.service
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Description=Create and verify Daniel driving-log archive
[Service]
Type=oneshot
WorkingDirectory=%h/work/driving-record
EnvironmentFile=-%h/.config/driving-log/environment
EnvironmentFile=-%h/work/driving-record/driving-log-runtime/environment
ExecStart=%h/work/driving-record/driving-log archive create
ExecStart=%h/work/driving-record/driving-log archive replicate
NoNewPrivileges=true
Expand Down
4 changes: 2 additions & 2 deletions deploy/systemd/driving-log-restore@.service
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ Conflicts=driving-log-web.service
[Service]
Type=oneshot
WorkingDirectory=%h/work/driving-record
EnvironmentFile=-%h/.config/driving-log/environment
EnvironmentFile=-%h/work/driving-record/driving-log-runtime/environment
ExecStart=%h/work/driving-record/driving-log archive restore-request %i
ExecStopPost=systemctl --user start driving-log-web.service
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=%h/.local/state/driving-log
ReadWritePaths=%h/work/driving-record/driving-log-runtime
UMask=0077
4 changes: 2 additions & 2 deletions deploy/systemd/driving-log-web.service
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ After=network.target tailscaled.service
[Service]
Type=simple
WorkingDirectory=%h/work/driving-record
EnvironmentFile=-%h/.config/driving-log/environment
EnvironmentFile=-%h/work/driving-record/driving-log-runtime/environment
ExecStart=%h/work/driving-record/driving-log serve
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=%h/.local/state/driving-log
ReadWritePaths=%h/work/driving-record/driving-log-runtime
UMask=0077

[Install]
Expand Down
1 change: 1 addition & 0 deletions driving_log/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
configure_logging()
selected.ensure_directories()
selected.validate_authentication()
with application_lock(selected.state_dir, exclusive=False):
# Liveness remains available while readiness explains the safe refusal.
with suppress(Exception):
Expand Down
121 changes: 121 additions & 0 deletions driving_log/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
from __future__ import annotations

import base64
import hashlib
import hmac
import json
import secrets
import time
from dataclasses import dataclass

COOKIE_NAME = "driving_log_session"
SESSION_LIFETIME_SECONDS = 60 * 60 * 24 * 30
ACCOUNTS = {"sean": "Sean Ahern", "jen": "Jen Ahern"}
SCRYPT_N = 2**17
SCRYPT_MAXMEM = 256 * 1024 * 1024


@dataclass(frozen=True)
class Authenticator:
sean_password_hash: str
jen_password_hash: str
session_secret: str

def authenticate(self, account: str, password: str) -> str | None:
password_hash = {
"sean": self.sean_password_hash,
"jen": self.jen_password_hash,
}.get(account)
if not password_hash or not verify_password(password, password_hash):
return None
return ACCOUNTS[account]

def make_session(self, user: str) -> str:
payload = {
"exp": int(time.time()) + SESSION_LIFETIME_SECONDS,
"nonce": secrets.token_urlsafe(16),
"user": user,
"credential": self._credential_fingerprint(user),
}
encoded = _encode(payload)
signature = hmac.new(
self.session_secret.encode(), encoded.encode(), hashlib.sha256
).hexdigest()
return f"{encoded}.{signature}"

def session_user(self, token: str | None) -> str | None:
if not token or "." not in token:
return None
encoded, supplied_signature = token.rsplit(".", 1)
expected_signature = hmac.new(
self.session_secret.encode(), encoded.encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(supplied_signature, expected_signature):
return None
try:
payload = json.loads(_decode(encoded))
user = payload["user"]
expires_at = payload["exp"]
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
return None
if (
not isinstance(user, str)
or user not in ACCOUNTS.values()
or not isinstance(expires_at, int)
or expires_at < time.time()
or payload.get("credential") != self._credential_fingerprint(user)
):
return None
return user

def _credential_fingerprint(self, user: str) -> str:
password_hash = {
"Sean Ahern": self.sean_password_hash,
"Jen Ahern": self.jen_password_hash,
}[user]
return hashlib.sha256(password_hash.encode()).hexdigest()[:16]


def password_hash(password: str) -> str:
if not password:
raise ValueError("password must not be empty")
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(
password.encode(), salt=salt, n=SCRYPT_N, r=8, p=1, maxmem=SCRYPT_MAXMEM
)
salt_text = base64.urlsafe_b64encode(salt).decode()
digest_text = base64.urlsafe_b64encode(digest).decode()
return f"scrypt${SCRYPT_N}$8$1${salt_text}${digest_text}"


def verify_password(password: str, encoded: str) -> bool:
try:
algorithm, n_text, r_text, p_text, salt_text, digest_text = encoded.split("$")
if algorithm != "scrypt":
return False
salt = base64.urlsafe_b64decode(salt_text)
expected = base64.urlsafe_b64decode(digest_text)
actual = hashlib.scrypt(
password.encode(),
salt=salt,
n=int(n_text),
r=int(r_text),
p=int(p_text),
dklen=len(expected),
maxmem=SCRYPT_MAXMEM,
)
except (ValueError, TypeError):
return False
return hmac.compare_digest(actual, expected)


def _encode(value: dict[str, object]) -> str:
return (
base64.urlsafe_b64encode(json.dumps(value, sort_keys=True, separators=(",", ":")).encode())
.decode()
.rstrip("=")
)


def _decode(value: str) -> str:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)).decode()
60 changes: 56 additions & 4 deletions driving_log/cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import argparse
import getpass
import json
import os
import shutil
Expand All @@ -11,6 +12,7 @@

from driving_log import __version__
from driving_log.archive import create_archive, restore_archive, verify_archive
from driving_log.auth import password_hash
from driving_log.config import Settings
from driving_log.csv_backup import export_csv, import_csv
from driving_log.db import Database
Expand All @@ -31,7 +33,8 @@
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="driving-log")
parser.add_argument("--version", action="version", version=__version__)
sub = parser.add_subparsers(dest="command", required=True)
parser.add_argument("--set-password", choices=("Sean", "Jen"), metavar="NAME")
sub = parser.add_subparsers(dest="command")
serve = sub.add_parser("serve")
serve.add_argument("--host")
serve.add_argument("--port", type=int)
Expand All @@ -42,7 +45,7 @@ def build_parser() -> argparse.ArgumentParser:
for command in ("start", "stop", "restart", "status", "logs"):
sub.add_parser(command)
install = sub.add_parser("install")
install.add_argument("--public-host", required=True)
install.add_argument("--public-host")
install.add_argument("--public-scheme", choices=("http", "https"), default="http")
install.add_argument("--external-archive-dir", type=Path)
live = sub.add_parser("live")
Expand Down Expand Up @@ -186,13 +189,62 @@ def doctor(settings: Settings) -> dict[str, object]:
return result


def tailscale_public_host() -> str:
status = subprocess.run(
["tailscale", "status", "--json"], text=True, capture_output=True, check=False
)
if status.returncode:
raise ValueError("could not read Tailscale status; pass --public-host HOST:8443")
try:
dns_name = json.loads(status.stdout)["Self"]["DNSName"]
except (KeyError, TypeError, json.JSONDecodeError) as exc:
raise ValueError(
"could not find the Tailscale DNS name; pass --public-host HOST:8443"
) from exc
if not isinstance(dns_name, str) or not dns_name:
raise ValueError("could not find the Tailscale DNS name; pass --public-host HOST:8443")
return f"{dns_name.rstrip('.')}:8443"


def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
parser = build_parser()
args = parser.parse_args(argv)
settings = Settings.from_env()
if args.set_password:
if args.command:
parser.error("--set-password cannot be combined with another command")
password = getpass.getpass(f"New password for {args.set_password}: ")
confirmation = getpass.getpass("Confirm password: ")
if password != confirmation:
raise ValueError("passwords do not match")
if not password:
raise ValueError("password must not be empty")
environment_path = settings.state_dir / "environment"
if not environment_path.exists():
raise ValueError("run driving-log install before setting a password")
environment = {
key: value
for line in environment_path.read_text(encoding="utf-8").splitlines()
if "=" in line and not line.startswith("#")
for key, value in [line.split("=", 1)]
}
key = f"DRIVING_LOG_{args.set_password.upper()}_PASSWORD_HASH"
environment[key] = password_hash(password)
temporary = environment_path.with_suffix(".tmp")
temporary.write_text(
"".join(f"{key}={value}\n" for key, value in sorted(environment.items())),
encoding="utf-8",
)
temporary.chmod(0o600)
os.replace(temporary, environment_path)
print(f"Password saved for {args.set_password}. Restart the service to use it.")
return 0
if not args.command:
parser.error("choose a command or use --set-password NAME")
if args.command == "install":
install_result = install_user_units(
Path(__file__).parent.parent,
public_host=args.public_host,
public_host=args.public_host or tailscale_public_host(),
public_scheme=args.public_scheme,
external_archive_dir=args.external_archive_dir,
)
Expand Down
24 changes: 23 additions & 1 deletion driving_log/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,17 @@ class Settings:
public_scheme: str = "http"
operation_secret: str = "development-only-operation-secret"
development_allow_non_loopback: bool = False
auth_required: bool = True
sean_password_hash: str = ""
jen_password_hash: str = ""
session_secret: str = ""

@classmethod
def from_env(cls) -> Settings:
state = Path(
os.environ.get(
"DRIVING_LOG_STATE_DIR",
str(Path.home() / ".local" / "state" / "driving-log"),
str(Path(__file__).resolve().parent.parent / "driving-log-runtime"),
)
).expanduser()
host = os.environ.get("DRIVING_LOG_HOST", "127.0.0.1")
Expand All @@ -40,6 +44,7 @@ def from_env(cls) -> Settings:
)
public_host = os.environ.get("DRIVING_LOG_PUBLIC_HOST", "127.0.0.1:8766")
public_scheme = os.environ.get("DRIVING_LOG_PUBLIC_SCHEME", "http")
auth_required_text = os.environ.get("DRIVING_LOG_AUTH_REQUIRED")
if public_scheme not in {"http", "https"}:
raise ValueError("DRIVING_LOG_PUBLIC_SCHEME must be http or https")
return cls(
Expand All @@ -60,8 +65,25 @@ def from_env(cls) -> Settings:
"development-only-operation-secret",
),
development_allow_non_loopback=development_override,
auth_required=(
auth_required_text != "0"
if auth_required_text is not None
else not public_host.startswith(("127.0.0.1", "localhost", "testserver"))
),
sean_password_hash=os.environ.get("DRIVING_LOG_SEAN_PASSWORD_HASH", ""),
jen_password_hash=os.environ.get("DRIVING_LOG_JEN_PASSWORD_HASH", ""),
session_secret=os.environ.get("DRIVING_LOG_SESSION_SECRET", ""),
)

def validate_authentication(self) -> None:
if self.auth_required and not all(
(self.sean_password_hash, self.jen_password_hash, self.session_secret)
):
raise ValueError(
"authentication is required; configure DRIVING_LOG_SEAN_PASSWORD_HASH, "
"DRIVING_LOG_JEN_PASSWORD_HASH, and DRIVING_LOG_SESSION_SECRET"
)

def ensure_directories(self) -> None:
for path in (self.state_dir, self.archive_dir, self.restore_dir):
path.mkdir(mode=0o700, parents=True, exist_ok=True)
Expand Down
Loading
Loading