ci: run Renovate from asxpi/renovate
Some checks failed
nixos / asxpio-services (push) Failing after 54s

Renovate now runs from its own repository for every repo, with the
shared rules in its preset. Extend that preset here and keep only what
is specific to this repo (the Ansible var manager, the Paper JRE, the
runner and nix notes); drop the workflow that ran it for iac alone.

Assisted-by: Claude:opus-5-5
This commit is contained in:
Sergei Poljanski 2026-11-13 00:00:06 +04:00
commit 71047d179d
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
15 changed files with 705 additions and 284 deletions

View file

@ -1,183 +1,81 @@
# Containers run under Podman, one systemd unit each
# (podman-<name>.service). Game data stays in /opt and is not managed here.
{
config,
lib,
pkgs,
...
}:
# Minecraft and SillyTavern. Their data stays in /opt and is not managed
# here.
{ config, ... }:
let
podman = lib.getExe pkgs.podman;
# Time for a Minecraft server to save the world before it is killed
# Paper needs Java 21; the tag is a floating JRE line
javaImage = "docker.io/library/eclipse-temurin:21-jre";
paperJar = "paper-1.21.11-54.jar";
# Time for the server to save the world before it is killed
stopTimeout = 120;
# Nothing that owns data starts on a freshly installed disk: a game
# server would generate a new world where the restore is about to land.
# After restoring /opt, `touch /opt/.restored` and start the units.
needsData.unitConfig.ConditionPathExists = "/opt/.restored";
gameServer = needsData // {
serviceConfig = {
TimeoutStopSec = lib.mkForce (stopTimeout + 30);
# A JVM stopped by SIGTERM exits 143 after saving; that is a clean stop
SuccessExitStatus = [ 143 ];
};
};
# renovate: datasource=docker depName=ghcr.io/sillytavern/sillytavern
sillytavernVersion = "1.19.0";
in
{
virtualisation.podman = {
enable = true;
dockerCompat = true; # `docker` runs podman
dockerSocket.enable = true; # /run/docker.sock, used by the CI runner
defaultNetwork.settings.dns_enabled = true;
autoPrune = {
enable = true;
dates = "Mon 03:00";
flags = [ "--all" ];
};
};
virtualisation.containers.registries.search = [ "docker.io" ];
virtualisation.oci-containers.backend = "podman";
sops.secrets."minecraft_fossils/env" = { };
virtualisation.oci-containers.containers = {
# Paper server; the jar and the world live in /opt/minecraft
minecraft = {
image = "docker.io/library/eclipse-temurin:21-jre";
workdir = "/data";
cmd = [
"java"
"-Xmx4G"
"-Xms2G"
"-jar"
"paper-1.21.11-54.jar"
"nogui"
];
ports = [
"25565:25565"
"19132:19132/udp"
];
volumes = [ "/opt/minecraft:/data" ];
extraOptions = [
"--tty"
"--interactive"
"--stop-timeout=${toString stopTimeout}"
];
};
minecraft-fossils = {
image = "docker.io/itzg/minecraft-server:java21";
environment = {
EULA = "TRUE";
TYPE = "FABRIC";
VERSION = "1.20.1";
FABRIC_LOADER_VERSION = "0.18.4";
MEMORY = "8G";
ONLINE_MODE = "FALSE";
ENFORCE_WHITELIST = "TRUE";
ENFORCE_SECURE_PROFILE = "FALSE";
WHITELIST_FILE = "/data/whitelist.json";
OPS_FILE = "/data/ops.json";
ENABLE_RCON = "TRUE";
MOTD = "[mc.czsk.dev] Fossils Archaeology Revival: Genesis";
USE_AIKAR_FLAGS = "TRUE";
};
environmentFiles = [ config.sops.secrets."minecraft_fossils/env".path ];
ports = [ "25566:25565" ];
volumes = [ "/opt/minecraft-fossils/data:/data" ];
extraOptions = [
"--tty"
"--interactive"
"--network=fossils"
"--stop-timeout=${toString stopTimeout}"
];
};
# Copies whitelist, ops and ban lists from the Paper server
minecraft-fossils-wlsync = {
image = "docker.io/library/alpine:3";
dependsOn = [ "minecraft-fossils" ];
environment = {
RCON_HOST = "minecraft-fossils";
INTERVAL = "15";
};
environmentFiles = [ config.sops.secrets."minecraft_fossils/env".path ];
volumes = [
"/opt/minecraft:/src:ro"
"/opt/minecraft-fossils/data:/dst"
"${./wlsync.sh}:/wlsync.sh:ro"
];
cmd = [
"sh"
"/wlsync.sh"
];
extraOptions = [ "--network=fossils" ];
};
# Reachable on the WireGuard address only
sillytavern = {
image = "ghcr.io/sillytavern/sillytavern:1.19.0";
ports = [ "10.20.0.20:8000:8000" ];
volumes = [
"/opt/sillytavern/config:/home/node/app/config"
"/opt/sillytavern/data:/home/node/app/data"
"/opt/sillytavern/plugins:/home/node/app/plugins"
];
# Paper server; the jar and the world live in /opt/minecraft. Not an
# oci-containers unit: the console needs a TTY and an open stdin, and
# `docker run -it` refuses to start without a terminal. The container
# is created first and then attached to, output only.
#
# docker attach minecraft console; leave with Ctrl-p Ctrl-q
systemd.services.docker-minecraft = {
description = "Minecraft (Paper)";
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
# requires: Docker restarting stops the server first, with time to save
requires = [ "docker.service" ];
after = [
"docker.service"
"network-online.target"
];
unitConfig.ConditionPathExists = "/opt/.restored";
path = [ config.virtualisation.docker.package ];
preStart = ''
docker rm -f minecraft || true
docker create --name minecraft --tty --interactive \
--stop-timeout ${toString stopTimeout} \
--workdir /data --volume /opt/minecraft:/data \
--publish 25565:25565 --publish 19132:19132/udp \
${javaImage} java -Xmx4G -Xms2G -jar ${paperJar} nogui
'';
script = "exec docker start --attach minecraft";
preStop = "docker stop minecraft || true";
postStop = "docker rm -f minecraft || true";
serviceConfig = {
TimeoutStartSec = 0; # the first start pulls the image
TimeoutStopSec = stopTimeout + 30;
# A JVM stopped by SIGTERM exits 143 after saving; that is a clean stop
SuccessExitStatus = [ 143 ];
Restart = "on-failure";
};
};
systemd.services = {
# Network shared by the Fossils server and its sidecar
podman-network-fossils = {
description = "Podman network fossils";
wantedBy = [ "multi-user.target" ];
before = [
"podman-minecraft-fossils.service"
"podman-minecraft-fossils-wlsync.service"
];
requiredBy = [
"podman-minecraft-fossils.service"
"podman-minecraft-fossils-wlsync.service"
];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = "${podman} network exists fossils || ${podman} network create fossils";
};
podman-minecraft = gameServer;
podman-minecraft-fossils = gameServer;
podman-minecraft-fossils-wlsync = needsData;
# The WireGuard address must exist before a port can be bound to it
podman-sillytavern = needsData // {
after = [ "wireguard-asxpio-services.service" ];
requires = [ "wireguard-asxpio-services.service" ];
};
minecraft-fossils-restart = {
description = "Daily restart of minecraft-fossils";
serviceConfig.Type = "oneshot";
script = "systemctl restart podman-minecraft-fossils.service";
};
# Reachable on the WireGuard address only
virtualisation.oci-containers.containers.sillytavern = {
image = "ghcr.io/sillytavern/sillytavern:${sillytavernVersion}";
ports = [ "10.20.0.20:8000:8000" ];
volumes = [
"/opt/sillytavern/config:/home/node/app/config"
"/opt/sillytavern/data:/home/node/app/data"
"/opt/sillytavern/plugins:/home/node/app/plugins"
];
};
systemd.timers.minecraft-fossils-restart = {
description = "Restart minecraft-fossils daily at 08:00 Europe/Tallinn";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "*-*-* 08:00:00 Europe/Tallinn";
Persistent = true;
};
# The WireGuard address must exist before a port can be bound to it
systemd.services.docker-sillytavern = {
unitConfig.ConditionPathExists = "/opt/.restored";
after = [ "wireguard-asxpio-services.service" ];
requires = [ "wireguard-asxpio-services.service" ];
};
# Published ports reach the containers without passing these rules over
# IPv4. Over IPv6 Docker's proxy listens on the host, so they are needed.
networking.firewall.allowedTCPPorts = [ 25565 ];
networking.firewall.allowedUDPPorts = [ 19132 ];
systemd.tmpfiles.rules = [
"d /opt 0755 root root -"
"d /opt/minecraft 0755 root root -"
"d /opt/minecraft-fossils 0755 asxpi users -"
"d /opt/minecraft-fossils/data 0755 asxpi users -"
"d /opt/sillytavern/config 0755 root root -"
"d /opt/sillytavern/data 0755 root root -"
"d /opt/sillytavern/plugins 0755 root root -"

View file

@ -1,9 +1,12 @@
# asxpio-services: CI runner, Minecraft, SillyTavern.
# asxpio-services: CI runner, Minecraft, XMPP, Xray, SillyTavern.
{ config, lib, ... }:
{
imports = [
./containers.nix
./replica.nix
./runner.nix
./xmpp.nix
./xray.nix
];
networking.hostName = lib.mkDefault "asxpio-services";
@ -59,27 +62,26 @@
};
# --- Firewall -----------------------------------------------------------
# The Hetzner firewall is the outer layer. Published container ports are
# forwarded by Podman and do not pass through these input rules.
networking.firewall = {
allowedTCPPorts = [
25565
25566
# The Hetzner firewall is the outer layer. Each service opens its own
# ports next to its definition.
networking.firewall.interfaces = {
asxpio-services.allowedTCPPorts = [
22
8000
];
enp7s0.allowedTCPPorts = [ 22 ];
};
# --- Backup -------------------------------------------------------------
# The Paper world is copied live (RCON is off, so there is no save-off);
# its autosave keeps that close enough. What Paper downloads again is
# left out.
asxpio.backup = {
schedule = "*-*-* 04:15:00";
excludes = [
"/opt/minecraft/cache"
"/opt/minecraft/libraries"
"/opt/minecraft/versions"
];
allowedUDPPorts = [ 19132 ];
interfaces = {
asxpio-services.allowedTCPPorts = [
22
8000
];
enp7s0.allowedTCPPorts = [ 22 ];
# CI job containers reach the runner's cache server on the host
"podman+".allowedTCPPortRanges = [
{
from = 1024;
to = 65535;
}
];
};
};
}

View file

@ -0,0 +1,93 @@
# Daily one-way replica of the hel1 buckets into their fsn1 twins.
# restic/ is excluded: restic writes its own, independent fsn1 repository.
{
config,
lib,
pkgs,
...
}:
let
pairs = [
{
src = "asxpio-backups-main";
dst = "asxpio-backups-repl";
exclude = [ "restic/**" ];
}
{
src = "asxpio-terraform-main";
dst = "asxpio-terraform-repl";
}
{
src = "k8s-asxpio-main";
dst = "k8s-asxpio-repl";
}
];
# A run that would delete more than this stops and fails (and mails):
# a mistake on the hel1 side must not empty the replica the next morning
maxDelete = 1000;
secret = name: config.sops.placeholder.${name};
remote = name: region: extra: ''
RCLONE_CONFIG_${name}_TYPE=s3
RCLONE_CONFIG_${name}_PROVIDER=Other
RCLONE_CONFIG_${name}_ENDPOINT=https://${region}.your-objectstorage.com
RCLONE_CONFIG_${name}_REGION=${region}
RCLONE_CONFIG_${name}_ACCESS_KEY_ID=${secret "s3/access_key"}
RCLONE_CONFIG_${name}_SECRET_ACCESS_KEY=${secret "s3/secret_key"}
${extra}'';
replicate = pkgs.writeShellApplication {
name = "s3-replicate";
runtimeInputs = [ pkgs.rclone ];
# No errexit: a failing pair does not stop the others, but fails the run
bashOptions = [
"nounset"
"pipefail"
];
text = ''
failed=0
''
+ lib.concatMapStrings (p: ''
echo "s3-replicate: ${p.src} -> ${p.dst}"
rclone sync hel1:${p.src} fsn1:${p.dst} \
--fast-list --checkers 16 --transfers 8 --max-delete ${toString maxDelete} \
${lib.concatMapStringsSep " " (e: "--exclude ${lib.escapeShellArg e}") (p.exclude or [ ])} \
--stats-one-line -v || failed=1
'') pairs
+ ''
exit $failed
'';
};
in
{
# The object storage remotes hel1: and fsn1:, as rclone environment
# variables. The migration's restore script reads them too.
sops.templates."s3-replicate-env".content =
remote "HEL1" "hel1" ""
+ remote "FSN1" "fsn1" ''
RCLONE_CONFIG_FSN1_NO_CHECK_BUCKET=true
'';
systemd.services.s3-replicate = {
description = "Replicate the hel1 buckets to fsn1";
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
onFailure = [ "restic-alert@%n.service" ];
serviceConfig = {
Type = "oneshot";
ExecStart = lib.getExe replicate;
EnvironmentFile = config.sops.templates."s3-replicate-env".path;
TimeoutStartSec = "12h";
};
};
systemd.timers.s3-replicate = {
description = "Daily hel1 -> fsn1 bucket replica";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "*-*-* 06:00:00";
RandomizedDelaySec = "15m";
Persistent = true;
};
};
}

View file

@ -1,13 +1,22 @@
# Forgejo Actions runner. It runs as a container and starts job containers
# through Podman's Docker-compatible socket. Its registration lives in
# through the Docker socket. Its registration lives in
# /opt/forgejo-runner/data/.runner and is state, not configuration.
#
# Deploying a change to it restarts the runner under the job that deploys:
# the change lands, that job shows as cancelled, and the next run confirms
# the result.
{ pkgs, ... }:
let
# renovate: datasource=docker depName=code.forgejo.org/forgejo/runner
runnerVersion = "13";
# Image behind the nix-latest label. Jobs mount a nix-store volume over
# /nix, seeded from this image on first use; the image's /bin/sh and
# /etc/nix/nix.conf link into that store, so a volume from another
# version breaks them. Bumping this also switches to a fresh volume.
nixVersion = "2.35.3";
# version breaks them. Bumping this also switches to a fresh volume;
# remove the old one afterwards: docker volume rm nix-store-<old version>
# renovate: datasource=docker depName=nixos/nix
nixVersion = "2.35.6";
settings = {
log = {
@ -65,8 +74,8 @@ let
in
{
virtualisation.oci-containers.containers.forgejo-runner = {
image = "code.forgejo.org/forgejo/runner:11";
user = "0:0"; # needs the container socket
image = "code.forgejo.org/forgejo/runner:${runnerVersion}";
user = "0:0"; # needs the Docker socket
cmd = [
"forgejo-runner"
"daemon"
@ -77,17 +86,29 @@ in
volumes = [
"/opt/forgejo-runner/data:/data"
"${configFile}:/config.yaml:ro"
"/run/podman/podman.sock:/var/run/docker.sock"
"/var/run/docker.sock:/var/run/docker.sock"
];
extraOptions = [ "--network=host" ];
};
systemd.services.podman-forgejo-runner = {
after = [ "podman.socket" ];
requires = [ "podman.socket" ];
# Without its registration the runner only crash-loops
unitConfig.ConditionPathExists = "/opt/forgejo-runner/data/.runner";
};
# Without its registration the runner only crash-loops
systemd.services.docker-forgejo-runner.unitConfig.ConditionPathExists =
"/opt/forgejo-runner/data/.runner";
# CI job containers reach the runner's cache server on the host
networking.firewall.interfaces =
let
highPorts.allowedTCPPortRanges = [
{
from = 1024;
to = 65535;
}
];
in
{
docker0 = highPorts;
"br-+" = highPorts;
};
systemd.tmpfiles.rules = [ "d /opt/forgejo-runner/data 0777 root root -" ];
}

View file

@ -1,35 +0,0 @@
#!/bin/sh
# Mirrors whitelist, ops and ban lists from the Paper server into the
# Fossils server and tells it to reload them over RCON.
apk add --no-cache python3 >/dev/null
cat > /rcon.py <<'PY'
import socket, struct, sys, os
host, port, pw, cmd = os.environ["RCON_HOST"], 25575, os.environ["RCON_PASSWORD"], sys.argv[1]
def pkt(i, t, b): p = struct.pack("<ii", i, t) + b.encode() + b"\x00\x00"; return struct.pack("<i", len(p)) + p
def recv(s):
ln = struct.unpack("<i", s.recv(4))[0]; d = b""
while len(d) < ln: d += s.recv(ln - len(d))
return struct.unpack("<ii", d[:8])[0], d[8:-2].decode(errors="replace")
s = socket.create_connection((host, port), timeout=5)
s.send(pkt(1, 3, pw))
rid, _ = recv(s)
if rid == -1: print("auth failed"); sys.exit(1)
s.send(pkt(2, 2, cmd))
_, out = recv(s); print(out)
PY
echo "[wlsync] started, interval=${INTERVAL}s"
while true; do
for f in whitelist.json ops.json banned-players.json banned-ips.json; do
if [ -f /src/$f ] && ! cmp -s /src/$f /dst/$f 2>/dev/null; then
cp /src/$f /dst/$f
echo "[wlsync] copied $f"
case $f in
whitelist.json) CMD="whitelist reload" ;;
ops.json) CMD="reload" ;;
banned-players.json|banned-ips.json) CMD="kick @a Ban list updated" ;;
esac
python3 /rcon.py "$CMD" 2>&1 | sed 's/^/[wlsync] rcon: /' || echo "[wlsync] rcon failed"
fi
done
sleep $INTERVAL
done

View file

@ -0,0 +1,134 @@
# another.im XMPP server for asxp.io: ejabberd, apushd (push), tackygram
# (Telegram gateway). After compose.yaml and compose.tackygram.yaml of
# https://dev.another.im/another-im/anotherim-server, CERTS=files.
#
# Secrets and keys are host data in /opt/xmpp/state, like a game world,
# and are covered by the restic backup:
# secrets.env ejabberd secrets
# certs/server.pem TLS certificate (asxp.io, conference., xmpp.)
# apushd/ push settings and VAPID key
# tackygram/{env,key} gateway credentials and master key
# Data: Docker volumes xmpp_database, xmpp_tackygram.
{ config, pkgs, ... }:
let
# renovate: datasource=docker depName=dev.another.im/another-im/anotherim-server
ejabberdVersion = "2026.11.07";
# renovate: datasource=docker depName=dev.another.im/another-im/apushd
apushdVersion = "0.2";
# renovate: datasource=docker depName=dev.another.im/another-im/tackygram
tackygramVersion = "0.4.3";
domain = "asxp.io";
publicIp = "46.62.228.216";
state = "/opt/xmpp/state";
tcpPorts = [
5222 # clients, STARTTLS
5223 # clients, direct TLS
5269 # other servers
5443 # WebSocket, host-meta
8448 # Matrix federation
3478 # STUN and TURN
7777 # SOCKS5 bytestreams proxy
];
turnRelay = {
from = 49160;
to = 49199;
};
gatewayCalls = {
from = 50000;
to = 50099;
};
range = r: "${toString r.from}-${toString r.to}";
needsData.unitConfig.ConditionPathExists = "/opt/.restored";
in
{
virtualisation.oci-containers.containers = {
xmpp-ejabberd = {
image = "dev.another.im/another-im/anotherim-server:${ejabberdVersion}";
environment = {
EJABBERD_MACRO_XMPP_DOMAIN = domain;
EJABBERD_MACRO_TURN_IP = publicIp;
EJABBERD_MACRO_POW_DIFFICULTY = "6";
EJABBERD_MACRO_REGISTRATION_TIMEOUT = "600";
EJABBERD_MACRO_UPLOAD_URL = "https://hel1.your-objectstorage.com/asxpio-xmpp-public/";
EJABBERD_MACRO_ACME_URL = "https://acme-v02.api.letsencrypt.org/directory";
};
environmentFiles = [ "${state}/secrets.env" ];
ports =
map (p: "${toString p}:${toString p}") tcpPorts
++ [
"3478:3478/udp"
"${range turnRelay}:${range turnRelay}/udp"
"${range gatewayCalls}:${range gatewayCalls}/udp"
];
volumes = [
"xmpp_database:/opt/ejabberd/database"
"xmpp_logs:/opt/ejabberd/logs"
"${state}/certs:/opt/ejabberd/conf/certs:ro"
];
};
# Push App Server and Telegram gateway share ejabberd's network
# namespace: they reach its component port on 127.0.0.1 only. They
# restart with it, since the namespace is gone when it stops.
xmpp-apushd = {
image = "dev.another.im/another-im/apushd:${apushdVersion}";
dependsOn = [ "xmpp-ejabberd" ];
networks = [ "container:xmpp-ejabberd" ];
volumes = [ "${state}/apushd:/etc/apushd:ro" ];
};
xmpp-tackygram = {
image = "dev.another.im/another-im/tackygram:${tackygramVersion}";
dependsOn = [ "xmpp-ejabberd" ];
networks = [ "container:xmpp-ejabberd" ];
environmentFiles = [ "${state}/tackygram/env" ];
environment = {
CREDENTIALS_DIRECTORY = "/run/credentials";
TACKYGRAM_CALL_IP = publicIp;
TACKYGRAM_CALL_PORTS = range gatewayCalls;
};
volumes = [
"xmpp_tackygram:/var/lib/tackygram"
"${state}/tackygram/key:/run/credentials/gateway-key:ro"
];
};
};
systemd.services = {
docker-xmpp-ejabberd = needsData;
docker-xmpp-apushd = needsData;
docker-xmpp-tackygram = needsData;
};
# For IPv6; see containers.nix
networking.firewall = {
allowedTCPPorts = tcpPorts;
allowedUDPPorts = [ 3478 ];
allowedUDPPortRanges = [
turnRelay
gatewayCalls
];
};
systemd.tmpfiles.rules = [ "d ${state} 0700 root root -" ];
# ejabberd's Mnesia through its own backup command, its SQLite through
# the online backup API
asxpio.backup = {
paths = [
"/var/lib/docker/volumes/xmpp_database"
"/var/lib/docker/volumes/xmpp_tackygram"
];
hookPackages = [
config.virtualisation.docker.package
pkgs.sqlite
];
preHooks = [
"docker exec xmpp-ejabberd ejabberdctl backup /opt/ejabberd/database/nightly.backup"
''sqlite3 /var/lib/docker/volumes/xmpp_database/_data/ejabberd.sqlite ".backup '$STAGE/ejabberd.sqlite'"''
];
};
}

View file

@ -0,0 +1,32 @@
# Xray VLESS Reality proxy on :443, host network. Root in the container
# only to read the 0400 config and bind :443; every other capability is
# dropped.
{ config, ... }:
let
# renovate: datasource=docker depName=ghcr.io/xtls/xray-core
xrayVersion = "26.11.9";
in
{
# The whole config.json; a new one restarts the container
sops.secrets."xray/config".restartUnits = [ "docker-xray.service" ];
virtualisation.oci-containers.containers.xray = {
image = "ghcr.io/xtls/xray-core:${xrayVersion}";
cmd = [
"run"
"-c"
"/etc/xray/config.json"
];
user = "0";
volumes = [ "${config.sops.secrets."xray/config".path}:/etc/xray/config.json:ro" ];
extraOptions = [
"--network=host"
"--cap-drop=ALL"
"--cap-add=NET_BIND_SERVICE"
"--security-opt=no-new-privileges:true"
"--read-only"
];
};
networking.firewall.allowedTCPPorts = [ 443 ];
}

View file

@ -1,6 +1,9 @@
# Overrides that turn asxpio-services into a throwaway test machine:
# plain DHCP, SSH from anywhere, no WireGuard, dummy secrets.
{ lib, ... }:
let
manual.autoStart = lib.mkForce false;
in
{
networking.hostName = "rehearsal";
sops.defaultSopsFile = ../secrets/rehearsal.yaml;
@ -13,11 +16,26 @@
};
networking.wireguard.interfaces = lib.mkForce { };
systemd.services.podman-sillytavern = {
systemd.services.docker-sillytavern = {
after = lib.mkForce [ ];
requires = lib.mkForce [ ];
};
virtualisation.oci-containers.containers.sillytavern.ports = lib.mkForce [ "127.0.0.1:8000:8000" ];
virtualisation.oci-containers.containers = {
sillytavern.ports = lib.mkForce [ "127.0.0.1:8000:8000" ];
# With production data restored these would act as production: a
# second asxp.io XMPP server and Telegram session, a runner taking
# real CI jobs. Start them by hand, and only with test data.
xmpp-ejabberd = manual;
xmpp-apushd = manual;
xmpp-tackygram = manual;
forgejo-runner = manual;
xray = manual; # its config is a dummy
};
# No backups of, or from, a test machine
systemd.timers.restic-backup.enable = false;
systemd.timers.s3-replicate.enable = false;
services.openssh.openFirewall = lib.mkForce true;
services.openssh.settings.AllowUsers = [ "root" ];