Compare commits

..

5 commits

Author SHA1 Message Date
Jet
01c0fa76cb
feat: remove default admin
Some checks failed
CI / check (push) Has been cancelled
CI / deploy (push) Has been cancelled
2026-03-25 23:04:52 -07:00
Jet
b00bd87046
feat: list features that we need to implement 2026-03-25 22:19:05 -07:00
Jet
98d5197056
fix: use individual admins 2026-03-25 22:15:32 -07:00
Jet
f9afc7285f
feat: update readme to be about prototype 2026-03-25 22:15:22 -07:00
Jet
3850948f71
feat: get to a solid bootstrap on public ssh 2026-03-22 21:39:35 -07:00
28 changed files with 472 additions and 890 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

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
.bootstrap/
.direnv/
dump/

202
README.md
View file

@ -1,31 +1,184 @@
# Noisebridge Wiki Infra
# Noisebridge Wiki *2.0 Prototype*
This repo manages the Noisebridge MediaWiki primary and replica on NixOS.
This repo manages the Noisebridge wiki. It is currently for the Noisebridge Wiki 2.0 Prototype that is planned to eventually replace the current Noisebridge wiki infrastructure.
## Development Hosts
- primary wiki: `main-wiki.extremist.software`
- read-only replica: `replica-wiki.extremist.software`
- deployment/admin SSH user: `jet` *this is hoped to expand soon!*
A note here, once this project is underway, CI/CD should only allow changes to come through reviewed PRs into the main branch. These changes would then be built and deployed from an automated Github action (could be forgejo actions in the future)
The current repo is the deployment foundation for a two-machine MediaWiki stack:
- primary host: MediaWiki, MariaDB primary, Caddy, agenix-managed secrets
- replica host: MediaWiki, MariaDB read-only replica, Caddy, agenix-managed secrets
We haven't fully implemented all the features, but the remaining work is tracked here so this README can act as the main working checklist.
## Implementation Checklist
### Core rollout
- [ ] Finish `hosts/main-wiki.nix` with MediaWiki, MariaDB primary, Caddy, and agenix-managed secrets
- [ ] Finish `hosts/replica-wiki.nix` with MediaWiki, MariaDB replica, Caddy, and agenix-managed secrets
- [ ] Make the flake configuration fully express hostnames, domains, shared secrets, and host-only secrets
- [ ] Wire all required agenix secrets into services on both hosts
- [ ] Keep both machine closures building cleanly from the flake
- [ ] Keep `deploy-rs` as the standard deployment path for both machines
### Database and replication
- [ ] Create the MediaWiki MariaDB database and application user on the primary
- [ ] Configure MariaDB replication user and secure replication credentials
- [ ] Configure the replica host as a real read-only MariaDB replica
- [ ] Verify replication from primary to replica under normal wiki writes
- [ ] Document promotion and rebuild expectations for the replica
### MediaWiki application
- [ ] Choose and pin the exact MediaWiki version for the new stack
- [ ] Recreate the current wiki configuration from `LocalSettings.php` in a maintainable Nix-managed form
- [ ] Configure uploads, logo, favicon, and local asset paths
- [ ] Reinstall and validate required extensions
- [ ] Reinstall and validate any non-default skins
- [ ] Recreate job queue or maintenance task execution needed by the wiki
- [ ] Confirm the primary wiki is writable
- [ ] Confirm the replica wiki is publicly readable and actually read-only
### Web serving and TLS
- [ ] Configure Caddy virtual hosts for `main-wiki.extremist.software`
- [ ] Configure Caddy virtual hosts for `replica-wiki.extremist.software`
- [ ] Configure TLS and renewal behavior for both public hosts
- [ ] Recreate any needed redirects, asset routes, and static file handling
- [ ] Verify PHP-FPM and web serving behavior under the chosen runtime
### Migration and cutover
- [ ] Dump the current MediaWiki database
- [ ] Verify the actual table prefix used in production
- [ ] Copy uploaded files from `/srv/mediawiki/noisebridge.net/images/`
- [ ] Copy local static assets from `/srv/mediawiki/noisebridge.net/img/`
- [ ] Copy and review the current `LocalSettings.php`
- [ ] Inventory current secrets including DB credentials, MediaWiki secret keys, upgrade key, and ReCaptcha keys
- [ ] Inventory the exact live MediaWiki version, extensions, skins, and Composer-managed dependencies
- [ ] Inventory current Caddy and PHP-FPM runtime configuration
- [ ] Inventory cron or systemd jobs related to MediaWiki maintenance, backups, or queues
- [ ] Measure current database and upload sizes for migration planning
- [ ] Produce rollback notes for the final cutover
- [ ] Import production data into the new primary host
- [ ] Verify the replica catches up from the imported primary state
- [ ] Smoke test reading, editing, login, uploads, search, history, and diff behavior before cutover
### CI/CD and repository workflow
- [ ] Require reviewed PRs before merge to `main`
- [ ] Block direct pushes to `main`
- [ ] Keep `nix flake check` required in CI
- [ ] Keep both host builds required in CI
- [ ] Keep automatic deploys on pushes to `main`
- [ ] Add post-deploy smoke checks if needed
- [ ] Optionally move from GitHub Actions to Forgejo Actions later
### Security and access policy
- [ ] Explicitly define anonymous user permissions in MediaWiki
- [ ] Keep account creation invite-only
- [ ] Allow read, search, history, and diff access where desired
- [ ] Restrict broader special-page use for anonymous traffic
- [ ] Review SSH/admin access model beyond the initial `jet` user
## Future Features Checklist
### Edge and public access
- [ ] Use the final apex domain for the primary wiki
- [ ] Serve the final replica from `replica.<domain>`
- [ ] Support a direct-to-origin non-Cloudflare deployment mode
- [ ] Add a separate Cloudflare-proxied deployment mode later
### Performance and abuse controls
- [ ] Add aggressive anonymous rate limiting in Caddy
- [ ] Add cache policy that favors anonymous page views
- [ ] Reduce or bypass caching for logged-in and dynamic traffic
- [ ] Preserve good behavior for logged-in editors while limiting abuse
- [ ] Add stronger service and access logging for tuning
### Database evolution
- [ ] Revisit the long-term database backend after the baseline is stable
- [ ] Evaluate migration away from MariaDB if a better fit emerges
### Observability
- [ ] Add public read-only Grafana
- [ ] Add a public status page
- [ ] Add email alerts
- [ ] Add Discord webhook alerts
- [ ] Add more detailed dashboards
### Backups and exports
- [ ] Add encrypted client-side backups to Backblaze B2
- [ ] Define a retention policy
- [ ] Write a restore runbook
- [ ] Support volunteer-hosted backup targets later
- [ ] Provide a scraper-friendly export API instead of forcing heavy live-site scraping
- [ ] Publish a sanitized public SQL subset rather than a raw production dump
- [ ] Generate daily export snapshots
- [ ] Host downloads away from the primary, ideally from the replica side
- [ ] Publish stable JSON metadata for latest export, export history, checksums, and download URLs
- [ ] Add additional public export formats beyond SQL subset dumps
### Tor and alternative access
- [ ] Add a stable onion service for the primary host
- [ ] Add a stable onion service for the replica host
- [ ] Manage onion private keys with agenix so addresses survive rebuilds
### Longer-term operations
- [ ] Write a formal failover and promotion runbook
- [ ] Add stronger deployment protections
- [ ] Add a scheduled flake lock update workflow or an admin-run update script with PR review before merge
## 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]
nix run .#bootstrap-host -- <main-target-host> <replica-target-host> [ssh-identity-file]
nix run .#bootstrap-host -- --admin <name> <main-wiki|replica-wiki> <target-host> [ssh-identity-file]
nix run .#bootstrap-host -- --admin <name> <main-target-host> <replica-target-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
nix run .#bootstrap-host -- --admin jet main-wiki root@203.0.113.10 ~/.ssh/do-bootstrap
nix run .#bootstrap-host -- --admin jet root@203.0.113.10 root@203.0.113.11 ~/.ssh/do-bootstrap
```
`--admin <name>` is required. The admin must exist in `siteConfig.adminUsers` in `flake.nix`.
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 requested admin user
- disables direct root SSH
- fixes the known bad IPv6 routes generated by `nixos-infect`
- verifies that the requested admin login and `sudo` work and that the host reaches `running`
What bootstrap is not:
- it is not the normal long-term deploy path
- it is not the full application rollout
- it is only the one-off Ubuntu-to-NixOS installer step
> This is made to only be run once and to potentially prop up new servers if needed
Deploy all already-bootstrapped hosts:
@ -58,24 +211,5 @@ 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.
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`
3. The machine installs NixOS and comes up over public SSH.
4. Future configuration changes would be made through CI/CD.

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_USERS_JSON@"
"@JQ@"
]
[
(builtins.toJSON siteConfig.adminUsers)
"${pkgs.jq}/bin/jq"
]
(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
'')
];
}

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

