From 642869ce9b8e859c1a8144ad2a81f94953f9328c Mon Sep 17 00:00:00 2001 From: Jet Date: Fri, 20 Mar 2026 21:31:50 -0700 Subject: [PATCH] init --- .envrc | 1 + .github/workflows/ci.yml | 57 ++++ .gitignore | 2 + README.md | 81 ++++++ disk-config.nix | 34 +++ flake.lock | 356 ++++++++++++++++++++++++ flake.nix | 318 +++++++++++++++++++++ hosts/main-wiki/default.nix | 11 + hosts/replica-wiki/default.nix | 11 + hosts/shared/hardware-configuration.nix | 18 ++ modules/caddy.nix | 31 +++ modules/common.nix | 43 +++ modules/mediawiki-base.nix | 109 ++++++++ modules/security.nix | 18 ++ modules/tailscale.nix | 32 +++ modules/wiki-primary/mediawiki.nix | 29 ++ modules/wiki-primary/mysql.nix | 123 ++++++++ modules/wiki-replica/mediawiki.nix | 9 + modules/wiki-replica/mysql.nix | 92 ++++++ secrets.nix | 1 + secrets/hosts/main-wiki.age.pub | 1 + secrets/hosts/replica-wiki.age.pub | 1 + secrets/mediawiki-admin-password.age | 5 + secrets/mysql-mediawiki.age | 5 + secrets/mysql-replication.age | Bin 0 -> 257 bytes secrets/secrets.nix | 21 ++ secrets/tailscale-auth.age | 5 + 27 files changed, 1414 insertions(+) create mode 100644 .envrc create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 disk-config.nix create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 hosts/main-wiki/default.nix create mode 100644 hosts/replica-wiki/default.nix create mode 100644 hosts/shared/hardware-configuration.nix create mode 100644 modules/caddy.nix create mode 100644 modules/common.nix create mode 100644 modules/mediawiki-base.nix create mode 100644 modules/security.nix create mode 100644 modules/tailscale.nix create mode 100644 modules/wiki-primary/mediawiki.nix create mode 100644 modules/wiki-primary/mysql.nix create mode 100644 modules/wiki-replica/mediawiki.nix create mode 100644 modules/wiki-replica/mysql.nix create mode 100644 secrets.nix create mode 100644 secrets/hosts/main-wiki.age.pub create mode 100644 secrets/hosts/replica-wiki.age.pub create mode 100644 secrets/mediawiki-admin-password.age create mode 100644 secrets/mysql-mediawiki.age create mode 100644 secrets/mysql-replication.age create mode 100644 secrets/secrets.nix create mode 100644 secrets/tailscale-auth.age diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e207d2f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: DeterminateSystems/nix-installer-action@main + + - name: nix flake check + run: nix flake check --print-build-logs + + - name: Build primary host + run: nix build .#nixosConfigurations.main-wiki.config.system.build.toplevel --print-build-logs + + - name: Build replica host + run: nix build .#nixosConfigurations.replica-wiki.config.system.build.toplevel --print-build-logs + + deploy: + needs: check + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + runs-on: ubuntu-latest + concurrency: + group: deploy + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + + - 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 + printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -t ed25519 "$(nix eval --raw .#deploy.nodes.\"main-wiki\".hostname)" >> ~/.ssh/known_hosts 2>/dev/null + ssh-keyscan -t ed25519 "$(nix eval --raw .#deploy.nodes.\"replica-wiki\".hostname)" >> ~/.ssh/known_hosts 2>/dev/null + + - name: Deploy all hosts + run: nix run .#deploy diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85bc657 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.bootstrap/ +.direnv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..56039ce --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# Noisebridge Wiki Infra + +This repo manages the Noisebridge MediaWiki primary and replica on NixOS. + +## Commands + +Bootstrap a brand new VPS into NixOS and seed its stable agenix host key: + +```sh +nix run .#bootstrap-host -- [ssh-identity-file] +nix run .#bootstrap-host -- [ssh-identity-file] +``` + +Example: + +```sh +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 +``` + +What bootstrap does: + +- generates or reuses `.bootstrap//host.age` +- writes the matching public recipient to `secrets/hosts/.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 + +Deploy all already-bootstrapped hosts: + +```sh +nix run .#deploy +``` + +Deploy one host only: + +```sh +nix run .#deploy -- .#main-wiki +nix run .#deploy -- .#replica-wiki +``` + +Check the flake: + +```sh +nix flake check 'path:.' --accept-flake-config +``` + +## Secret Model + +- admin keys stay in `secrets/secrets.nix` +- host recipients live in `secrets/hosts/*.age.pub` +- host private age keys stay local in `.bootstrap/` and are gitignored +- hosts decrypt agenix secrets with `/var/lib/agenix/host.age` +- host SSH keys are separate and can rotate without breaking agenix + +## Normal Lifecycle + +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. +4. Future changes use `nix run .#deploy`. + +## GitHub Settings + +To require pull requests and auto-deploy only from `main`, set branch protection or a ruleset on `main` with: + +- require a pull request before merging +- do not allow direct pushes to `main` +- require status checks to pass before merging +- select the CI check job from this repo +- optionally require approvals before merging + +This repo already deploys on pushes to `main` in `.github/workflows/ci.yml`. + +That means the intended flow is: + +1. open a PR +2. CI passes +3. merge into `main` +4. GitHub Actions runs `nix run .#deploy` diff --git a/disk-config.nix b/disk-config.nix new file mode 100644 index 0000000..c6db013 --- /dev/null +++ b/disk-config.nix @@ -0,0 +1,34 @@ +{ + 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 = "/"; + }; + }; + }; + }; + }; + }; +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..0c708a0 --- /dev/null +++ b/flake.lock @@ -0,0 +1,356 @@ +{ + "nodes": { + "agenix": { + "inputs": { + "darwin": "darwin", + "home-manager": "home-manager", + "nixpkgs": [ + "nixpkgs" + ], + "systems": "systems" + }, + "locked": { + "lastModified": 1770165109, + "narHash": "sha256-9VnK6Oqai65puVJ4WYtCTvlJeXxMzAp/69HhQuTdl/I=", + "owner": "ryantm", + "repo": "agenix", + "rev": "b027ee29d959fda4b60b57566d64c98a202e0feb", + "type": "github" + }, + "original": { + "owner": "ryantm", + "repo": "agenix", + "type": "github" + } + }, + "darwin": { + "inputs": { + "nixpkgs": [ + "agenix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1744478979, + "narHash": "sha256-dyN+teG9G82G+m+PX/aSAagkC+vUv0SgUw3XkPhQodQ=", + "owner": "lnl7", + "repo": "nix-darwin", + "rev": "43975d782b418ebf4969e9ccba82466728c2851b", + "type": "github" + }, + "original": { + "owner": "lnl7", + "ref": "master", + "repo": "nix-darwin", + "type": "github" + } + }, + "deploy-rs": { + "inputs": { + "flake-compat": "flake-compat", + "nixpkgs": [ + "nixpkgs" + ], + "utils": "utils" + }, + "locked": { + "lastModified": 1770019181, + "narHash": "sha256-hwsYgDnby50JNVpTRYlF3UR/Rrpt01OrxVuryF40CFY=", + "owner": "serokell", + "repo": "deploy-rs", + "rev": "77c906c0ba56aabdbc72041bf9111b565cdd6171", + "type": "github" + }, + "original": { + "owner": "serokell", + "repo": "deploy-rs", + "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": { + "lastModified": 1733328505, + "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "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": [ + "agenix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1745494811, + "narHash": "sha256-YZCh2o9Ua1n9uCvrvi5pRxtuVNml8X2a03qIFfRKpFs=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "abfad3d2958c9e6300a883bd443512c55dfeb1be", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "home-manager", + "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, + "narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "agenix": "agenix", + "deploy-rs": "deploy-rs", + "disko": "disko", + "nixos-anywhere": "nixos-anywhere", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "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" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..72f0e57 --- /dev/null +++ b/flake.nix @@ -0,0 +1,318 @@ +{ + description = "Basic MediaWiki primary + replica deployment"; + + nixConfig = { + max-jobs = "auto"; + cores = 0; + }; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + agenix = { + url = "github:ryantm/agenix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + deploy-rs = { + 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 = + { + self, + nixpkgs, + agenix, + deploy-rs, + disko, + nixos-anywhere, + ... + }: + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + lib = nixpkgs.lib; + + siteConfig = rec { + wikiName = "Noisebridge"; + baseDomain = "noisebridge.net"; + replicaSubdomain = "replica"; + sshUser = "root"; + primaryHostName = "main-wiki"; + replicaHostName = "replica-wiki"; + + database = { + name = "noisebridge_mediawiki"; + mediawikiUser = "wiki"; + replicationUser = "repl"; + }; + + hosts = { + primary = { + nixosName = primaryHostName; + tailscaleName = primaryHostName; + }; + replica = { + nixosName = replicaHostName; + tailscaleName = replicaHostName; + }; + }; + }; + + mkPublicDomain = + role: + if role == "primary" then + siteConfig.baseDomain + else + "${siteConfig.replicaSubdomain}.${siteConfig.baseDomain}"; + + mkHostMeta = + role: + siteConfig.hosts.${role} + // { + inherit role; + publicDomain = mkPublicDomain role; + }; + + mkHost = + { + hostMeta, + hostModule, + roleModules, + }: + lib.nixosSystem { + inherit system; + specialArgs = { + inherit agenix siteConfig hostMeta; + }; + 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; + }; + + primaryMeta = mkHostMeta "primary"; + replicaMeta = mkHostMeta "replica"; + in + { + nixosConfigurations = { + main-wiki = mkHost { + hostMeta = primaryMeta; + hostModule = ./hosts/main-wiki; + roleModules = [ + ./modules/wiki-primary/mysql.nix + ./modules/wiki-primary/mediawiki.nix + ]; + }; + + replica-wiki = mkHost { + hostMeta = replicaMeta; + hostModule = ./hosts/replica-wiki; + roleModules = [ + ./modules/wiki-replica/mysql.nix + ./modules/wiki-replica/mediawiki.nix + ]; + }; + }; + + deploy.nodes = { + main-wiki = { + hostname = primaryMeta.tailscaleName; + profiles.system = { + user = siteConfig.sshUser; + sshUser = siteConfig.sshUser; + path = deploy-rs.lib.${system}.activate.nixos self.nixosConfigurations.main-wiki; + }; + }; + + replica-wiki = { + hostname = replicaMeta.tailscaleName; + profiles.system = { + user = siteConfig.sshUser; + sshUser = siteConfig.sshUser; + path = deploy-rs.lib.${system}.activate.nixos self.nixosConfigurations.replica-wiki; + }; + }; + }; + + checks = builtins.mapAttrs (_: deployLib: deployLib.deployChecks self.deploy) deploy-rs.lib; + + apps.${system} = { + deploy = { + type = "app"; + program = "${pkgs.writeShellScript "deploy-noisebridge" '' + if [ "$#" -eq 0 ] || [ "''${1#-}" != "$1" ]; then + exec ${deploy-rs.packages.${system}.default}/bin/deploy \ + --auto-rollback true \ + --magic-rollback true \ + path:.# \ + "$@" + fi + + exec ${deploy-rs.packages.${system}.default}/bin/deploy \ + --auto-rollback true \ + --magic-rollback true \ + "$@" + ''}"; + meta.description = "Deploy all Noisebridge wiki hosts by default"; + }; + + bootstrap-host = { + type = "app"; + program = "${pkgs.writeShellScript "bootstrap-host" '' + set -euo pipefail + + usage() { + cat <<'EOF' + Usage: + nix run .#bootstrap-host -- [ssh-identity-file] + nix run .#bootstrap-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"; + }; + }; + + devShells.${system}.default = pkgs.mkShell { + packages = with pkgs; [ + agenix.packages.${system}.default + deploy-rs.packages.${system}.default + nixos-anywhere.packages.${system}.default + mariadb.client + rsync + curl + jq + age + openssl + ]; + }; + }; +} diff --git a/hosts/main-wiki/default.nix b/hosts/main-wiki/default.nix new file mode 100644 index 0000000..0780e08 --- /dev/null +++ b/hosts/main-wiki/default.nix @@ -0,0 +1,11 @@ +{ hostMeta, siteConfig, ... }: +{ + imports = [ + ../shared/hardware-configuration.nix + ]; + + networking.hostName = hostMeta.nixosName; + networking.domain = siteConfig.baseDomain; + + system.stateVersion = "24.11"; +} diff --git a/hosts/replica-wiki/default.nix b/hosts/replica-wiki/default.nix new file mode 100644 index 0000000..0780e08 --- /dev/null +++ b/hosts/replica-wiki/default.nix @@ -0,0 +1,11 @@ +{ hostMeta, siteConfig, ... }: +{ + imports = [ + ../shared/hardware-configuration.nix + ]; + + networking.hostName = hostMeta.nixosName; + networking.domain = siteConfig.baseDomain; + + system.stateVersion = "24.11"; +} diff --git a/hosts/shared/hardware-configuration.nix b/hosts/shared/hardware-configuration.nix new file mode 100644 index 0000000..e3a8322 --- /dev/null +++ b/hosts/shared/hardware-configuration.nix @@ -0,0 +1,18 @@ +{ lib, modulesPath, ... }: +{ + imports = [ + (modulesPath + "/virtualisation/digital-ocean-config.nix") + ]; + + boot.loader.systemd-boot.enable = true; + boot.loader.efi.canTouchEfiVariables = true; + + fileSystems."/" = { + device = lib.mkDefault "/dev/disk/by-label/nixos"; + fsType = "ext4"; + }; + + swapDevices = [ ]; + + networking.useDHCP = lib.mkDefault true; +} diff --git a/modules/caddy.nix b/modules/caddy.nix new file mode 100644 index 0000000..92e3343 --- /dev/null +++ b/modules/caddy.nix @@ -0,0 +1,31 @@ +{ 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 + } + ''; + }; +} diff --git a/modules/common.nix b/modules/common.nix new file mode 100644 index 0000000..e28e43c --- /dev/null +++ b/modules/common.nix @@ -0,0 +1,43 @@ +{ pkgs, ... }: +{ + age.identityPaths = [ "/var/lib/agenix/host.age" ]; + + nix.settings = { + experimental-features = [ + "nix-command" + "flakes" + ]; + trusted-users = [ + "root" + "@wheel" + ]; + auto-optimise-store = true; + max-jobs = "auto"; + cores = 0; + }; + + nix.gc = { + automatic = true; + dates = "weekly"; + options = "--delete-older-than 14d"; + }; + + time.timeZone = "America/Los_Angeles"; + i18n.defaultLocale = "en_US.UTF-8"; + + services.timesyncd.enable = true; + + systemd.tmpfiles.rules = [ + "d /var/lib/agenix 0700 root root -" + "z /var/lib/agenix/host.age 0400 root root -" + ]; + + environment.systemPackages = with pkgs; [ + vim + git + curl + jq + rsync + mariadb.client + ]; +} diff --git a/modules/mediawiki-base.nix b/modules/mediawiki-base.nix new file mode 100644 index 0000000..272ca2d --- /dev/null +++ b/modules/mediawiki-base.nix @@ -0,0 +1,109 @@ +{ + 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"; + }; +} diff --git a/modules/security.nix b/modules/security.nix new file mode 100644 index 0000000..7aae683 --- /dev/null +++ b/modules/security.nix @@ -0,0 +1,18 @@ +{ ... }: +{ + networking.firewall = { + enable = true; + allowedTCPPorts = [ 80 443 ]; + }; + + services.openssh = { + enable = true; + openFirewall = false; + settings = { + PasswordAuthentication = false; + KbdInteractiveAuthentication = false; + PermitRootLogin = "prohibit-password"; + X11Forwarding = false; + }; + }; +} diff --git a/modules/tailscale.nix b/modules/tailscale.nix new file mode 100644 index 0000000..4056016 --- /dev/null +++ b/modules/tailscale.nix @@ -0,0 +1,32 @@ +{ + 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"; +} diff --git a/modules/wiki-primary/mediawiki.nix b/modules/wiki-primary/mediawiki.nix new file mode 100644 index 0000000..eaa2d77 --- /dev/null +++ b/modules/wiki-primary/mediawiki.nix @@ -0,0 +1,29 @@ +{ 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"; + }; + }; +} diff --git a/modules/wiki-primary/mysql.nix b/modules/wiki-primary/mysql.nix new file mode 100644 index 0000000..cf4699d --- /dev/null +++ b/modules/wiki-primary/mysql.nix @@ -0,0 +1,123 @@ +{ + 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} < ssh-ed25519 Ziw7aw 0yT2caPg64EyERo1cFIGmOo8lzVzOe3aoRyjz7JnhQA +NbKUAH7m/tCA4J3ICwOBx9OQJVUrc8lkqHzI5vWqFnc +--- efr0t2OzlcLkLPGc77FUJSheExslTUlAFOOWO1bJhx4 +ȼF Y ?=2ojz6~gxMVN).ewʬաEl G!R/^f[\J \ No newline at end of file diff --git a/secrets/mysql-mediawiki.age b/secrets/mysql-mediawiki.age new file mode 100644 index 0000000..4a2945c --- /dev/null +++ b/secrets/mysql-mediawiki.age @@ -0,0 +1,5 @@ +age-encryption.org/v1 +-> ssh-ed25519 Ziw7aw dohDDxsPMp1TRbvNh2qAPUFmW4cuMLnjislpRleHEHI +9MEq670uN4CzO8U/2HZAXr8MpI/vte/5pC2yQPKWemg +--- 2npxqFr1cEdGtDhZlb4zVpy04F4Xsfb2NAu3eTDUTYg +عLT Lt2l!q>0"H5^輗 ^j5QRw^?MVoniĵ,dDS-q \ No newline at end of file diff --git a/secrets/mysql-replication.age b/secrets/mysql-replication.age new file mode 100644 index 0000000000000000000000000000000000000000..f465ff57b7ddbc5eed30cba3ebb5926ecd184863 GIT binary patch literal 257 zcmYdHPt{G$OD?J`D9Oyv)5|YP*Do{V(zR14F3!+RO))YxHMCTS$}BfeELX5hDT>fH zcGk}E@(zs%bvAKM3U$x%^-0P|PS4EL4-at<^NLC|2=l2jvfy&8N=&mbC@P3BE_OF9 zH7zL!i!6)ubn!FwC`c~Na5qgU^D^ ssh-ed25519 Ziw7aw Fa0iIVw0anW/5eEZeeHjPQub7dSfowddlBHGnv/zZ2E +cXCR7bmRgwlrRGvJxAXmeceG8IZeEikBYpcK23WPn/c +--- tE9ZGzyHyQpZoBGr8m3YySYeSbpNlISsxKG7fIgqdwg +?\\tr-D{)>Jr XzZ]Ee1Wc[QƳ2ŏcfaf95 \ No newline at end of file