Files
puppy-tracker/module.nix
T
Alexander Heldt 51d015c231 Add push reminders for sleep, pee, poo and meals
A closed PWA has no timers, so reminders are evaluated on the server: the
event log is already there (clients sync on every mutation), and a ticker
re-checks each enabled rule once a minute and pushes the ones that are due.

Two rule shapes. "sleep" measures from the last sleep-end and fires only
while the puppy is awake. "pee"/"poo"/"eat" measure from the newest event of
that type and stay quiet while the puppy is asleep — otherwise they nag all
night, and suppressing them means an overdue rule instead fires promptly on
waking, which is when it actually matters. Sleep state is derived exactly the
way currentSleepState() does in app.js, tie-break included, so both sides
always agree. Rules read the event's own timestamp rather than when it synced,
so a pee logged offline at 03:10 cancels the reminder retroactively.

Every push carries a tag, so a repeat replaces the previous notification
instead of stacking another one on the lock screen. last_fired is server-owned
and not writable by a client, so a stale device can't force a re-fire.

Web Push is implemented directly rather than pulled in as a dependency: RFC
8291 encryption in the RFC 8188 aes128gcm coding with an RFC 8292 VAPID token,
stdlib only, checked against the RFC 8291 test vector. The key is generated
into vapid.json beside the DB or supplied via -vapid-key; without one the
server logs a warning, skips registering the routes, and the client hides the
UI. Subscriptions a push service reports as 404/410 are dropped.

PNG icons are added because iOS gates push on a Home Screen install and
rejects SVG for apple-touch-icon, and Android has no notification icon
without them.
2026-08-20 17:19:18 +00:00

129 lines
4.4 KiB
Nix

self: { config, lib, pkgs, ... }:
let
cfg = config.services.puppy-tracker;
staticPkg = self.packages.${pkgs.system}.static;
serverPkg = self.packages.${pkgs.system}.server;
in
{
options.services.puppy-tracker = {
enable = lib.mkEnableOption "Puppy Tracker (offline-first puppy tracking app with sync server)";
address = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0";
description = "Address the server listens on.";
};
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "TCP port the server listens on.";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to open the configured port in the firewall.";
};
inviteCodeFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/run/secrets/puppy-invite-code";
description = ''
Path to an EnvironmentFile containing the shared registration secret as
`PUPPY_INVITE_CODE=...`. Kept out of the Nix store so the code stays
secret. When null, registration is disabled (existing accounts can still
log in).
'';
};
vapidKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/run/secrets/puppy-vapid-key";
description = ''
Path to an EnvironmentFile containing the Web Push signing key as
`PUPPY_VAPID_KEY=...` (a base64url P-256 private scalar). When null the
server generates one on first start and keeps it in its state directory,
which is fine for a single host. Note that browsers pin this key when
they subscribe: replacing it silently breaks every existing reminder
subscription until each device re-enables notifications.
'';
};
secureCookies = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Mark session cookies Secure. Enable once the service is reached over
HTTPS (e.g. behind a TLS-terminating reverse proxy); leave off for plain
HTTP on a LAN, or browsers will drop the cookie and logins won't stick.
'';
};
package = lib.mkOption {
type = lib.types.package;
default = serverPkg;
defaultText = lib.literalExpression "puppy-tracker.packages.\${system}.server";
description = "The puppy-tracker server package.";
};
staticPackage = lib.mkOption {
type = lib.types.package;
default = staticPkg;
defaultText = lib.literalExpression "puppy-tracker.packages.\${system}.static";
description = "The puppy-tracker static-site package (HTML/CSS/JS).";
};
};
config = lib.mkIf cfg.enable {
systemd.services.puppy-tracker = {
description = "Puppy Tracker server";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
ExecStart = lib.concatStringsSep " " ([
"${cfg.package}/bin/puppy-tracker-server"
"-addr ${cfg.address}:${toString cfg.port}"
"-static ${cfg.staticPackage}/share/puppy-tracker"
"-data /var/lib/puppy-tracker/puppy.db"
] ++ lib.optional cfg.secureCookies "-secure-cookies");
# Secrets (registration code, Web Push key) are read from env files kept
# out of the store, exposed to the server as PUPPY_INVITE_CODE and
# PUPPY_VAPID_KEY.
EnvironmentFile = lib.filter (f: f != null) [ cfg.inviteCodeFile cfg.vapidKeyFile ];
DynamicUser = true;
StateDirectory = "puppy-tracker";
StateDirectoryMode = "0750";
Restart = "on-failure";
RestartSec = "2s";
# Hardening
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = true;
NoNewPrivileges = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
RestrictRealtime = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
SystemCallArchitectures = "native";
SystemCallFilter = [ "@system-service" "~@privileged @resources" ];
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
};
};
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
};
}