diff --git a/.gitignore b/.gitignore
index 30bfd99..fd106d0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ htmlcov/
playwright-report/
test-results/
.env
+/driving-log-runtime/
*.sqlite3
*.sqlite3-wal
*.sqlite3-shm
diff --git a/deploy/README.md b/deploy/README.md
index 521036a..e59fad2 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -1,7 +1,9 @@
# 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:
@@ -9,10 +11,24 @@ Install the checked-out stack tip:
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
@@ -20,14 +36,17 @@ 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.
diff --git a/deploy/systemd/driving-log-archive.service b/deploy/systemd/driving-log-archive.service
index d22f55d..2e27320 100644
--- a/deploy/systemd/driving-log-archive.service
+++ b/deploy/systemd/driving-log-archive.service
@@ -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
diff --git a/deploy/systemd/driving-log-restore@.service b/deploy/systemd/driving-log-restore@.service
index 761758f..cb45e39 100644
--- a/deploy/systemd/driving-log-restore@.service
+++ b/deploy/systemd/driving-log-restore@.service
@@ -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
diff --git a/deploy/systemd/driving-log-web.service b/deploy/systemd/driving-log-web.service
index c8c8eab..7200841 100644
--- a/deploy/systemd/driving-log-web.service
+++ b/deploy/systemd/driving-log-web.service
@@ -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]
diff --git a/driving_log/app.py b/driving_log/app.py
index df78091..a3427be 100644
--- a/driving_log/app.py
+++ b/driving_log/app.py
@@ -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):
diff --git a/driving_log/auth.py b/driving_log/auth.py
new file mode 100644
index 0000000..1bb8507
--- /dev/null
+++ b/driving_log/auth.py
@@ -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()
diff --git a/driving_log/cli.py b/driving_log/cli.py
index 488144e..1e1090f 100644
--- a/driving_log/cli.py
+++ b/driving_log/cli.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
+import getpass
import json
import os
import shutil
@@ -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
@@ -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)
@@ -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")
@@ -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,
)
diff --git a/driving_log/config.py b/driving_log/config.py
index 7608229..6f4321d 100644
--- a/driving_log/config.py
+++ b/driving_log/config.py
@@ -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")
@@ -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(
@@ -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)
diff --git a/driving_log/operations.py b/driving_log/operations.py
index b11bf24..a4b0dc9 100644
--- a/driving_log/operations.py
+++ b/driving_log/operations.py
@@ -6,6 +6,7 @@
import os
import secrets
import shutil
+import sqlite3
import subprocess
import time
import uuid
@@ -38,11 +39,10 @@ def install_user_units(
public_scheme: str = "http",
external_archive_dir: Path | None = None,
) -> dict[str, str]:
- config_dir = Path.home() / ".config" / "driving-log"
unit_dir = Path.home() / ".config" / "systemd" / "user"
- config_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
unit_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
- state_dir = Path.home() / ".local" / "state" / "driving-log"
+ state_dir = repo_root / "driving-log-runtime"
+ legacy_state_dir = Path.home() / ".local" / "state" / "driving-log"
for directory in (
state_dir,
state_dir / "archives",
@@ -50,10 +50,15 @@ def install_user_units(
):
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
directory.chmod(0o700)
- environment_path = config_dir / "environment"
+ migrated = _migrate_legacy_runtime(legacy_state_dir, state_dir)
+ environment_path = state_dir / "environment"
+ legacy_environment_path = Path.home() / ".config" / "driving-log" / "environment"
existing: dict[str, str] = {}
- if environment_path.exists():
- for line in environment_path.read_text(encoding="utf-8").splitlines():
+ source_environment_path = (
+ environment_path if environment_path.exists() else legacy_environment_path
+ )
+ if source_environment_path.exists():
+ for line in source_environment_path.read_text(encoding="utf-8").splitlines():
if "=" in line and not line.startswith("#"):
key, value = line.split("=", 1)
existing[key] = value
@@ -62,13 +67,17 @@ def install_user_units(
or existing.get("DRIVING_LOG_FORM_SECRET")
or secrets.token_urlsafe(48)
)
+ session_secret = existing.get("DRIVING_LOG_SESSION_SECRET") or secrets.token_urlsafe(48)
environment = {
**existing,
"DRIVING_LOG_HOST": "127.0.0.1",
"DRIVING_LOG_PORT": "8766",
+ "DRIVING_LOG_STATE_DIR": str(state_dir.resolve()),
"DRIVING_LOG_PUBLIC_HOST": public_host,
"DRIVING_LOG_PUBLIC_SCHEME": public_scheme,
+ "DRIVING_LOG_AUTH_REQUIRED": "1",
"DRIVING_LOG_OPERATION_SECRET": secret,
+ "DRIVING_LOG_SESSION_SECRET": session_secret,
}
selected_external = (
str(external_archive_dir.expanduser().resolve())
@@ -97,9 +106,32 @@ def install_user_units(
"environment": str(environment_path),
"unit_directory": str(unit_dir),
"public_host": public_host,
+ "legacy_runtime_migrated": str(migrated).lower(),
}
+def _migrate_legacy_runtime(legacy_state_dir: Path, state_dir: Path) -> bool:
+ """Copy an existing default runtime safely before switching to the repo runtime."""
+ legacy_database = legacy_state_dir / "driving-log.sqlite3"
+ database = state_dir / "driving-log.sqlite3"
+ if not legacy_database.exists() or database.exists():
+ return False
+ source = sqlite3.connect(legacy_database)
+ destination = sqlite3.connect(database)
+ try:
+ source.backup(destination)
+ finally:
+ destination.close()
+ source.close()
+ database.chmod(0o600)
+ for directory in ("archives", "restore-requests"):
+ source_dir = legacy_state_dir / directory
+ destination_dir = state_dir / directory
+ if source_dir.exists():
+ shutil.copytree(source_dir, destination_dir, dirs_exist_ok=True)
+ return True
+
+
def service_snapshot() -> dict[str, object]:
result: dict[str, object] = {}
for unit in ("driving-log-web.service", "driving-log-archive.timer"):
diff --git a/driving_log/static/app.css b/driving_log/static/app.css
index 8241e2f..297b773 100644
--- a/driving_log/static/app.css
+++ b/driving_log/static/app.css
@@ -36,6 +36,13 @@ header a { color: var(--text); text-decoration: none; }
.brand { font-weight: 800; }
nav { display: flex; gap: 1rem; font-size: .88rem; }
main { width: min(100%, 42rem); margin: auto; padding: 1rem 1rem max(2rem, env(safe-area-inset-bottom)); }
+.account-footer {
+ display: flex; align-items: center; justify-content: space-between; gap: 1rem;
+ width: min(100%, 42rem); margin: 0 auto; padding: 1rem max(1rem, env(safe-area-inset-right))
+ max(1rem, env(safe-area-inset-bottom)); color: var(--muted); font-size: .88rem;
+}
+.account-footer form { margin: 0; }
+.account-footer button { width: auto; min-height: 2.5rem; padding: .45rem .75rem; }
h1 { font-size: 1.75rem; margin: .4rem 0 1.2rem; }
h2 { font-size: 1.2rem; }
.progress-card {
diff --git a/driving_log/templates/base.html b/driving_log/templates/base.html
index 146eb4f..75536a4 100644
--- a/driving_log/templates/base.html
+++ b/driving_log/templates/base.html
@@ -16,11 +16,17 @@
{% block content %}{% endblock %}
+ {% if current_user %}
+
+ {% endif %}