feat: get to a solid bootstrap on public ssh

This commit is contained in:
Jet 2026-03-21 16:05:47 -07:00
parent 642869ce9b
commit 3850948f71
No known key found for this signature in database
27 changed files with 262 additions and 865 deletions

View file

@ -38,13 +38,6 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
- name: Connect to Tailscale
uses: tailscale/github-action@v2
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:ci
- name: Configure SSH
run: |
mkdir -p ~/.ssh

View file

@ -1,10 +1,10 @@
# Noisebridge Wiki Infra
This repo manages the Noisebridge MediaWiki primary and replica on NixOS.
This repo manages the Noisebridge wiki hosts on NixOS.
## Commands
Bootstrap a brand new VPS into NixOS and seed its stable agenix host key:
Bootstrap a brand new Ubuntu 22.04 DigitalOcean VPS into NixOS:
```sh
nix run .#bootstrap-host -- <main-wiki|replica-wiki> <target-host> [ssh-identity-file]
@ -20,12 +20,11 @@ nix run .#bootstrap-host -- root@203.0.113.10 root@203.0.113.11 ~/.ssh/do-bootst
What bootstrap does:
- generates or reuses `.bootstrap/<host>/host.age`
- writes the matching public recipient to `secrets/hosts/<host>.age.pub`
- rekeys the agenix secrets with `agenix -r`
- runs `nixos-anywhere` against one or both raw VPS targets
- installs `/var/lib/agenix/host.age` onto the new machine
- lets the machine decrypt its Tailscale auth secret and come up on Tailscale with its configured hostname
- copies a first-boot module to the host
- runs `nixos-infect` on the Ubuntu VPS
- converts the machine to NixOS with the `jet` admin user
- disables direct root SSH
- fixes the known bad IPv6 routes generated by `nixos-infect`
Deploy all already-bootstrapped hosts:
@ -58,7 +57,7 @@ nix flake check 'path:.' --accept-flake-config
1. Create a raw VPS.
2. Run `nix run .#bootstrap-host -- ...` from the repo root on an admin laptop.
3. The machine installs NixOS, gets its host agenix key, and joins Tailscale.
3. The machine installs NixOS and comes up over hardened public SSH as `jet`.
4. Future changes use `nix run .#deploy`.
## GitHub Settings

View file