@ -0,0 +1,193 @@
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
nix run .#bootstrap-host -- --admin <name> <main-wiki|replica-wiki> <target-host> [ssh-identity-file]
nix run .#bootstrap-host -- --admin <name> <main-target-host> <replica-target-host> [ssh-identity-file]
USAGE
}
repo_root="$(pwd)"
if [ ! -f "$repo_root/flake.nix" ]; then
printf 'Run bootstrap-host from the repo root\n' >&2
exit 1
fi
admin_users_json='@ADMIN_USERS_JSON@'
pinned_nix_install_url='https://releases.nixos.org/nix/nix-2.24.14/install'
bootstrap_admin=""
ssh_identity_file=""
main_target=""
replica_target=""
failures=()
if [ "${1:-}" != "--admin" ] || [ "$#" -lt 4 ]; then
printf 'Bootstrap requires --admin <name>\n' >&2
usage
exit 1
fi
bootstrap_admin="$2"
shift 2
if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then
usage
exit 1
fi
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
admin_keys() {
printf '%s' "$admin_users_json" | @JQ@ -r --arg user "$1" '.[$user].openssh.authorizedKeys.keys[]? | " \"" + . + "\""'
}
admin_exists() {
printf '%s' "$admin_users_json" | @JQ@ -e --arg user "$1" 'has($user)' >/dev/null
}
if ! admin_exists "$bootstrap_admin"; then
printf 'Unknown admin user for bootstrap: %s\n' "$bootstrap_admin" >&2
exit 1
fi
make_host_module() {
local module_file="$1"
local admin_name="$2"
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.${admin_name} = {
isNormalUser = true;
extraGroups = [ "wheel" ];
openssh.authorizedKeys.keys = [
$(admin_keys "$admin_name")
];
};
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)
local admin_target
work_dir="$(mktemp -d)"
module_file="$work_dir/host-bootstrap.nix"
remote_target="$target_host:/etc/nixos/host-bootstrap.nix"
admin_target="${bootstrap_admin}@${target_host#*@}"
make_host_module "$module_file" "$bootstrap_admin"
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 NIX_INSTALL_URL='$pinned_nix_install_url' 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 "$admin_target" 'grep -q "^ID=nixos" /etc/os-release' 2>/dev/null; then
break
fi
sleep 5
done
if ! "${ssh_cmd[@]}" -o ConnectTimeout=5 "$admin_target" 'grep -q "^ID=nixos" /etc/os-release' 2>/dev/null; then
printf 'Bootstrap failed for %s: host did not come back as NixOS with %s access\n' "$host_name" "$bootstrap_admin" >&2
failures+=( "$host_name" )
rm -rf "$work_dir"
return 1
fi
printf 'Finalizing network config on %s\n' "$host_name"
"${ssh_cmd[@]}" "$admin_target" '
sudo sed -i "/defaultGateway6 = {/,/};/d" /etc/nixos/networking.nix 2>/dev/null || true
sudo sed -i "/ipv6.routes = \[ { address = \"\"; prefixLength = 128; } \];/d" /etc/nixos/networking.nix 2>/dev/null || true
sudo nixos-rebuild switch
'
if ! "${ssh_cmd[@]}" "$admin_target" 'sudo -n true >/dev/null && test "$(systemctl is-system-running || true)" = running' 2>/dev/null; then
printf 'Bootstrap verification failed for %s: host is not healthy after first switch\n' "$host_name" >&2
failures+=( "$host_name" )
rm -rf "$work_dir"
return 1
fi
printf 'Bootstrap verified for %s\n' "$host_name"
rm -rf "$work_dir"
}
if [ -n "${main_target:-}" ]; then
run_bootstrap main-wiki "$main_target" || true
fi
if [ -n "${replica_target:-}" ]; then
run_bootstrap replica-wiki "$replica_target" || true
fi
if [ "${#failures[@]}" -ne 0 ]; then
printf '\nBootstrap failed for: %s\n' "${failures[*]}" >&2
exit 1
fi
printf '\nBootstrap complete. The hosts should now be reachable as NixOS systems over public SSH as %s.\n' "$bootstrap_admin"

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äÚ