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
37 changes: 30 additions & 7 deletions deploy/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Rocky deployment

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 three
accounts: Sean Ahern and Jen Ahern can record drives. Daniel Ahern can be given
view-only access to the log, CSV backup, and DMV PDF. The account that starts
or saves a drive is recorded as that drive's supervising driver.
Tailscale Funnel, its application login is the access boundary. It has four
accounts: Sean Ahern, Jen Ahern, and Bethany O'Banion can record drives.
Daniel Ahern can be given view-only access to the log, CSV backup, and DMV PDF.
The account that starts or saves a drive is recorded as that drive's supervising
driver.

Install the checked-out stack tip:

Expand All @@ -28,6 +29,7 @@ file, so nothing needs to be copied or pasted:
```sh
./driving-log --set-password Sean
./driving-log --set-password Jen
./driving-log --set-password Bethany
./driving-log --set-password Daniel
./driving-log start
tailscale funnel --bg --https=8443 http://127.0.0.1:8766
Expand All @@ -41,9 +43,30 @@ one-shot commands.
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 Sean and Jen's password hashes are configured; Daniel's account is
available after its password is set. The installer creates the
separate session-signing secret automatically.
start unless Sean and Jen's password hashes are configured; Bethany's and
Daniel's accounts are available after their passwords are set. The installer
creates the separate session-signing secret automatically.

### Updating an existing deployment

After an update has merged, run these commands from the checked-out repository
on the Rocky host:

```sh
./driving-log stop
git pull --ff-only origin main
./driving-log --set-password Bethany
./driving-log install --public-scheme https
./driving-log start
./driving-log status
```

The password command prompts twice and writes only a salted hash to the private
runtime environment. Stopping first ensures the old process cannot continue
using the previous environment. `install` reloads the user units and applies
the checked-out code; `start` brings the web service and archive timer back up.
If the deployment uses an explicit `--public-host`, pass that same value to
`install` instead of relying on Tailscale DNS discovery.

The git-ignored `driving-log-runtime/` directory holds the mode-`0600`
environment file and database, plus mode-`0700` archive and restore-request
Expand Down
12 changes: 10 additions & 2 deletions driving_log/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@

COOKIE_NAME = "driving_log_session"
SESSION_LIFETIME_SECONDS = 60 * 60 * 24 * 30
ACCOUNTS = {"sean": "Sean Ahern", "jen": "Jen Ahern", "daniel": "Daniel Ahern"}
ACCOUNTS = {
"sean": "Sean Ahern",
"jen": "Jen Ahern",
"bethany": "Bethany O'Banion",
"daniel": "Daniel Ahern",
}
SCRYPT_N = 2**17
SCRYPT_MAXMEM = 256 * 1024 * 1024

Expand All @@ -21,11 +26,13 @@ class Authenticator:
jen_password_hash: str
session_secret: str
daniel_password_hash: str = ""
bethany_password_hash: str = ""

def authenticate(self, account: str, password: str) -> str | None:
password_hash = {
"sean": self.sean_password_hash,
"jen": self.jen_password_hash,
"bethany": self.bethany_password_hash,
"daniel": self.daniel_password_hash,
}.get(account)
if not password_hash or not verify_password(password, password_hash):
Expand All @@ -34,7 +41,7 @@ def authenticate(self, account: str, password: str) -> str | None:

@staticmethod
def can_mutate(user: str) -> bool:
return user in (ACCOUNTS["sean"], ACCOUNTS["jen"])
return user in (ACCOUNTS["sean"], ACCOUNTS["jen"], ACCOUNTS["bethany"])

def make_session(self, user: str) -> str:
payload = {
Expand Down Expand Up @@ -78,6 +85,7 @@ def _credential_fingerprint(self, user: str) -> str:
password_hash = {
"Sean Ahern": self.sean_password_hash,
"Jen Ahern": self.jen_password_hash,
"Bethany O'Banion": self.bethany_password_hash,
"Daniel Ahern": self.daniel_password_hash,
}[user]
return hashlib.sha256(password_hash.encode()).hexdigest()[:16]
Expand Down
4 changes: 3 additions & 1 deletion driving_log/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="driving-log")
parser.add_argument("--version", action="version", version=__version__)
parser.add_argument("--set-password", choices=("Sean", "Jen", "Daniel"), metavar="NAME")
parser.add_argument(
"--set-password", choices=("Sean", "Jen", "Bethany", "Daniel"), metavar="NAME"
)
sub = parser.add_subparsers(dest="command")
serve = sub.add_parser("serve")
serve.add_argument("--host")
Expand Down
2 changes: 2 additions & 0 deletions driving_log/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class Settings:
jen_password_hash: str = ""
session_secret: str = ""
daniel_password_hash: str = ""
bethany_password_hash: str = ""

@classmethod
def from_env(cls) -> Settings:
Expand Down Expand Up @@ -75,6 +76,7 @@ def from_env(cls) -> Settings:
jen_password_hash=os.environ.get("DRIVING_LOG_JEN_PASSWORD_HASH", ""),
session_secret=os.environ.get("DRIVING_LOG_SESSION_SECRET", ""),
daniel_password_hash=os.environ.get("DRIVING_LOG_DANIEL_PASSWORD_HASH", ""),
bethany_password_hash=os.environ.get("DRIVING_LOG_BETHANY_PASSWORD_HASH", ""),
)

def validate_authentication(self) -> None:
Expand Down
1 change: 1 addition & 0 deletions driving_log/templates/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ <h1>Sign in</h1>
<legend>Account</legend>
<label class="choice"><input type="radio" name="account" value="sean" checked> Sean Ahern</label>
<label class="choice"><input type="radio" name="account" value="jen"> Jen Ahern</label>
<label class="choice"><input type="radio" name="account" value="bethany"> Bethany O'Banion</label>
<label class="choice"><input type="radio" name="account" value="daniel"> Daniel Ahern (view only)</label>
</fieldset>
<label>Password<input name="password" type="password" autocomplete="current-password" required autofocus></label>
Expand Down
7 changes: 5 additions & 2 deletions driving_log/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ def _duration_parts(minutes: int) -> dict[str, int]:
("weather", "Weather"),
("duration", "Duration"),
)
AUTH_SUPERVISORS = (ACCOUNTS["sean"], ACCOUNTS["jen"])
AUTH_SUPERVISORS = (ACCOUNTS["sean"], ACCOUNTS["jen"], ACCOUNTS["bethany"])
MUTATION_PAGE_PATHS = frozenset({"/drives/new", "/live", "/archives"})


Expand Down Expand Up @@ -273,6 +273,7 @@ def register_web(app: FastAPI, settings: Settings, database: Database) -> None:
settings.jen_password_hash,
settings.session_secret,
settings.daniel_password_hash,
settings.bethany_password_hash,
)
login_failures: dict[tuple[str, str], tuple[int, float]] = {}
login_failures_lock = asyncio.Lock()
Expand Down Expand Up @@ -337,7 +338,9 @@ def selected_supervisor(
) -> str | None:
value = str(form.get("supervisor_name", default or _actor(request) or "")) or None
if settings.auth_required and value not in AUTH_SUPERVISORS:
raise ValueError("Choose Sean Ahern or Jen Ahern as the supervising driver")
raise ValueError(
"Choose Sean Ahern, Jen Ahern, or Bethany O'Banion as the supervising driver"
)
return value

def safe_next(value: str | None) -> str:
Expand Down
6 changes: 5 additions & 1 deletion tests/test_foundation.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,14 @@ def test_password_hashes_and_signed_sessions_reject_invalid_values(self) -> None
self.assertFalse(verify_password("password", "not-a-password-hash"))
with self.assertRaisesRegex(ValueError, "empty"):
password_hash("")
authenticator = Authenticator(encoded_password, encoded_password, "session-secret")
authenticator = Authenticator(
encoded_password, encoded_password, "session-secret", "", encoded_password
)
token = authenticator.make_session("Sean Ahern")
self.assertEqual(authenticator.session_user(token), "Sean Ahern")
self.assertEqual(authenticator.authenticate("bethany", "password"), "Bethany O'Banion")
self.assertTrue(authenticator.can_mutate("Sean Ahern"))
self.assertTrue(authenticator.can_mutate("Bethany O'Banion"))
self.assertFalse(authenticator.can_mutate("Daniel Ahern"))
rotated = Authenticator(password_hash("rotated"), encoded_password, "session-secret")
self.assertIsNone(rotated.session_user(token))
Expand Down
17 changes: 17 additions & 0 deletions tests/test_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ def test_systemd_service_binds_only_through_configured_loopback(self) -> None:
self.assertIn("127.0.0.1:8765", readme)
self.assertIn("tailscale funnel", readme.lower())
self.assertIn("--set-password Sean", readme)
self.assertIn("--set-password Bethany", readme)
self.assertIn("--set-password Daniel", readme)

def test_no_private_seed_is_tracked_by_ignore_contract(self) -> None:
Expand Down Expand Up @@ -381,6 +382,22 @@ def test_set_password_supports_daniel_view_only_account(self) -> None:
self.assertNotIn("new-password", contents)
self.assertEqual(environment.stat().st_mode & 0o777, 0o600)

def test_set_password_supports_bethany_account(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
state = Path(temporary)
environment = state / "environment"
environment.write_text("DRIVING_LOG_STATE_DIR=" + str(state) + "\n")
with (
mock.patch.dict(os.environ, {"DRIVING_LOG_STATE_DIR": str(state)}),
mock.patch(
"driving_log.cli.getpass.getpass", side_effect=["new-password", "new-password"]
),
):
self.assertEqual(main(["--set-password", "Bethany"]), 0)
contents = environment.read_text()
self.assertIn("DRIVING_LOG_BETHANY_PASSWORD_HASH=scrypt$", contents)
self.assertNotIn("new-password", contents)

def test_set_password_requires_matching_confirmation_and_runtime_environment(self) -> None:
with (
tempfile.TemporaryDirectory() as temporary,
Expand Down
Loading