deploy-guard: block activation while users are online
- modules/server-deploy-guard.nix: extendable aggregator registered via
services.deployGuard.checks.<name>.{description,command}. Installs
deploy-guard-check with per-check timeout, pass/block reporting, JSON
output, DEPLOY_GUARD_BYPASS / /run/deploy-guard-bypass (single-shot).
- services/jellyfin/jellyfin-deploy-guard.nix: curl+jq on /Sessions,
blocks when any session carries NowPlayingItem; soft-fails when unreachable.
- services/minecraft-deploy-guard.nix: mcstatus SLP query on 25565, blocks
when players.online > 0; soft-fails when unreachable.
- flake.nix: wrap deploy.nodes.muffin activation with activate.custom so
deploy-guard-check runs before switch-to-configuration. Auto-rollback
catches the failure. dryActivate/boot branches preserved.
- deploy.sh: SSH preflight for ./deploy.sh muffin with --force /
DEPLOY_GUARD_FORCE=1 (touches remote bypass marker). Connectivity
failure is soft; activation still enforces.
- tests/deploy-guard.nix: aggregator contract, bypass mechanics, timeout,
JSON output.
This commit is contained in:
@@ -2,5 +2,6 @@
|
||||
imports = [
|
||||
./jellyfin.nix
|
||||
./jellyfin-qbittorrent-monitor.nix
|
||||
./jellyfin-deploy-guard.nix
|
||||
];
|
||||
}
|
||||
|
||||
78
services/jellyfin/jellyfin-deploy-guard.nix
Normal file
78
services/jellyfin/jellyfin-deploy-guard.nix
Normal file
@@ -0,0 +1,78 @@
|
||||
# Deploy guard check for Jellyfin.
|
||||
#
|
||||
# Contract (deploy-guard-check plug-in):
|
||||
# - exit 0: Jellyfin has no active playback sessions (or is unreachable, which
|
||||
# also means no users can be watching).
|
||||
# - exit 1: at least one session is actively playing back media; stdout lists
|
||||
# user / title / client so the operator sees who they'd disrupt.
|
||||
#
|
||||
# A paused session counts as "active" — the user is at the keyboard and will
|
||||
# notice a restart.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
service_configs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
apiKeyPath = config.age.secrets.jellyfin-api-key.path;
|
||||
jellyfinPort = service_configs.ports.private.jellyfin.port;
|
||||
|
||||
check = pkgs.writeShellApplication {
|
||||
name = "deploy-guard-check-jellyfin";
|
||||
runtimeInputs = with pkgs; [
|
||||
curl
|
||||
jq
|
||||
coreutils
|
||||
];
|
||||
text = ''
|
||||
api_key_path=${lib.escapeShellArg apiKeyPath}
|
||||
if [[ ! -r "$api_key_path" ]]; then
|
||||
echo "jellyfin: api key not readable at $api_key_path; skipping" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
key=$(cat "$api_key_path")
|
||||
|
||||
if ! resp=$(curl -sf --max-time 5 \
|
||||
-H "Authorization: MediaBrowser Token=$key" \
|
||||
"http://127.0.0.1:${toString jellyfinPort}/Sessions" 2>/dev/null); then
|
||||
echo "jellyfin: unreachable; assuming safe to deploy" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Parse defensively — if Jellyfin returns something we can't understand
|
||||
# we prefer allowing the deploy over blocking it (the worst case is we
|
||||
# restart jellyfin while nobody is watching).
|
||||
if ! active=$(printf '%s' "$resp" | jq '[.[] | select(.NowPlayingItem)] | length' 2>/dev/null); then
|
||||
echo "jellyfin: /Sessions response not parsable; assuming safe" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$active" -eq 0 ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Jellyfin: $active active playback session(s):"
|
||||
printf '%s' "$resp" | jq -r '
|
||||
.[]
|
||||
| select(.NowPlayingItem)
|
||||
| " - \(.UserName // "?") \(if (.PlayState.IsPaused // false) then "paused" else "playing" end) \(.NowPlayingItem.Type // "item") \"\(.NowPlayingItem.Name // "?")\" on \(.Client // "?") / \(.DeviceName // "?")"
|
||||
'
|
||||
exit 1
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
../../modules/server-deploy-guard.nix
|
||||
];
|
||||
|
||||
config = lib.mkIf config.services.jellyfin.enable {
|
||||
services.deployGuard.checks.jellyfin = {
|
||||
description = "Active Jellyfin playback sessions";
|
||||
command = check;
|
||||
};
|
||||
};
|
||||
}
|
||||
67
services/minecraft-deploy-guard.nix
Normal file
67
services/minecraft-deploy-guard.nix
Normal file
@@ -0,0 +1,67 @@
|
||||
# Deploy guard check for the Minecraft server.
|
||||
#
|
||||
# Queries the standard Server List Ping (SLP) handshake on the game port —
|
||||
# no RCON, no query, no extra config. SLP is always enabled and returns the
|
||||
# live player count plus (usually) a short name sample.
|
||||
#
|
||||
# Contract (deploy-guard-check plug-in):
|
||||
# - exit 0: no players online, or the server isn't reachable at all (down ⇒
|
||||
# no users to disrupt).
|
||||
# - exit 1: at least one player is connected; stdout lists the names that
|
||||
# made it into the SLP sample.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
service_configs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
minecraftPort = service_configs.ports.public.minecraft.port;
|
||||
|
||||
check =
|
||||
pkgs.writers.writePython3Bin "deploy-guard-check-minecraft"
|
||||
{
|
||||
libraries = [ pkgs.python3Packages.mcstatus ];
|
||||
flakeIgnore = [
|
||||
"E501"
|
||||
"E402"
|
||||
];
|
||||
}
|
||||
''
|
||||
import sys
|
||||
|
||||
try:
|
||||
from mcstatus import JavaServer
|
||||
except ImportError as e:
|
||||
print(f"minecraft: mcstatus unavailable ({e}); assuming safe", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
status = JavaServer.lookup("127.0.0.1:${toString minecraftPort}", timeout=5).status()
|
||||
except Exception as e:
|
||||
print(f"minecraft: unreachable ({e}); assuming safe to deploy", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
online = status.players.online
|
||||
if online <= 0:
|
||||
sys.exit(0)
|
||||
|
||||
sample = getattr(status.players, "sample", None) or []
|
||||
names = ", ".join(p.name for p in sample) or "<names not reported>"
|
||||
print(f"Minecraft: {online} player(s) online: {names}")
|
||||
sys.exit(1)
|
||||
'';
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
../modules/server-deploy-guard.nix
|
||||
];
|
||||
|
||||
config = lib.mkIf config.services.minecraft-servers.enable {
|
||||
services.deployGuard.checks.minecraft = {
|
||||
description = "Players connected to the Minecraft server";
|
||||
command = check;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
"z ${service_configs.minecraft.parent_dir}/${service_configs.minecraft.server_name} 710 ${config.services.minecraft-servers.user} ${config.services.minecraft-servers.group}"
|
||||
"z ${service_configs.minecraft.parent_dir}/${service_configs.minecraft.server_name}/squaremap 710 ${config.services.minecraft-servers.user} ${config.services.minecraft-servers.group}"
|
||||
])
|
||||
./minecraft-deploy-guard.nix
|
||||
];
|
||||
|
||||
boot.kernel.sysctl = {
|
||||
|
||||
Reference in New Issue
Block a user