@ -1,34 +0,0 @@
{
disko.devices = {
disk.main = {
type = "disk";
device = "/dev/vda";
content = {
type = "gpt";
partitions = {
bios = {
size = "1M";
type = "EF02";
};
esp = {
size = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
};
}

175
flake.lock generated
View file

@ -67,48 +67,6 @@
"type": "github"
}
},
"disko": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1773889306,
"narHash": "sha256-PAqwnsBSI9SVC2QugvQ3xeYCB0otOwCacB1ueQj2tgw=",
"owner": "nix-community",
"repo": "disko",
"rev": "5ad85c82cc52264f4beddc934ba57f3789f28347",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "disko",
"type": "github"
}
},
"disko_2": {
"inputs": {
"nixpkgs": [
"nixos-anywhere",
"nixpkgs"
]
},
"locked": {
"lastModified": 1769524058,
"narHash": "sha256-zygdD6X1PcVNR2PsyK4ptzrVEiAdbMqLos7utrMDEWE=",
"owner": "nix-community",
"repo": "disko",
"rev": "71a3fc97d80881e91710fe721f1158d3b96ae14d",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "master",
"repo": "disko",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
@ -125,27 +83,6 @@
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"nixos-anywhere",
"nixpkgs"
]
},
"locked": {
"lastModified": 1768135262,
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"home-manager": {
"inputs": {
"nixpkgs": [
@ -167,95 +104,6 @@
"type": "github"
}
},
"nix-vm-test": {
"inputs": {
"nixpkgs": [
"nixos-anywhere",
"nixpkgs"
]
},
"locked": {
"lastModified": 1769079217,
"narHash": "sha256-R6qzhu+YJolxE2vUsPQWWwUKMbAG5nXX3pBtg8BNX38=",
"owner": "Enzime",
"repo": "nix-vm-test",
"rev": "58c15f78947b431d6c206e0966500c7e9139bd2f",
"type": "github"
},
"original": {
"owner": "Enzime",
"ref": "pr-105-latest",
"repo": "nix-vm-test",
"type": "github"
}
},
"nixos-anywhere": {
"inputs": {
"disko": "disko_2",
"flake-parts": "flake-parts",
"nix-vm-test": "nix-vm-test",
"nixos-images": "nixos-images",
"nixos-stable": "nixos-stable",
"nixpkgs": [
"nixpkgs"
],
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1769956140,
"narHash": "sha256-D+RQ+DaIC/GVwv5lUs7e8jSmh8aPc77Kg/gRjaS25Zk=",
"owner": "nix-community",
"repo": "nixos-anywhere",
"rev": "92f82c5196a5f8588be4967e535c4cfd35e85902",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixos-anywhere",
"type": "github"
}
},
"nixos-images": {
"inputs": {
"nixos-stable": [
"nixos-anywhere",
"nixos-stable"
],
"nixos-unstable": [
"nixos-anywhere",
"nixpkgs"
]
},
"locked": {
"lastModified": 1766770015,
"narHash": "sha256-kUmVBU+uBUPl/v3biPiWrk680b8N9rRMhtY97wsxiJc=",
"owner": "nix-community",
"repo": "nixos-images",
"rev": "e4dba54ddb6b2ad9c6550e5baaed2fa27938a5d2",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixos-images",
"type": "github"
}
},
"nixos-stable": {
"locked": {
"lastModified": 1769598131,
"narHash": "sha256-e7VO/kGLgRMbWtpBqdWl0uFg8Y2XWFMdz0uUJvlML8o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "fa83fd837f3098e3e678e6cf017b2b36102c7211",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1773821835,
@ -276,8 +124,6 @@
"inputs": {
"agenix": "agenix",
"deploy-rs": "deploy-rs",
"disko": "disko",
"nixos-anywhere": "nixos-anywhere",
"nixpkgs": "nixpkgs"
}
},
@ -311,27 +157,6 @@
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"nixos-anywhere",
"nixpkgs"
]
},
"locked": {
"lastModified": 1769691507,
"narHash": "sha256-8aAYwyVzSSwIhP2glDhw/G0i5+wOrren3v6WmxkVonM=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "28b19c5844cc6e2257801d43f2772a4b4c050a1b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
},
"utils": {
"inputs": {
"systems": "systems_2"

211
flake.nix
View file

@ -16,14 +16,6 @@
url = "github:serokell/deploy-rs";
inputs.nixpkgs.follows = "nixpkgs";
};
disko = {
url = "github:nix-community/disko";
inputs.nixpkgs.follows = "nixpkgs";
};
nixos-anywhere = {
url = "github:nix-community/nixos-anywhere";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
@ -32,8 +24,6 @@
nixpkgs,
agenix,
deploy-rs,
disko,
nixos-anywhere,
...
}:
let
@ -45,7 +35,14 @@
wikiName = "Noisebridge";
baseDomain = "noisebridge.net";
replicaSubdomain = "replica";
sshUser = "root";
sshUser = "jet";
adminUsers = {
jet = {
openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE40ISu3ydCqfdpb26JYD5cIN0Fu0id/FDS+xjB5zpqu jetthomaspham@gmail.com"
];
};
};
primaryHostName = "main-wiki";
replicaHostName = "replica-wiki";
@ -58,11 +55,11 @@
hosts = {
primary = {
nixosName = primaryHostName;
tailscaleName = primaryHostName;
publicIpv4 = "134.199.221.52";
};
replica = {
nixosName = replicaHostName;
tailscaleName = replicaHostName;
publicIpv4 = "167.99.174.109";
};
};
};
@ -82,12 +79,8 @@
publicDomain = mkPublicDomain role;
};
mkHost =
{
hostMeta,
hostModule,
roleModules,
}:
mkDeployHost =
hostModule: hostMeta:
lib.nixosSystem {
inherit system;
specialArgs = {
@ -95,16 +88,11 @@
};
modules = [
agenix.nixosModules.default
disko.nixosModules.disko
hostModule
./disk-config.nix
./modules/common.nix
./modules/security.nix
./modules/tailscale.nix
./modules/caddy.nix
./modules/mediawiki-base.nix
]
++ roleModules;
./modules/admin-users.nix
./modules/deploy-ssh.nix
];
};
primaryMeta = mkHostMeta "primary";
@ -112,40 +100,28 @@
in
{
nixosConfigurations = {
main-wiki = mkHost {
hostMeta = primaryMeta;
hostModule = ./hosts/main-wiki;
roleModules = [
./modules/wiki-primary/mysql.nix
./modules/wiki-primary/mediawiki.nix
];
};
main-wiki = mkDeployHost ./hosts/main-wiki.nix primaryMeta;
replica-wiki = mkHost {
hostMeta = replicaMeta;
hostModule = ./hosts/replica-wiki;
roleModules = [
./modules/wiki-replica/mysql.nix
./modules/wiki-replica/mediawiki.nix
];
};
replica-wiki = mkDeployHost ./hosts/replica-wiki.nix replicaMeta;
};
deploy.nodes = {
main-wiki = {
hostname = primaryMeta.tailscaleName;
hostname = primaryMeta.publicIpv4;
remoteBuild = true;
sshUser = siteConfig.sshUser;
profiles.system = {
user = siteConfig.sshUser;
sshUser = siteConfig.sshUser;
user = "root";
path = deploy-rs.lib.${system}.activate.nixos self.nixosConfigurations.main-wiki;
};
};
replica-wiki = {
hostname = replicaMeta.tailscaleName;
hostname = replicaMeta.publicIpv4;
remoteBuild = true;
sshUser = siteConfig.sshUser;
profiles.system = {
user = siteConfig.sshUser;
sshUser = siteConfig.sshUser;
user = "root";
path = deploy-rs.lib.${system}.activate.nixos self.nixosConfigurations.replica-wiki;
};
};
@ -175,129 +151,19 @@
bootstrap-host = {
type = "app";
program = "${pkgs.writeShellScript "bootstrap-host" ''
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
nix run .#bootstrap-host -- <main-wiki|replica-wiki> <target-host> [ssh-identity-file]
nix run .#bootstrap-host -- <main-target-host> <replica-target-host> [ssh-identity-file]
Examples:
nix run .#bootstrap-host -- main-wiki root@203.0.113.10 ~/.ssh/do-bootstrap
nix run .#bootstrap-host -- root@203.0.113.10 root@203.0.113.11 ~/.ssh/do-bootstrap
EOF
}
if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then
usage
exit 1
fi
repo_root="$(pwd)"
if [ ! -f "$repo_root/flake.nix" ]; then
printf 'Run bootstrap-host from the repo root\n' >&2
exit 1
fi
ssh_identity_file=""
main_target=""
replica_target=""
selected_host=""
case "$1" in
main-wiki|replica-wiki)
selected_host="$1"
if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then
usage
exit 1
fi
if [ "$selected_host" = "main-wiki" ]; then
main_target="$2"
else
replica_target="$2"
fi
ssh_identity_file="''${3:-}"
;;
*)
main_target="$1"
replica_target="$2"
ssh_identity_file="''${3:-}"
;;
esac
install_root="$(mktemp -d)"
cleanup() {
rm -rf "$install_root"
}
trap cleanup EXIT
ensure_host_key() {
local host_name="$1"
local private_key_file="$repo_root/.bootstrap/$host_name/host.age"
local public_key_file="$repo_root/secrets/hosts/$host_name.age.pub"
local public_key
mkdir -p "$(dirname "$private_key_file")" "$(dirname "$public_key_file")"
if [ ! -s "$private_key_file" ]; then
public_key="$(${pkgs.age}/bin/age-keygen -o "$private_key_file" | sed 's/^Public key: //')"
chmod 600 "$private_key_file"
printf 'Generated host age key for %s\n' "$host_name"
else
public_key="$(${pkgs.age}/bin/age-keygen -y "$private_key_file")"
printf 'Reused existing host age key for %s\n' "$host_name"
fi
printf '%s\n' "$public_key" > "$public_key_file"
}
run_bootstrap() {
local host_name="$1"
local target_host="$2"
local private_key_file="$repo_root/.bootstrap/$host_name/host.age"
local -a nixos_anywhere_args
mkdir -p "$install_root/var/lib/agenix"
${pkgs.coreutils}/bin/install -m 0400 "$private_key_file" "$install_root/var/lib/agenix/host.age"
nixos_anywhere_args=(
--extra-files "$install_root"
--flake "path:$repo_root#$host_name"
)
if [ -n "$ssh_identity_file" ]; then
nixos_anywhere_args+=( -i "$ssh_identity_file" )
fi
printf 'Bootstrapping %s onto %s\n' "$host_name" "$target_host"
${
nixos-anywhere.packages.${system}.default
}/bin/nixos-anywhere "''${nixos_anywhere_args[@]}" "$target_host"
rm -f "$install_root/var/lib/agenix/host.age"
}
if [ -n "$main_target" ]; then
ensure_host_key main-wiki
fi
if [ -n "$replica_target" ]; then
ensure_host_key replica-wiki
fi
printf 'Rekeying agenix secrets\n'
${agenix.packages.${system}.default}/bin/agenix -r
if [ -n "$main_target" ]; then
run_bootstrap main-wiki "$main_target"
fi
if [ -n "$replica_target" ]; then
run_bootstrap replica-wiki "$replica_target"
fi
printf '\nBootstrap complete. The hosts should now join Tailscale using their configured hostnames.\n'
''}";
meta.description = "Install NixOS on one or both raw hosts and seed agenix identities";
program = "${pkgs.writeShellScript "bootstrap-host" (
builtins.replaceStrings
[ "@ADMIN_KEYS@" ]
[
(lib.concatMapStringsSep "\n" (key: " \"${key}\"") (
lib.flatten (
lib.mapAttrsToList (_: userCfg: userCfg.openssh.authorizedKeys.keys or [ ]) siteConfig.adminUsers
)
))
]
(builtins.readFile ./scripts/bootstrap-host.sh)
)}";
meta.description = "Convert one or both Ubuntu DigitalOcean hosts into a minimal NixOS bootstrap config with nixos-infect";
};
};
@ -305,7 +171,6 @@
packages = with pkgs; [
agenix.packages.${system}.default
deploy-rs.packages.${system}.default
nixos-anywhere.packages.${system}.default
mariadb.client
rsync
curl

View file

@ -1,7 +1,7 @@
{ hostMeta, siteConfig, ... }:
{
imports = [
../shared/hardware-configuration.nix
./hardware-configuration.nix
];
networking.hostName = hostMeta.nixosName;

View file

@ -4,8 +4,11 @@
(modulesPath + "/virtualisation/digital-ocean-config.nix")
];
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
boot.loader.grub = {
enable = true;
efiSupport = false;
devices = lib.mkForce [ "/dev/vda" ];
};
fileSystems."/" = {
device = lib.mkDefault "/dev/disk/by-label/nixos";

6
hosts/main-wiki.nix Normal file
View file

@ -0,0 +1,6 @@
{ ... }:
{
imports = [
./common.nix
];
}

6
hosts/replica-wiki.nix Normal file
View file

@ -0,0 +1,6 @@
{ ... }:
{
imports = [
./common.nix
];
}

View file

@ -1,11 +0,0 @@
{ hostMeta, siteConfig, ... }:
{
imports = [
../shared/hardware-configuration.nix
];
networking.hostName = hostMeta.nixosName;
networking.domain = siteConfig.baseDomain;
system.stateVersion = "24.11";
}

13
modules/admin-users.nix Normal file
View file

@ -0,0 +1,13 @@
{ lib, siteConfig, ... }:
{
users.users = lib.mapAttrs (
_: userCfg:
{
isNormalUser = true;
extraGroups = [ "wheel" ];
}
// userCfg
) siteConfig.adminUsers;
security.sudo.wheelNeedsPassword = false;
}

View file

@ -1,31 +0,0 @@
{ config, hostMeta, ... }:
{
services.caddy = {
enable = true;
virtualHosts.${hostMeta.publicDomain}.extraConfig = ''
encode zstd gzip
handle /health {
respond "ok" 200
}
${
if hostMeta.role == "replica" then
''
header {
X-Wiki-Mode "read-only"
}
''
else
""
}php_fastcgi unix//run/phpfpm/mediawiki.sock {
root ${config.services.mediawiki.finalPackage}/share/mediawiki
}
file_server {
root ${config.services.mediawiki.finalPackage}/share/mediawiki
}
'';
};
}

View file

@ -25,6 +25,8 @@
time.timeZone = "America/Los_Angeles";
i18n.defaultLocale = "en_US.UTF-8";
services.journald.storage = "persistent";
services.timesyncd.enable = true;
systemd.tmpfiles.rules = [

29
modules/deploy-ssh.nix Normal file
View file

@ -0,0 +1,29 @@
{ ... }:
{
networking.firewall = {
enable = true;
allowedTCPPorts = [ 22 ];
};
services.openssh = {
enable = true;
openFirewall = true;
settings = {
AllowAgentForwarding = false;
AllowGroups = [ "wheel" ];
AllowTcpForwarding = false;
ClientAliveCountMax = 2;
ClientAliveInterval = 300;
KbdInteractiveAuthentication = false;
LoginGraceTime = 20;
MaxAuthTries = 3;
MaxSessions = 4;
PasswordAuthentication = false;
PermitRootLogin = "no";
PermitTunnel = false;
PermitUserEnvironment = false;
StreamLocalBindUnlink = false;
X11Forwarding = false;
};
};
}

View file

@ -1,109 +0,0 @@
{
config,
hostMeta,
siteConfig,
...
}:
{
services.mediawiki = {
enable = true;
name = siteConfig.wikiName;
url = "https://${hostMeta.publicDomain}";
webserver = "none";
passwordFile = config.age.secrets.mediawiki-admin-password.path;
database = {
type = "mysql";
name = siteConfig.database.name;
user = siteConfig.database.mediawikiUser;
passwordFile = config.age.secrets.mysql-mediawiki.path;
socket = "/run/mysqld/mysqld.sock";
createLocally = false;
};
extensions = {
ParserFunctions = null;
WikiEditor = null;
};
extraConfig = ''
$wgServer = "https://${hostMeta.publicDomain}";
$wgSitename = "${siteConfig.wikiName}";
$wgMetaNamespace = "${siteConfig.wikiName}";
$wgScriptPath = "";
$wgArticlePath = "/wiki/$1";
$wgUsePathInfo = true;
wfLoadSkin('Vector');
$wgEnableUploads = true;
$wgEnableEmail = false;
$wgShowIPinHeader = false;
$wgGroupPermissions['*']['edit'] = false;
$wgGroupPermissions['*']['createpage'] = false;
$wgGroupPermissions['*']['createtalk'] = false;
$wgGroupPermissions['*']['writeapi'] = false;
$wgGroupPermissions['*']['createaccount'] = false;
$wgGroupPermissions['user']['edit'] = true;
$wgGroupPermissions['user']['createpage'] = true;
$wgGroupPermissions['user']['createtalk'] = true;
$wgGroupPermissions['user']['upload'] = true;
$wgGroupPermissions['user']['writeapi'] = true;
'';
};
services.mediawiki.poolConfig =
if hostMeta.role == "primary" then
{
"pm" = "dynamic";
"pm.max_children" = 12;
"pm.start_servers" = 3;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 4;
"pm.max_requests" = 500;
}
else
{
"pm" = "dynamic";
"pm.max_children" = 6;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 1;
"pm.max_spare_servers" = 3;
"pm.max_requests" = 500;
};
services.phpfpm.pools.mediawiki.phpOptions =
if hostMeta.role == "primary" then
''
opcache.enable=1
opcache.memory_consumption=192
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
realpath_cache_size=4096K
realpath_cache_ttl=600
''
else
''
opcache.enable=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
realpath_cache_size=2048K
realpath_cache_ttl=600
'';
age.secrets.mediawiki-admin-password = {
file = ../secrets/mediawiki-admin-password.age;
owner = "mediawiki";
group = "mediawiki";
mode = "0400";
};
age.secrets.mysql-mediawiki = {
file = ../secrets/mysql-mediawiki.age;
owner = "mediawiki";
group = "mediawiki";
mode = "0400";
};
}

View file

@ -1,18 +0,0 @@
{ ... }:
{
networking.firewall = {
enable = true;
allowedTCPPorts = [ 80 443 ];
};
services.openssh = {
enable = true;
openFirewall = false;
settings = {
PasswordAuthentication = false;
KbdInteractiveAuthentication = false;
PermitRootLogin = "prohibit-password";
X11Forwarding = false;
};
};
}

View file

@ -1,32 +0,0 @@
{
config,
hostMeta,
...
}:
{
age.secrets.tailscale-auth = {
file = ../secrets/tailscale-auth.age;
owner = "root";
mode = "0400";
};
services.tailscale = {
enable = true;
authKeyFile = config.age.secrets.tailscale-auth.path;
extraUpFlags = [ "--hostname=${hostMeta.tailscaleName}" ];
};
networking.firewall.interfaces.tailscale0.allowedTCPPorts =
if hostMeta.role == "primary" then
[
22
3306
]
else
[
22
873
];
networking.firewall.allowedUDPPorts = [ config.services.tailscale.port ];
networking.firewall.checkReversePath = "loose";
}

View file

@ -1,29 +0,0 @@
{ pkgs, siteConfig, ... }:
{
systemd.services.mediawiki-image-sync = {
description = "Sync MediaWiki uploads to the replica";
after = [ "tailscale-autoconnect.service" ];
wants = [ "tailscale-autoconnect.service" ];
path = [ pkgs.rsync ];
serviceConfig.Type = "oneshot";
script = ''
set -euo pipefail
${pkgs.rsync}/bin/rsync \
-az \
--delete \
/var/lib/mediawiki/images/ \
"rsync://${siteConfig.hosts.replica.tailscaleName}/mediawiki-images/"
'';
};
systemd.timers.mediawiki-image-sync = {
description = "Periodic upload sync from primary to replica";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "hourly";
Persistent = true;
RandomizedDelaySec = "10m";
};
};
}

View file

@ -1,123 +0,0 @@
{
config,
pkgs,
siteConfig,
...
}:
let
dbName = siteConfig.database.name;
wikiUser = siteConfig.database.mediawikiUser;
replicationUser = siteConfig.database.replicationUser;
mysqlCli = "${pkgs.mariadb}/bin/mysql -u root";
bootstrapSql = pkgs.writeText "mysql-bootstrap.sql" ''
SET @wiki_pass = FROM_BASE64('$wiki_pass_b64');
SET @repl_pass = FROM_BASE64('$repl_pass_b64');
CREATE DATABASE IF NOT EXISTS \`${dbName}\`;
SET @create_wiki_user = CONCAT(
"CREATE USER IF NOT EXISTS '${wikiUser}'@'localhost' IDENTIFIED BY ",
QUOTE(@wiki_pass)
);
PREPARE stmt FROM @create_wiki_user;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @alter_wiki_user = CONCAT(
"ALTER USER '${wikiUser}'@'localhost' IDENTIFIED BY ",
QUOTE(@wiki_pass)
);
PREPARE stmt FROM @alter_wiki_user;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
GRANT ALL PRIVILEGES ON \`${dbName}\`.* TO '${wikiUser}'@'localhost';
SET @create_repl_user = CONCAT(
"CREATE USER IF NOT EXISTS '${replicationUser}'@'%' IDENTIFIED BY ",
QUOTE(@repl_pass)
);
PREPARE stmt FROM @create_repl_user;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @alter_repl_user = CONCAT(
"ALTER USER '${replicationUser}'@'%' IDENTIFIED BY ",
QUOTE(@repl_pass)
);
PREPARE stmt FROM @alter_repl_user;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO '${replicationUser}'@'%';
FLUSH PRIVILEGES;
'';
in
{
services.mysql = {
enable = true;
package = pkgs.mariadb;
settings.mysqld = {
bind-address = "0.0.0.0";
server-id = 1;
log_bin = "mysql-bin";
log_slave_updates = 1;
binlog_format = "ROW";
gtid_strict_mode = 1;
innodb_file_per_table = 1;
innodb_buffer_pool_size = "4G";
innodb_log_file_size = "512M";
innodb_flush_method = "O_DIRECT";
innodb_flush_neighbors = 0;
innodb_io_capacity = 1000;
innodb_io_capacity_max = 2000;
max_connections = 80;
thread_cache_size = 100;
table_open_cache = 4000;
tmp_table_size = "64M";
max_heap_table_size = "64M";
performance_schema = true;
slow_query_log = 1;
long_query_time = 1;
skip_name_resolve = 1;
};
};
age.secrets.mysql-replication = {
file = ../../secrets/mysql-replication.age;
owner = "mysql";
group = "mysql";
mode = "0400";
};
systemd.services.mysql-bootstrap = {
description = "Create MediaWiki database and users";
after = [ "mysql.service" ];
requires = [ "mysql.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
set -euo pipefail
read_secret_b64() {
tr -d '\n' < "$1" | base64 -w0
}
wiki_pass_b64="$(read_secret_b64 ${config.age.secrets.mysql-mediawiki.path})"
repl_pass_b64="$(read_secret_b64 ${config.age.secrets.mysql-replication.path})"
${mysqlCli} <<SQL
$(< ${bootstrapSql})
SQL
'';
};
systemd.services.mediawiki-init = {
after = [ "mysql-bootstrap.service" ];
requires = [ "mysql-bootstrap.service" ];
};
}

View file

@ -1,9 +0,0 @@
{ lib, ... }:
{
services.mediawiki.extraConfig = lib.mkAfter ''
$wgReadOnly = "This wiki is a read-only replica.";
$wgEnableUploads = false;
'';
systemd.services.mediawiki-init.wantedBy = lib.mkForce [ ];
}

View file

@ -1,92 +0,0 @@
{
config,
pkgs,
siteConfig,
...
}:
let
replicationUser = siteConfig.database.replicationUser;
mysqlCli = "${pkgs.mariadb}/bin/mysql -u root";
initReplicaSql = pkgs.writeText "mysql-init-replica.sql" ''
STOP SLAVE;
SET @repl_pass = FROM_BASE64('$repl_pass_b64');
SET @change_master = CONCAT(
"CHANGE MASTER TO ",
"MASTER_HOST='${siteConfig.hosts.primary.tailscaleName}', ",
"MASTER_USER='${replicationUser}', ",
"MASTER_PASSWORD=", QUOTE(@repl_pass), ", ",
"MASTER_USE_GTID=slave_pos"
);
PREPARE stmt FROM @change_master;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
START SLAVE;
SHOW SLAVE STATUS\G
'';
in
{
services.mysql = {
enable = true;
package = pkgs.mariadb;
settings.mysqld = {
bind-address = "127.0.0.1";
server-id = 2;
relay_log = "relay-bin";
log_slave_updates = 1;
read_only = 1;
super_read_only = 1;
gtid_strict_mode = 1;
innodb_file_per_table = 1;
innodb_buffer_pool_size = "2G";
innodb_log_file_size = "256M";
innodb_flush_method = "O_DIRECT";
innodb_flush_neighbors = 0;
innodb_io_capacity = 500;
innodb_io_capacity_max = 1000;
max_connections = 40;
thread_cache_size = 50;
table_open_cache = 2000;
tmp_table_size = "32M";
max_heap_table_size = "32M";
performance_schema = true;
slow_query_log = 1;
long_query_time = 1;
skip_name_resolve = 1;
};
};
age.secrets.mysql-replication = {
file = ../../secrets/mysql-replication.age;
owner = "mysql";
group = "mysql";
mode = "0400";
};
services.rsyncd = {
enable = true;
settings = {
globalSection = {
"use chroot" = false;
};
sections.mediawiki-images = {
path = "/var/lib/mediawiki/images";
comment = "MediaWiki upload mirror target";
"read only" = false;
list = false;
};
};
};
environment.systemPackages = [
(pkgs.writeShellScriptBin "init-replica" ''
set -euo pipefail
repl_pass_b64="$(tr -d '\n' < ${config.age.secrets.mysql-replication.path} | base64 -w0)"
${mysqlCli} <<SQL
$(< ${initReplicaSql})
SQL
'')
];
}

144
scripts/bootstrap-host.sh Normal file
View file

@ -0,0 +1,144 @@
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
nix run .#bootstrap-host -- <main-wiki|replica-wiki> <target-host> [ssh-identity-file]
nix run .#bootstrap-host -- <main-target-host> <replica-target-host> [ssh-identity-file]
Examples:
nix run .#bootstrap-host -- main-wiki root@203.0.113.10 ~/.ssh/do-bootstrap
nix run .#bootstrap-host -- root@203.0.113.10 root@203.0.113.11 ~/.ssh/do-bootstrap
USAGE
}
if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then
usage
exit 1
fi
repo_root="$(pwd)"
if [ ! -f "$repo_root/flake.nix" ]; then
printf 'Run bootstrap-host from the repo root\n' >&2
exit 1
fi
ssh_identity_file=""
main_target=""
replica_target=""
case "$1" in
main-wiki|replica-wiki)
if [ "$1" = "main-wiki" ]; then
main_target="$2"
else
replica_target="$2"
fi
ssh_identity_file="${3:-}"
;;
*)
main_target="$1"
replica_target="$2"
ssh_identity_file="${3:-}"
;;
esac
make_host_module() {
local module_file="$1"
cat > "$module_file" <<'MODULE'
{ ... }:
{
services.journald.storage = "persistent";
services.openssh = {
enable = true;
openFirewall = true;
settings = {
AllowAgentForwarding = false;
AllowGroups = [ "wheel" ];
AllowTcpForwarding = false;
ClientAliveCountMax = 2;
ClientAliveInterval = 300;
PasswordAuthentication = false;
KbdInteractiveAuthentication = false;
LoginGraceTime = 20;
MaxAuthTries = 3;
MaxSessions = 4;
PermitRootLogin = "no";
PermitTunnel = false;
PermitUserEnvironment = false;
StreamLocalBindUnlink = false;
X11Forwarding = false;
};
};
users.users.jet = {
isNormalUser = true;
extraGroups = [ "wheel" ];
openssh.authorizedKeys.keys = [
@ADMIN_KEYS@
];
};
security.sudo.wheelNeedsPassword = false;
services.do-agent.enable = false;
}
MODULE
}
run_bootstrap() {
local host_name="$1"
local target_host="$2"
local work_dir
local module_file
local remote_target
local try
local ssh_cmd=(ssh -o StrictHostKeyChecking=accept-new)
local scp_cmd=(scp -o StrictHostKeyChecking=accept-new)
work_dir="$(mktemp -d)"
module_file="$work_dir/host-bootstrap.nix"
remote_target="$target_host:/etc/nixos/host-bootstrap.nix"
make_host_module "$module_file"
if [ -n "$ssh_identity_file" ]; then
ssh_cmd+=( -i "$ssh_identity_file" )
scp_cmd+=( -i "$ssh_identity_file" )
fi
"${ssh_cmd[@]}" "$target_host" 'mkdir -p /etc/nixos'
"${scp_cmd[@]}" "$module_file" "$remote_target"
printf 'Infecting %s onto %s\n' "$host_name" "$target_host"
"${ssh_cmd[@]}" "$target_host" \
'umount /boot/efi 2>/dev/null || true; curl -fsSL https://raw.githubusercontent.com/elitak/nixos-infect/36f48d8feb89ca508261d7390355144fc0048932/nixos-infect | env PROVIDER=digitalocean doNetConf=y NIX_CHANNEL=nixos-24.05 NIXOS_IMPORT=./host-bootstrap.nix bash -x' || true
printf 'Waiting for %s to reboot into NixOS\n' "$host_name"
for try in $(seq 1 60); do
if "${ssh_cmd[@]}" -o ConnectTimeout=5 "$target_host" 'grep -q "^ID=nixos" /etc/os-release'; then
break
fi
sleep 5
done
printf 'Finalizing network config on %s\n' "$host_name"
"${ssh_cmd[@]}" "$target_host" '
sed -i "/defaultGateway6 = {/,/};/d" /etc/nixos/networking.nix 2>/dev/null || true
sed -i "/ipv6.routes = \[ { address = \"\"; prefixLength = 128; } \];/d" /etc/nixos/networking.nix 2>/dev/null || true
nixos-rebuild switch
'
rm -rf "$work_dir"
}
if [ -n "${main_target:-}" ]; then
run_bootstrap main-wiki "$main_target"
fi
if [ -n "${replica_target:-}" ]; then
run_bootstrap replica-wiki "$replica_target"
fi
printf '\nBootstrap complete. The hosts should now be reachable as minimal NixOS systems over public SSH.\n'

Binary file not shown.

View file

@ -1,5 +1,11 @@
age-encryption.org/v1
-> ssh-ed25519 Ziw7aw dohDDxsPMp1TRbvNh2qAPUFmW4cuMLnjislpRleHEHI
9MEq670uN4CzO8U/2HZAXr8MpI/vte/5pC2yQPKWemg
--- 2npxqFr1cEdGtDhZlb4zVpy04F4Xsfb2NAu3eTDUTYg
»æØ¹LT<EFBFBD> LÜt2là!q>Àý0"ËH5ò^Ìè¼—Ó ^˜ƒj5ÁQRwš×^à?MVon«iĵ,dD昞S-q¢€
-> ssh-ed25519 Ziw7aw 5sI+R2UT4nP6T6WTm1EJ4elU8KXBlI7jzgTxVOXi/Sg
pQcdU7F0xoTiZQMyA+8L8bAUgg+7ftk1cQPCP5UOWvc
-> X25519 PRy6mfo+WzqtakJny8K9A0M8ilnEmkpREpa0wuMGNVI
Szmhsy/l6Qs5pX6TwcjPg3gxgQtNT1y0KTXEMPG/wZg
-> X25519 ja3QNUKZfQIkTCt8O41f0+3oh1dXk/v86ZKHUcvvKxI
6Qb1KMzfbyZMT2OJsckGRiWrcxS3xgCL7jXz0QWucZw
--- 7d03+3jqRYD4cII07DMedPieU+qIyl+b3KL+xcEUwro
þ¿Â휫5Dh?G^|ƤJd`üg4ïU¥ ÒÀ'¯ä« :«t
LNÍIIÂfå˜3²rFÚ Rå9€”¥êƒÄ
vŒD6•7ã

Binary file not shown.

View file

@ -14,7 +14,6 @@ let
];
in
{
"tailscale-auth.age".publicKeys = adminKeys ++ allHosts;
"mysql-mediawiki.age".publicKeys = adminKeys ++ allHosts;
"mysql-replication.age".publicKeys = adminKeys ++ allHosts;
"mediawiki-admin-password.age".publicKeys = adminKeys ++ allHosts;

View file

@ -1,5 +0,0 @@
age-encryption.org/v1
-> ssh-ed25519 Ziw7aw Fa0iIVw0anW/5eEZeeHjPQub7dSfowddlBHGnv/zZ2E
cXCR7bmRgwlrRGvJxAXmeceG8IZeEikBYpcK23WPn/c
--- tE9ZGzyHyQpZoBGr8m3YySYeSbpNlISsxKG7fIgqdwg
<EFBFBD>?Õ\ƒ\Ótîr-¼Dÿã{)ßÍ>þJ¼—r Xz‡àñŸZ]¤“Eîáÿ¶šßeë1±œÝWúý÷§c[ÔQ†Æ³2²¢€Å<E282AC>š÷cfaf9Ž5äÚ