feat: init
This commit is contained in:
commit
8cfede9f57
28 changed files with 2129 additions and 0 deletions
1
.envrc
Normal file
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
use flake
|
||||
11
.github/pull_request_template.md
vendored
Normal file
11
.github/pull_request_template.md
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
## What
|
||||
|
||||
<!-- Brief description of the change -->
|
||||
|
||||
## Why
|
||||
|
||||
<!-- Motivation or link to issue -->
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- How was this tested? -->
|
||||
61
.github/workflows/ci.yml
vendored
Normal file
61
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
- uses: cachix/cachix-action@v15
|
||||
with:
|
||||
name: noisebridge-wiki
|
||||
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: nix flake check
|
||||
run: nix flake check
|
||||
|
||||
- name: Build wiki
|
||||
run: nix build .#nixosConfigurations.wiki.config.system.build.toplevel
|
||||
|
||||
- name: Build wiki-replica
|
||||
run: nix build .#nixosConfigurations.wiki-replica.config.system.build.toplevel
|
||||
|
||||
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
|
||||
- uses: cachix/cachix-action@v15
|
||||
with:
|
||||
name: noisebridge-wiki
|
||||
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- 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
|
||||
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -t ed25519 wiki wiki-replica >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Deploy wiki
|
||||
run: nix run .#deploy -- .#wiki -- --ssh-opts="-o ConnectTimeout=30"
|
||||
|
||||
- name: Deploy wiki-replica
|
||||
run: nix run .#deploy -- .#wiki-replica -- --ssh-opts="-o ConnectTimeout=30"
|
||||
17
.github/workflows/update-flake.yml
vendored
Normal file
17
.github/workflows/update-flake.yml
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
name: Update flake.lock
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * *" # 6am UTC daily
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
- uses: DeterminateSystems/update-flake-lock@main
|
||||
with:
|
||||
pr-title: "chore: update flake.lock"
|
||||
pr-labels: dependencies
|
||||
110
flake.nix
Normal file
110
flake.nix
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
{
|
||||
description = "Noisebridge Wiki — Standalone NixOS Infrastructure";
|
||||
|
||||
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";
|
||||
};
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, agenix, deploy-rs, ... }:
|
||||
let
|
||||
system = "x86_64-linux";
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in
|
||||
{
|
||||
overlays.default = import ./overlays/caddy.nix;
|
||||
|
||||
nixosConfigurations.wiki = nixpkgs.lib.nixosSystem {
|
||||
inherit system;
|
||||
specialArgs = { inherit agenix; };
|
||||
modules = [
|
||||
{ nixpkgs.overlays = [ self.overlays.default ]; }
|
||||
agenix.nixosModules.default
|
||||
./hosts/wiki
|
||||
./modules/common.nix
|
||||
./modules/tailscale.nix
|
||||
./modules/security.nix
|
||||
./modules/users.nix
|
||||
./modules/tor.nix
|
||||
./modules/mediawiki-base.nix
|
||||
./modules/wiki-primary/mediawiki.nix
|
||||
./modules/wiki-primary/mysql.nix
|
||||
./modules/wiki-primary/caddy.nix
|
||||
./modules/wiki-primary/prometheus.nix
|
||||
./modules/wiki-primary/alerting.nix
|
||||
./modules/wiki-primary/grafana.nix
|
||||
./modules/wiki-primary/backup.nix
|
||||
./modules/wiki-primary/postfix.nix
|
||||
];
|
||||
};
|
||||
|
||||
nixosConfigurations.wiki-replica = nixpkgs.lib.nixosSystem {
|
||||
inherit system;
|
||||
specialArgs = { inherit agenix; };
|
||||
modules = [
|
||||
{ nixpkgs.overlays = [ self.overlays.default ]; }
|
||||
agenix.nixosModules.default
|
||||
./hosts/wiki-replica
|
||||
./modules/common.nix
|
||||
./modules/tailscale.nix
|
||||
./modules/security.nix
|
||||
./modules/users.nix
|
||||
./modules/tor.nix
|
||||
./modules/mediawiki-base.nix
|
||||
./modules/wiki-replica/mediawiki.nix
|
||||
./modules/wiki-replica/mysql.nix
|
||||
./modules/wiki-replica/caddy.nix
|
||||
];
|
||||
};
|
||||
|
||||
deploy.nodes = {
|
||||
wiki = {
|
||||
hostname = "wiki"; # Tailscale hostname
|
||||
profiles.system = {
|
||||
user = "root";
|
||||
sshUser = "root";
|
||||
path = deploy-rs.lib.${system}.activate.nixos
|
||||
self.nixosConfigurations.wiki;
|
||||
};
|
||||
};
|
||||
wiki-replica = {
|
||||
hostname = "wiki-replica"; # Tailscale hostname
|
||||
profiles.system = {
|
||||
user = "root";
|
||||
sshUser = "root";
|
||||
path = deploy-rs.lib.${system}.activate.nixos
|
||||
self.nixosConfigurations.wiki-replica;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
checks = builtins.mapAttrs
|
||||
(system: deployLib: deployLib.deployChecks self.deploy)
|
||||
deploy-rs.lib;
|
||||
|
||||
apps.${system}.deploy = {
|
||||
type = "app";
|
||||
program = "${deploy-rs.packages.${system}.default}/bin/deploy";
|
||||
};
|
||||
|
||||
devShells.${system}.default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
deploy-rs.packages.${system}.default
|
||||
agenix.packages.${system}.default
|
||||
mariadb.client
|
||||
rclone
|
||||
curl
|
||||
jq
|
||||
hey
|
||||
mydumper
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
16
hosts/wiki-replica/default.nix
Normal file
16
hosts/wiki-replica/default.nix
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
{
|
||||
imports = [
|
||||
./hardware-configuration.nix
|
||||
];
|
||||
|
||||
boot.loader.systemd-boot.enable = true;
|
||||
boot.loader.efi.canTouchEfiVariables = true;
|
||||
|
||||
networking.hostName = "wiki-replica";
|
||||
networking.domain = "noisebridge.net";
|
||||
|
||||
networking.useDHCP = true;
|
||||
|
||||
system.stateVersion = "24.11";
|
||||
}
|
||||
15
hosts/wiki-replica/hardware-configuration.nix
Normal file
15
hosts/wiki-replica/hardware-configuration.nix
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Replace with output of `nixos-generate-config --show-hardware-config`
|
||||
# after installing on the actual VPS.
|
||||
{ config, lib, modulesPath, ... }:
|
||||
{
|
||||
imports = [
|
||||
(modulesPath + "/profiles/qemu-guest.nix")
|
||||
];
|
||||
|
||||
boot.initrd.availableKernelModules = [
|
||||
"virtio_pci"
|
||||
"virtio_scsi"
|
||||
"ahci"
|
||||
"sd_mod"
|
||||
];
|
||||
}
|
||||
21
hosts/wiki/default.nix
Normal file
21
hosts/wiki/default.nix
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
{
|
||||
imports = [
|
||||
./hardware-configuration.nix
|
||||
];
|
||||
|
||||
boot.loader.systemd-boot.enable = true;
|
||||
boot.loader.efi.canTouchEfiVariables = true;
|
||||
|
||||
networking.hostName = "wiki";
|
||||
networking.domain = "noisebridge.net";
|
||||
|
||||
# VPS typically uses DHCP — override with static IP if needed
|
||||
networking.useDHCP = true;
|
||||
# networking.interfaces.ens3 = {
|
||||
# ipv4.addresses = [{ address = "TODO"; prefixLength = 24; }];
|
||||
# };
|
||||
# networking.defaultGateway = { address = "TODO"; interface = "ens3"; };
|
||||
|
||||
system.stateVersion = "24.11";
|
||||
}
|
||||
15
hosts/wiki/hardware-configuration.nix
Normal file
15
hosts/wiki/hardware-configuration.nix
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Replace with output of `nixos-generate-config --show-hardware-config`
|
||||
# after installing on the actual VPS.
|
||||
{ config, lib, modulesPath, ... }:
|
||||
{
|
||||
imports = [
|
||||
(modulesPath + "/profiles/qemu-guest.nix")
|
||||
];
|
||||
|
||||
boot.initrd.availableKernelModules = [
|
||||
"virtio_pci"
|
||||
"virtio_scsi"
|
||||
"ahci"
|
||||
"sd_mod"
|
||||
];
|
||||
}
|
||||
31
modules/common.nix
Normal file
31
modules/common.nix
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{ config, pkgs, ... }:
|
||||
{
|
||||
nix = {
|
||||
settings = {
|
||||
auto-optimise-store = true;
|
||||
experimental-features = [ "nix-command" "flakes" ];
|
||||
trusted-users = [ "root" "@wheel" ];
|
||||
};
|
||||
gc = {
|
||||
automatic = true;
|
||||
dates = "weekly";
|
||||
options = "--delete-older-than 30d";
|
||||
};
|
||||
};
|
||||
|
||||
time.timeZone = "US/Pacific";
|
||||
i18n.defaultLocale = "en_US.UTF-8";
|
||||
services.timesyncd.enable = true;
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
vim
|
||||
git
|
||||
htop
|
||||
tmux
|
||||
curl
|
||||
wget
|
||||
jq
|
||||
dig
|
||||
tcpdump
|
||||
];
|
||||
}
|
||||
179
modules/mediawiki-base.nix
Normal file
179
modules/mediawiki-base.nix
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
{
|
||||
services.memcached = {
|
||||
enable = true;
|
||||
maxMemory = 256;
|
||||
listen = "127.0.0.1";
|
||||
port = 11211;
|
||||
};
|
||||
|
||||
services.mediawiki = {
|
||||
enable = true;
|
||||
name = "Noisebridge";
|
||||
url = "https://www.noisebridge.net";
|
||||
passwordFile = config.age.secrets.mediawiki-secret-key.path;
|
||||
|
||||
database = {
|
||||
type = "mysql";
|
||||
name = "noisebridge_mediawiki";
|
||||
user = "wiki";
|
||||
passwordFile = config.age.secrets.mysql-mediawiki.path;
|
||||
socket = "/run/mysqld/mysqld.sock";
|
||||
createLocally = false;
|
||||
};
|
||||
|
||||
extensions = {
|
||||
# Bundled extensions
|
||||
CiteThisPage = null;
|
||||
Cite = null;
|
||||
ConfirmEdit = null;
|
||||
Gadgets = null;
|
||||
ImageMap = null;
|
||||
InputBox = null;
|
||||
Interwiki = null;
|
||||
LocalisationUpdate = null;
|
||||
Nuke = null;
|
||||
ParserFunctions = null;
|
||||
PdfHandler = null;
|
||||
Poem = null;
|
||||
Renameuser = null;
|
||||
SpamBlacklist = null;
|
||||
SyntaxHighlight_GeSHi = null;
|
||||
TitleBlacklist = null;
|
||||
WikiEditor = null;
|
||||
CategoryTree = null;
|
||||
CodeEditor = null;
|
||||
VisualEditor = null;
|
||||
Scribunto = null;
|
||||
TemplateData = null;
|
||||
TextExtracts = null;
|
||||
PageImages = null;
|
||||
Popups = null;
|
||||
MultimediaViewer = null;
|
||||
Math = null;
|
||||
ReplaceText = null;
|
||||
SecureLinkFixer = null;
|
||||
};
|
||||
|
||||
skins = {
|
||||
Vector = null;
|
||||
};
|
||||
|
||||
extraConfig = ''
|
||||
# ----- Branding & URLs -----
|
||||
$wgMetaNamespace = "Noisebridge";
|
||||
$wgSitename = "Noisebridge";
|
||||
$wgServer = "https://www.noisebridge.net";
|
||||
$wgScriptPath = "";
|
||||
$wgArticlePath = "/wiki/$1";
|
||||
$wgUsePathInfo = true;
|
||||
$wgLogo = "$wgResourceBasePath/resources/assets/noisebridge-logo.png";
|
||||
|
||||
# ----- Skin: Vector 2022 only -----
|
||||
$wgDefaultSkin = "vector-2022";
|
||||
$wgVectorDefaultSkinVersion = "2";
|
||||
$wgSkipSkins = [ "cologneblue", "monobook", "modern", "timeless" ];
|
||||
|
||||
# ----- Locale & License -----
|
||||
$wgLanguageCode = "en";
|
||||
$wgLocaltimezone = "America/Los_Angeles";
|
||||
$wgRightsPage = "";
|
||||
$wgRightsUrl = "https://creativecommons.org/licenses/by-sa/4.0/";
|
||||
$wgRightsText = "Creative Commons Attribution-ShareAlike";
|
||||
$wgRightsIcon = "$wgResourceBasePath/resources/assets/licenses/cc-by-sa.png";
|
||||
|
||||
# ----- Database -----
|
||||
$wgDBtype = "mysql";
|
||||
$wgDBTableOptions = "ENGINE=InnoDB, DEFAULT CHARSET=binary";
|
||||
|
||||
# ----- Memcached -----
|
||||
$wgMainCacheType = CACHE_MEMCACHED;
|
||||
$wgMemCachedServers = [ "127.0.0.1:11211" ];
|
||||
$wgSessionCacheType = CACHE_MEMCACHED;
|
||||
$wgMessageCacheType = CACHE_MEMCACHED;
|
||||
$wgParserCacheType = CACHE_MEMCACHED;
|
||||
|
||||
# ----- Permissions -----
|
||||
# Anonymous users can read but not edit
|
||||
$wgGroupPermissions['*']['edit'] = false;
|
||||
$wgGroupPermissions['*']['createpage'] = false;
|
||||
$wgGroupPermissions['*']['createtalk'] = false;
|
||||
$wgGroupPermissions['*']['writeapi'] = false;
|
||||
|
||||
# Registered users can edit after autoconfirm
|
||||
$wgGroupPermissions['user']['edit'] = true;
|
||||
$wgGroupPermissions['user']['createpage'] = true;
|
||||
$wgGroupPermissions['user']['createtalk'] = true;
|
||||
$wgGroupPermissions['user']['writeapi'] = true;
|
||||
$wgGroupPermissions['user']['upload'] = true;
|
||||
$wgGroupPermissions['user']['reupload'] = true;
|
||||
$wgGroupPermissions['user']['move'] = true;
|
||||
|
||||
# Autoconfirm: 5 edits + 3 days + email confirmed
|
||||
$wgAutoConfirmAge = 3 * 86400;
|
||||
$wgAutoConfirmCount = 5;
|
||||
$wgEmailConfirmToEdit = true;
|
||||
|
||||
# ----- Uploads -----
|
||||
$wgEnableUploads = true;
|
||||
$wgFileExtensions = array_merge(
|
||||
$wgFileExtensions,
|
||||
[ "pdf", "svg", "png", "gif", "jpg", "jpeg", "webp" ]
|
||||
);
|
||||
$wgMaxUploadSize = 10 * 1024 * 1024; // 10MB
|
||||
$wgUseImageMagick = true;
|
||||
$wgImageMagickConvertCommand = "${pkgs.imagemagick}/bin/convert";
|
||||
|
||||
# ----- Foreign file repos (Wikimedia Commons) -----
|
||||
$wgUseInstantCommons = true;
|
||||
|
||||
# ----- Cookie prefix (must match Caddy session detection) -----
|
||||
$wgCookiePrefix = "nb_wiki";
|
||||
|
||||
# ----- Performance -----
|
||||
$wgUseGzip = true;
|
||||
$wgDiff3 = "${pkgs.diffutils}/bin/diff3";
|
||||
$wgJobRunRate = 0; // jobs handled by maintenance script
|
||||
$wgResourceLoaderMaxage = [
|
||||
'versioned' => 30 * 86400,
|
||||
'unversioned' => 300,
|
||||
];
|
||||
|
||||
# ----- Extension configs -----
|
||||
# Scribunto (Lua templating)
|
||||
$wgScribuntoDefaultEngine = "luastandalone";
|
||||
$wgScribuntoEngineConf['luastandalone']['luaPath'] = "${pkgs.lua5_4}/bin/lua";
|
||||
|
||||
# VisualEditor
|
||||
$wgVisualEditorEnableWikitext = true;
|
||||
$wgDefaultUserOptions['visualeditor-enable'] = 1;
|
||||
|
||||
# ParserFunctions
|
||||
$wgPFEnableStringFunctions = true;
|
||||
|
||||
# SpamBlacklist
|
||||
$wgSpamBlacklistFiles = [
|
||||
"https://meta.wikimedia.org/w/index.php?title=Spam_blacklist&action=raw&sb_ver=1",
|
||||
];
|
||||
|
||||
# SyntaxHighlight
|
||||
$wgSyntaxHighlightModels['nix'] = 'nix';
|
||||
|
||||
# Popups (Page Previews)
|
||||
$wgPopupsHideOptInOnPreferencesPage = true;
|
||||
$wgPopupsOptInDefaultState = "1";
|
||||
'';
|
||||
};
|
||||
|
||||
age.secrets.mediawiki-secret-key = {
|
||||
file = ../secrets/mediawiki-secret-key.age;
|
||||
owner = "mediawiki";
|
||||
group = "mediawiki";
|
||||
};
|
||||
|
||||
age.secrets.mysql-mediawiki = {
|
||||
file = ../secrets/mysql-mediawiki.age;
|
||||
owner = "mediawiki";
|
||||
group = "mediawiki";
|
||||
};
|
||||
}
|
||||
121
modules/security.nix
Normal file
121
modules/security.nix
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
{ config, pkgs, ... }:
|
||||
{
|
||||
networking.firewall = {
|
||||
enable = true;
|
||||
# SSH is NOT public — only accessible via Tailscale (trustedInterfaces)
|
||||
allowedTCPPorts = [
|
||||
80 # HTTP (Caddy ACME + redirect)
|
||||
443 # HTTPS
|
||||
];
|
||||
logReversePathDrops = true;
|
||||
|
||||
# Kernel-level DDoS protection via iptables
|
||||
# These rules fire BEFORE Caddy even sees the packet, so they're very cheap.
|
||||
extraCommands = ''
|
||||
# ── SYN flood protection ──
|
||||
# Limit new TCP connections to 30/sec per source IP (burst 50).
|
||||
# Legitimate browsers open ~6 connections; scrapers open hundreds.
|
||||
iptables -N RATE_LIMIT 2>/dev/null || iptables -F RATE_LIMIT
|
||||
iptables -A RATE_LIMIT -m hashlimit \
|
||||
--hashlimit-name syn_flood \
|
||||
--hashlimit-above 30/sec \
|
||||
--hashlimit-burst 50 \
|
||||
--hashlimit-mode srcip \
|
||||
--hashlimit-htable-expire 300000 \
|
||||
-j DROP
|
||||
iptables -A RATE_LIMIT -j RETURN
|
||||
|
||||
# Hook into INPUT chain for new TCP SYN packets to HTTP/HTTPS
|
||||
iptables -C INPUT -p tcp --syn -m multiport --dports 80,443 -j RATE_LIMIT 2>/dev/null || \
|
||||
iptables -I INPUT -p tcp --syn -m multiport --dports 80,443 -j RATE_LIMIT
|
||||
|
||||
# ── Connection limit ──
|
||||
# Max 200 concurrent connections per source IP to HTTP/HTTPS.
|
||||
# A single browser uses ~6-10; a scraper farm uses thousands.
|
||||
iptables -C INPUT -p tcp -m multiport --dports 80,443 -m connlimit --connlimit-above 200 --connlimit-mask 32 -j DROP 2>/dev/null || \
|
||||
iptables -I INPUT -p tcp -m multiport --dports 80,443 -m connlimit --connlimit-above 200 --connlimit-mask 32 -j DROP
|
||||
|
||||
# ── Same for IPv6 ──
|
||||
ip6tables -N RATE_LIMIT 2>/dev/null || ip6tables -F RATE_LIMIT
|
||||
ip6tables -A RATE_LIMIT -m hashlimit \
|
||||
--hashlimit-name syn_flood_v6 \
|
||||
--hashlimit-above 30/sec \
|
||||
--hashlimit-burst 50 \
|
||||
--hashlimit-mode srcip \
|
||||
--hashlimit-htable-expire 300000 \
|
||||
-j DROP
|
||||
ip6tables -A RATE_LIMIT -j RETURN
|
||||
|
||||
ip6tables -C INPUT -p tcp --syn -m multiport --dports 80,443 -j RATE_LIMIT 2>/dev/null || \
|
||||
ip6tables -I INPUT -p tcp --syn -m multiport --dports 80,443 -j RATE_LIMIT
|
||||
|
||||
ip6tables -C INPUT -p tcp -m multiport --dports 80,443 -m connlimit --connlimit-above 200 --connlimit-mask 64 -j DROP 2>/dev/null || \
|
||||
ip6tables -I INPUT -p tcp -m multiport --dports 80,443 -m connlimit --connlimit-above 200 --connlimit-mask 64 -j DROP
|
||||
'';
|
||||
|
||||
# Clean up custom chains on stop
|
||||
extraStopCommands = ''
|
||||
iptables -D INPUT -p tcp --syn -m multiport --dports 80,443 -j RATE_LIMIT 2>/dev/null || true
|
||||
iptables -D INPUT -p tcp -m multiport --dports 80,443 -m connlimit --connlimit-above 200 --connlimit-mask 32 -j DROP 2>/dev/null || true
|
||||
iptables -F RATE_LIMIT 2>/dev/null || true
|
||||
iptables -X RATE_LIMIT 2>/dev/null || true
|
||||
|
||||
ip6tables -D INPUT -p tcp --syn -m multiport --dports 80,443 -j RATE_LIMIT 2>/dev/null || true
|
||||
ip6tables -D INPUT -p tcp -m multiport --dports 80,443 -m connlimit --connlimit-above 200 --connlimit-mask 64 -j DROP 2>/dev/null || true
|
||||
ip6tables -F RATE_LIMIT 2>/dev/null || true
|
||||
ip6tables -X RATE_LIMIT 2>/dev/null || true
|
||||
'';
|
||||
};
|
||||
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
settings = {
|
||||
PasswordAuthentication = false;
|
||||
KbdInteractiveAuthentication = false;
|
||||
PermitRootLogin = "prohibit-password";
|
||||
X11Forwarding = false;
|
||||
MaxAuthTries = 3;
|
||||
};
|
||||
# Do NOT open firewall — SSH only over Tailscale
|
||||
openFirewall = false;
|
||||
};
|
||||
|
||||
# Fail2ban for HTTP abuse (not SSH — SSH isn't public)
|
||||
services.fail2ban = {
|
||||
enable = true;
|
||||
maxretry = 5;
|
||||
bantime = "1h";
|
||||
bantime-increment = {
|
||||
enable = true;
|
||||
maxtime = "48h";
|
||||
};
|
||||
};
|
||||
|
||||
boot.kernel.sysctl = {
|
||||
# Reverse path filtering
|
||||
"net.ipv4.conf.all.rp_filter" = 1;
|
||||
"net.ipv4.conf.default.rp_filter" = 1;
|
||||
|
||||
# Ignore broadcast pings
|
||||
"net.ipv4.icmp_echo_ignore_broadcasts" = 1;
|
||||
|
||||
# Don't accept or send redirects
|
||||
"net.ipv4.conf.all.accept_redirects" = 0;
|
||||
"net.ipv6.conf.all.accept_redirects" = 0;
|
||||
"net.ipv4.conf.all.send_redirects" = 0;
|
||||
|
||||
# Reject source-routed packets
|
||||
"net.ipv4.conf.all.accept_source_route" = 0;
|
||||
"net.ipv6.conf.all.accept_source_route" = 0;
|
||||
|
||||
# SYN flood protection (kernel-level SYN cookies)
|
||||
"net.ipv4.tcp_syncookies" = 1;
|
||||
"net.ipv4.tcp_max_syn_backlog" = 4096;
|
||||
|
||||
# Reduce TIME_WAIT accumulation from abusive connections
|
||||
"net.ipv4.tcp_fin_timeout" = 15;
|
||||
|
||||
# Connection tracking table size (default 65536 is too small under DDoS)
|
||||
"net.netfilter.nf_conntrack_max" = 262144;
|
||||
};
|
||||
}
|
||||
26
modules/tailscale.nix
Normal file
26
modules/tailscale.nix
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{ config, pkgs, ... }:
|
||||
{
|
||||
age.secrets.tailscale-auth = {
|
||||
file = ../secrets/tailscale-auth.age;
|
||||
owner = "root";
|
||||
};
|
||||
|
||||
services.tailscale.enable = true;
|
||||
|
||||
systemd.services.tailscale-autoconnect = {
|
||||
description = "Automatic connection to Tailscale";
|
||||
after = [ "network-pre.target" "tailscale.service" ];
|
||||
wants = [ "network-pre.target" "tailscale.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig.Type = "oneshot";
|
||||
script = ''
|
||||
sleep 2
|
||||
status="$(${pkgs.tailscale}/bin/tailscale status -json | ${pkgs.jq}/bin/jq -r .BackendState)"
|
||||
if [ "$status" = "Running" ]; then exit 0; fi
|
||||
${pkgs.tailscale}/bin/tailscale up --authkey file:${config.age.secrets.tailscale-auth.path}
|
||||
'';
|
||||
};
|
||||
|
||||
networking.firewall.trustedInterfaces = [ "tailscale0" ];
|
||||
networking.firewall.allowedUDPPorts = [ 41641 ];
|
||||
}
|
||||
45
modules/tor.nix
Normal file
45
modules/tor.nix
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Tor hidden service — gives each machine a .onion address
|
||||
#
|
||||
# After first boot, find the .onion address:
|
||||
# cat /var/lib/tor/onion/wiki/hostname
|
||||
#
|
||||
# Back up the private key! Losing it means losing the .onion address:
|
||||
# /var/lib/tor/onion/wiki/hs_ed25519_secret_key
|
||||
#
|
||||
# The .onion address is a hash of this key — it's permanent as long as
|
||||
# the key exists. Both machines get different keys and different addresses.
|
||||
#
|
||||
# Traffic flow:
|
||||
# Tor user → Tor network → local Tor daemon → localhost:8080 → Caddy → PHP-FPM
|
||||
#
|
||||
# No Cloudflare in the path, no TLS needed (.onion v3 is end-to-end encrypted),
|
||||
# no IP-based rate limiting possible (all traffic arrives from 127.0.0.1).
|
||||
{ config, pkgs, lib, ... }:
|
||||
{
|
||||
services.tor = {
|
||||
enable = true;
|
||||
client.enable = false; # we're a server, not a client
|
||||
|
||||
relay.onionServices.wiki = {
|
||||
version = 3;
|
||||
map = [{
|
||||
port = 80;
|
||||
target = {
|
||||
addr = "127.0.0.1";
|
||||
port = 8080;
|
||||
};
|
||||
}];
|
||||
};
|
||||
};
|
||||
|
||||
# Tor needs outbound connectivity to join the network
|
||||
# (already allowed — the firewall doesn't block outbound by default)
|
||||
|
||||
# Ensure the onion service directory is backed up
|
||||
# The key files are in /var/lib/tor/onion/wiki/
|
||||
# If using agenix to manage a pre-generated key for a stable .onion address:
|
||||
# 1. Generate a key: tor --keygen (or use mkp224o for vanity addresses)
|
||||
# 2. Encrypt with agenix: agenix -e secrets/tor-onion-key.age
|
||||
# 3. Deploy to /var/lib/tor/onion/wiki/hs_ed25519_secret_key
|
||||
# For now, Tor generates the key on first boot — just back it up.
|
||||
}
|
||||
74
modules/users.nix
Normal file
74
modules/users.nix
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
admins = [
|
||||
{
|
||||
name = "superq";
|
||||
github = "SuperQ";
|
||||
description = "Ben Kochie";
|
||||
}
|
||||
{
|
||||
name = "rizend";
|
||||
description = "rizend";
|
||||
extraKeys = [
|
||||
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDWvlc3+qDxhKE3jCCxKKU1h9QJyhCqLgHAwkiokvSPig6dXZW9f8uS/1CNMEmB5avrZhT6S3V00NExqZMldJechROhQoZb6YrUzakaeJCHrbThotQ/TlDuRWCCEh+y/qowk261X4Rbdx/KMwPuROP0p+pw2u3CVoLC7ejnsCwzTMZJ450QtZau0nvP7PY1vnehg2npA4HOqtwjOABJlMMpSZfaQdddwQJ7YE01GLpXF73Lwcnyue51fWFdjsQwIeQM2feO0yf1r1fjoLyMfWCVLK2GI0ONXVFWKQ52kfzr4QQ7Tq+Xi12qr7KGlHZ8yl7tw3MUoyU7k0HrUea1F8WF"
|
||||
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCvHlZKV8yBsJOkeu2FkWZ1UDY/uTS8bBUbqh1W0pJ3BMec55uvRLNv1AT5Z7RHKbwdjiZTBm6sP0CRVjsOxeGRCVeddHx1SxsXeihZIRQLHX+Z7M1YwYdzmzRDIEhuZhp+RnGH71ESVEHlmUhNPYsNmlgE3nyNbbDatYRZQqC204pal6cz4CHRUWYIozAQvpO8BF+cNDbNgT1yR5DWflwHErlv8yltmxNjh+gQQgp7RzI+05uzpRgumLCIqdHIKUflDJGvZXnUNAr5nv8Xe3W77AZz348nK2SYoD7dOBw23LpEzmy0mENL+/d3ZCuricslc1eBqCpVxJiF7s/RCtix"
|
||||
];
|
||||
}
|
||||
{
|
||||
name = "bfb";
|
||||
github = "kevinjos";
|
||||
description = "bfb";
|
||||
}
|
||||
{
|
||||
name = "jof";
|
||||
github = "jof";
|
||||
description = "Jonathan Lassoff";
|
||||
}
|
||||
{
|
||||
name = "mcint";
|
||||
github = "mcint";
|
||||
description = "Loren McIntyre";
|
||||
}
|
||||
];
|
||||
|
||||
mkAdmin = { name, github ? null, description, extraKeys ? [] }: {
|
||||
inherit name;
|
||||
value = {
|
||||
isNormalUser = true;
|
||||
inherit description;
|
||||
extraGroups = [ "wheel" ];
|
||||
openssh.authorizedKeys.keys = extraKeys;
|
||||
openssh.authorizedKeys.keyFiles =
|
||||
lib.optionals (github != null) [
|
||||
(builtins.fetchurl {
|
||||
url = "https://github.com/${github}.keys";
|
||||
})
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
# Collect all GitHub key files for root access (deploy-rs needs root SSH)
|
||||
adminKeyFiles = lib.concatMap
|
||||
({ github ? null, ... }:
|
||||
lib.optionals (github != null) [
|
||||
(builtins.fetchurl { url = "https://github.com/${github}.keys"; })
|
||||
])
|
||||
admins;
|
||||
|
||||
adminExtraKeys = lib.concatMap
|
||||
({ extraKeys ? [], ... }: extraKeys)
|
||||
admins;
|
||||
in
|
||||
{
|
||||
users.mutableUsers = false;
|
||||
|
||||
users.users = builtins.listToAttrs (map mkAdmin admins);
|
||||
|
||||
# Root gets all admin keys so deploy-rs can SSH in
|
||||
users.users.root.openssh.authorizedKeys = {
|
||||
keyFiles = adminKeyFiles;
|
||||
keys = adminExtraKeys;
|
||||
};
|
||||
|
||||
security.sudo.wheelNeedsPassword = false;
|
||||
}
|
||||
291
modules/wiki-primary/alerting.nix
Normal file
291
modules/wiki-primary/alerting.nix
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
{
|
||||
services.prometheus = {
|
||||
alertmanagers = [{
|
||||
static_configs = [{
|
||||
targets = [ "localhost:9093" ];
|
||||
}];
|
||||
}];
|
||||
|
||||
rules = [
|
||||
(builtins.toJSON {
|
||||
groups = [
|
||||
{
|
||||
name = "wiki-availability";
|
||||
rules = [
|
||||
{
|
||||
alert = "WikiDown";
|
||||
expr = ''probe_success{job="blackbox-http",instance=~".*www.noisebridge.net.*"} == 0'';
|
||||
"for" = "2m";
|
||||
labels.severity = "critical";
|
||||
annotations = {
|
||||
summary = "Primary wiki is unreachable";
|
||||
description = "{{ $labels.instance }} has been down for more than 2 minutes.";
|
||||
};
|
||||
}
|
||||
{
|
||||
alert = "ReplicaDown";
|
||||
expr = ''probe_success{job="blackbox-http",instance=~".*readonly.noisebridge.net.*"} == 0'';
|
||||
"for" = "5m";
|
||||
labels.severity = "warning";
|
||||
annotations = {
|
||||
summary = "Replica wiki is unreachable";
|
||||
description = "{{ $labels.instance }} has been down for more than 5 minutes.";
|
||||
};
|
||||
}
|
||||
{
|
||||
alert = "HighErrorRate";
|
||||
expr = ''sum(rate(caddy_http_responses_total{code=~"5.."}[5m])) by (instance) / sum(rate(caddy_http_responses_total[5m])) by (instance) > 0.05'';
|
||||
"for" = "5m";
|
||||
labels.severity = "critical";
|
||||
annotations = {
|
||||
summary = "High HTTP 5xx error rate on {{ $labels.instance }}";
|
||||
description = "More than 5% of requests are returning server errors.";
|
||||
};
|
||||
}
|
||||
{
|
||||
alert = "HighLatency";
|
||||
expr = ''histogram_quantile(0.95, sum(rate(caddy_http_request_duration_seconds_bucket[5m])) by (le, instance)) > 2'';
|
||||
"for" = "5m";
|
||||
labels.severity = "warning";
|
||||
annotations = {
|
||||
summary = "High p95 latency on {{ $labels.instance }}";
|
||||
description = "95th percentile response time is {{ $value | humanizeDuration }}.";
|
||||
};
|
||||
}
|
||||
{
|
||||
alert = "TLSCertExpiringSoon";
|
||||
expr = ''probe_ssl_earliest_cert_expiry{job="blackbox-http"} - time() < 7 * 86400'';
|
||||
"for" = "1h";
|
||||
labels.severity = "warning";
|
||||
annotations = {
|
||||
summary = "TLS certificate expiring within 7 days";
|
||||
description = "Certificate for {{ $labels.instance }} expires in {{ $value | humanizeDuration }}.";
|
||||
};
|
||||
}
|
||||
];
|
||||
}
|
||||
{
|
||||
name = "wiki-infrastructure";
|
||||
rules = [
|
||||
{
|
||||
alert = "DiskFull";
|
||||
expr = ''(node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) < 0.15'';
|
||||
"for" = "5m";
|
||||
labels.severity = "warning";
|
||||
annotations = {
|
||||
summary = "Disk usage above 85% on {{ $labels.instance }}";
|
||||
description = "Root filesystem is {{ $value | humanizePercentage }} free.";
|
||||
};
|
||||
}
|
||||
{
|
||||
alert = "DiskCritical";
|
||||
expr = ''(node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) < 0.05'';
|
||||
"for" = "2m";
|
||||
labels.severity = "critical";
|
||||
annotations = {
|
||||
summary = "Disk almost full on {{ $labels.instance }}";
|
||||
description = "Root filesystem is {{ $value | humanizePercentage }} free. Immediate action required.";
|
||||
};
|
||||
}
|
||||
{
|
||||
alert = "HighMemoryUsage";
|
||||
expr = ''(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.9'';
|
||||
"for" = "5m";
|
||||
labels.severity = "warning";
|
||||
annotations = {
|
||||
summary = "Memory usage above 90% on {{ $labels.instance }}";
|
||||
description = "Available memory is {{ $value | humanizePercentage }} of total.";
|
||||
};
|
||||
}
|
||||
{
|
||||
alert = "HighCPU";
|
||||
expr = ''1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) > 0.85'';
|
||||
"for" = "10m";
|
||||
labels.severity = "warning";
|
||||
annotations = {
|
||||
summary = "Sustained high CPU on {{ $labels.instance }}";
|
||||
description = "CPU usage has been above 85% for 10 minutes.";
|
||||
};
|
||||
}
|
||||
{
|
||||
alert = "SystemdUnitFailed";
|
||||
expr = ''node_systemd_unit_state{state="failed"} == 1'';
|
||||
"for" = "5m";
|
||||
labels.severity = "warning";
|
||||
annotations = {
|
||||
summary = "Systemd unit failed on {{ $labels.instance }}";
|
||||
description = "Unit {{ $labels.name }} is in failed state.";
|
||||
};
|
||||
}
|
||||
];
|
||||
}
|
||||
{
|
||||
name = "wiki-database";
|
||||
rules = [
|
||||
{
|
||||
alert = "ReplicationBroken";
|
||||
expr = ''mysql_slave_status_slave_io_running{instance="wiki-replica"} == 0 or mysql_slave_status_slave_sql_running{instance="wiki-replica"} == 0'';
|
||||
"for" = "2m";
|
||||
labels.severity = "critical";
|
||||
annotations = {
|
||||
summary = "MySQL replication thread stopped";
|
||||
description = "Replication IO or SQL thread is not running on the replica.";
|
||||
};
|
||||
}
|
||||
{
|
||||
alert = "ReplicationLagging";
|
||||
expr = ''mysql_slave_status_seconds_behind_master{instance="wiki-replica"} > 300'';
|
||||
"for" = "5m";
|
||||
labels.severity = "warning";
|
||||
annotations = {
|
||||
summary = "MySQL replication lagging";
|
||||
description = "Replica is {{ $value }}s behind the primary.";
|
||||
};
|
||||
}
|
||||
{
|
||||
alert = "MySQLConnectionsExhausted";
|
||||
expr = ''mysql_global_status_threads_connected / mysql_global_variables_max_connections > 0.8'';
|
||||
"for" = "5m";
|
||||
labels.severity = "warning";
|
||||
annotations = {
|
||||
summary = "MySQL connections above 80% on {{ $labels.instance }}";
|
||||
description = "{{ $value | humanizePercentage }} of max connections in use.";
|
||||
};
|
||||
}
|
||||
{
|
||||
alert = "MySQLSlowQueries";
|
||||
expr = ''rate(mysql_global_status_slow_queries[5m]) > 0.1'';
|
||||
"for" = "10m";
|
||||
labels.severity = "warning";
|
||||
annotations = {
|
||||
summary = "Elevated slow queries on {{ $labels.instance }}";
|
||||
description = "{{ $value }} slow queries per second over the last 5 minutes.";
|
||||
};
|
||||
}
|
||||
];
|
||||
}
|
||||
{
|
||||
name = "wiki-application";
|
||||
rules = [
|
||||
{
|
||||
alert = "PHPFPMExhausted";
|
||||
expr = ''phpfpm_active_processes >= phpfpm_total_processes'';
|
||||
"for" = "1m";
|
||||
labels.severity = "warning";
|
||||
annotations = {
|
||||
summary = "PHP-FPM workers exhausted";
|
||||
description = "All PHP-FPM workers are active — requests may be queuing.";
|
||||
};
|
||||
}
|
||||
{
|
||||
alert = "PHPFPMDown";
|
||||
expr = ''up{job="phpfpm"} == 0'';
|
||||
"for" = "1m";
|
||||
labels.severity = "critical";
|
||||
annotations = {
|
||||
summary = "PHP-FPM exporter is down";
|
||||
description = "Cannot scrape PHP-FPM metrics — the PHP-FPM process may be dead.";
|
||||
};
|
||||
}
|
||||
{
|
||||
alert = "MemcachedDown";
|
||||
expr = ''up{job=~"memcached.*"} == 0'';
|
||||
"for" = "2m";
|
||||
labels.severity = "critical";
|
||||
annotations = {
|
||||
summary = "Memcached is down on {{ $labels.instance }}";
|
||||
description = "The memcached exporter is unreachable. MediaWiki will fall back to database queries and be slow.";
|
||||
};
|
||||
}
|
||||
{
|
||||
alert = "MemcachedEvictions";
|
||||
expr = ''rate(memcached_items_evicted_total[5m]) > 10'';
|
||||
"for" = "10m";
|
||||
labels.severity = "warning";
|
||||
annotations = {
|
||||
summary = "High memcached eviction rate on {{ $labels.instance }}";
|
||||
description = "{{ $value }} evictions/sec — cache is too small, consider increasing maxMemory.";
|
||||
};
|
||||
}
|
||||
{
|
||||
alert = "MemcachedHitRateLow";
|
||||
expr = ''rate(memcached_commands_total{command="get",status="hit"}[5m]) / rate(memcached_commands_total{command="get"}[5m]) < 0.8'';
|
||||
"for" = "15m";
|
||||
labels.severity = "warning";
|
||||
annotations = {
|
||||
summary = "Low memcached hit rate on {{ $labels.instance }}";
|
||||
description = "Cache hit rate is {{ $value | humanizePercentage }}. Pages may be slow.";
|
||||
};
|
||||
}
|
||||
];
|
||||
}
|
||||
{
|
||||
name = "wiki-backups";
|
||||
rules = [
|
||||
{
|
||||
alert = "BackupStale";
|
||||
expr = ''(time() - backup_latest_timestamp_seconds) > 86400'';
|
||||
"for" = "1h";
|
||||
labels.severity = "warning";
|
||||
annotations = {
|
||||
summary = "Wiki backup is stale";
|
||||
description = "Last successful backup was more than 24 hours ago.";
|
||||
};
|
||||
}
|
||||
{
|
||||
alert = "BackupFailed";
|
||||
expr = ''backup_b2_sync_success != 1'';
|
||||
"for" = "10m";
|
||||
labels.severity = "critical";
|
||||
annotations = {
|
||||
summary = "B2 backup sync failed";
|
||||
description = "The last rclone sync to Backblaze B2 did not succeed.";
|
||||
};
|
||||
}
|
||||
{
|
||||
alert = "ImageSyncStale";
|
||||
expr = ''(time() - imagesync_latest_timestamp_seconds) > 7200'';
|
||||
"for" = "30m";
|
||||
labels.severity = "warning";
|
||||
annotations = {
|
||||
summary = "Image sync to replica is stale";
|
||||
description = "Last successful image sync was more than 2 hours ago. Replica may have broken image links.";
|
||||
};
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
})
|
||||
];
|
||||
};
|
||||
|
||||
services.prometheus.alertmanager = {
|
||||
enable = true;
|
||||
port = 9093;
|
||||
listenAddress = "127.0.0.1";
|
||||
configuration = {
|
||||
route = {
|
||||
receiver = "discord";
|
||||
group_by = [ "alertname" "instance" ];
|
||||
group_wait = "30s";
|
||||
group_interval = "5m";
|
||||
repeat_interval = "4h";
|
||||
};
|
||||
receivers = [
|
||||
{
|
||||
name = "discord";
|
||||
webhook_configs = [{
|
||||
url_file = config.age.secrets.discord-webhook.path;
|
||||
}];
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
age.secrets.discord-webhook = {
|
||||
file = ../../secrets/discord-webhook.age;
|
||||
owner = "alertmanager";
|
||||
group = "alertmanager";
|
||||
};
|
||||
}
|
||||
97
modules/wiki-primary/backup.nix
Normal file
97
modules/wiki-primary/backup.nix
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
backupScript = pkgs.writeShellScript "wiki-backup" ''
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR="/var/backups/mysql"
|
||||
TEXTFILE_DIR="/var/lib/prometheus-node-exporter/textfile"
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
|
||||
# Cleanup old local backups (keep 7 days)
|
||||
find "$BACKUP_DIR" -maxdepth 1 -type d -mtime +7 -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# Dump database with mydumper
|
||||
echo "Starting database dump..."
|
||||
${pkgs.mydumper}/bin/mydumper \
|
||||
--database noisebridge_mediawiki \
|
||||
--outputdir "$BACKUP_DIR/$TIMESTAMP" \
|
||||
--threads 2 \
|
||||
--compress \
|
||||
--routines \
|
||||
--triggers \
|
||||
--events \
|
||||
--logfile "$BACKUP_DIR/mydumper.log"
|
||||
|
||||
# Sync to Backblaze B2
|
||||
echo "Syncing to Backblaze B2..."
|
||||
export RCLONE_CONFIG_B2_TYPE=b2
|
||||
export RCLONE_CONFIG_B2_ACCOUNT=$(cat ${config.age.secrets.b2-credentials.path} | ${pkgs.jq}/bin/jq -r .keyID)
|
||||
export RCLONE_CONFIG_B2_KEY=$(cat ${config.age.secrets.b2-credentials.path} | ${pkgs.jq}/bin/jq -r .applicationKey)
|
||||
|
||||
SYNC_SUCCESS=0
|
||||
${pkgs.rclone}/bin/rclone sync "$BACKUP_DIR" b2:noisebridge-wiki-backup/mysql/ \
|
||||
--transfers 4 \
|
||||
--checkers 8 \
|
||||
--b2-hard-delete \
|
||||
&& SYNC_SUCCESS=1
|
||||
|
||||
# Sync uploaded images to B2
|
||||
${pkgs.rclone}/bin/rclone sync /var/lib/mediawiki/images/ b2:noisebridge-wiki-backup/images/ \
|
||||
--transfers 4 \
|
||||
--checkers 8 \
|
||||
|| SYNC_SUCCESS=0
|
||||
|
||||
# Back up Tor hidden service keys (losing these = losing the .onion address)
|
||||
${pkgs.rclone}/bin/rclone sync /var/lib/tor/onion/ b2:noisebridge-wiki-backup/tor-keys/ \
|
||||
--transfers 1 \
|
||||
|| true
|
||||
|
||||
# Write metrics for Prometheus textfile collector (no leading whitespace!)
|
||||
cat > "$TEXTFILE_DIR/backup.prom" <<'METRICS'
|
||||
# HELP backup_latest_timestamp_seconds Unix timestamp of latest backup
|
||||
# TYPE backup_latest_timestamp_seconds gauge
|
||||
METRICS
|
||||
echo "backup_latest_timestamp_seconds $(date +%s)" >> "$TEXTFILE_DIR/backup.prom"
|
||||
cat >> "$TEXTFILE_DIR/backup.prom" <<METRICS
|
||||
# HELP backup_b2_sync_success Whether the last B2 sync succeeded (1=success, 0=failure)
|
||||
# TYPE backup_b2_sync_success gauge
|
||||
backup_b2_sync_success $SYNC_SUCCESS
|
||||
METRICS
|
||||
|
||||
echo "Backup complete."
|
||||
'';
|
||||
in
|
||||
{
|
||||
systemd.services.wiki-backup = {
|
||||
description = "Wiki database and image backup to Backblaze B2";
|
||||
after = [ "mysql.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = backupScript;
|
||||
User = "root";
|
||||
IOSchedulingClass = "idle";
|
||||
CPUSchedulingPolicy = "idle";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.timers.wiki-backup = {
|
||||
description = "Daily wiki backup timer";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnCalendar = "*-*-* 04:00:00";
|
||||
Persistent = true;
|
||||
RandomizedDelaySec = "15m";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/backups/mysql 0750 root root -"
|
||||
];
|
||||
|
||||
age.secrets.b2-credentials = {
|
||||
file = ../../secrets/b2-credentials.age;
|
||||
owner = "root";
|
||||
group = "root";
|
||||
mode = "0400";
|
||||
};
|
||||
}
|
||||
162
modules/wiki-primary/caddy.nix
Normal file
162
modules/wiki-primary/caddy.nix
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
{
|
||||
services.caddy = {
|
||||
enable = true;
|
||||
package = pkgs.caddy-custom;
|
||||
|
||||
globalConfig = ''
|
||||
order rate_limit before basicauth
|
||||
servers {
|
||||
# Trust Cloudflare's edge IPs so {client_ip} resolves to the real visitor
|
||||
trusted_proxies static 173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 141.101.64.0/18 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22 2400:cb00::/32 2606:4700::/32 2803:f800::/32 2405:b500::/32 2405:8100::/32 2a06:98c0::/29 2c0f:f248::/32
|
||||
metrics
|
||||
}
|
||||
'';
|
||||
|
||||
virtualHosts = {
|
||||
"www.noisebridge.net" = {
|
||||
extraConfig = ''
|
||||
# Health check endpoint
|
||||
handle /health {
|
||||
respond "ok" 200
|
||||
}
|
||||
|
||||
# Bot blocking
|
||||
@bots header_regexp User-Agent "(?i)(ClaudeBot|GPTBot|CCBot|Bytespider|AhrefsBot|SemrushBot|MJ12bot|DotBot|PetalBot|Amazonbot|anthropic-ai|ChatGPT-User|cohere-ai|FacebookBot|Google-Extended|PerplexityBot)"
|
||||
respond @bots 403
|
||||
|
||||
# robots.txt
|
||||
handle /robots.txt {
|
||||
respond "User-agent: ClaudeBot
|
||||
Disallow: /
|
||||
User-agent: GPTBot
|
||||
Disallow: /
|
||||
User-agent: CCBot
|
||||
Disallow: /
|
||||
User-agent: Bytespider
|
||||
Disallow: /
|
||||
User-agent: anthropic-ai
|
||||
Disallow: /
|
||||
User-agent: ChatGPT-User
|
||||
Disallow: /
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Sitemap: https://www.noisebridge.net/sitemap.xml
|
||||
"
|
||||
}
|
||||
|
||||
# Rate limiting for anonymous users (no session cookie)
|
||||
# {client_ip} works with or without a reverse proxy in front
|
||||
@anon {
|
||||
not header_regexp Cookie "nb_wiki_session="
|
||||
}
|
||||
rate_limit @anon {
|
||||
zone anon_zone {
|
||||
key {client_ip}
|
||||
events 60
|
||||
window 1m
|
||||
}
|
||||
}
|
||||
|
||||
# Cache headers: anon gets public caching, logged-in gets private
|
||||
@logged_in {
|
||||
header_regexp Cookie "nb_wiki_session="
|
||||
}
|
||||
header @anon Cache-Control "public, max-age=7200"
|
||||
header @logged_in Cache-Control "private, no-cache"
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
}
|
||||
|
||||
# Proxy to PHP-FPM
|
||||
php_fastcgi unix//run/phpfpm/mediawiki.sock {
|
||||
root ${config.services.mediawiki.finalPackage}/share/mediawiki
|
||||
}
|
||||
|
||||
file_server {
|
||||
root ${config.services.mediawiki.finalPackage}/share/mediawiki
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
"grafana.noisebridge.net" = {
|
||||
extraConfig = ''
|
||||
reverse_proxy localhost:3000
|
||||
'';
|
||||
};
|
||||
|
||||
# Domain redirects
|
||||
"noisebridge.net" = {
|
||||
extraConfig = ''
|
||||
redir https://www.noisebridge.net{uri} permanent
|
||||
'';
|
||||
};
|
||||
|
||||
"noisebridge.com" = {
|
||||
extraConfig = ''
|
||||
redir https://www.noisebridge.net{uri} permanent
|
||||
'';
|
||||
};
|
||||
|
||||
"noisebridge.org" = {
|
||||
extraConfig = ''
|
||||
redir https://www.noisebridge.net{uri} permanent
|
||||
'';
|
||||
};
|
||||
|
||||
"noisebridge.io" = {
|
||||
extraConfig = ''
|
||||
redir https://www.noisebridge.net{uri} permanent
|
||||
'';
|
||||
};
|
||||
|
||||
# ── Tor .onion vhost ──
|
||||
# Tor daemon forwards port 80 → localhost:8080. Caddy listens here
|
||||
# with HTTP only (no TLS — .onion v3 is already end-to-end encrypted).
|
||||
#
|
||||
# Differences from the clearnet vhost:
|
||||
# - No IP-based rate limiting (all Tor traffic arrives from 127.0.0.1)
|
||||
# - No HSTS (no TLS to enforce)
|
||||
# - No Cache-Control: public (no CDN to cache at)
|
||||
# - Bot blocking by User-Agent still works
|
||||
":8080" = {
|
||||
extraConfig = ''
|
||||
# Bot blocking (same list as clearnet)
|
||||
@bots header_regexp User-Agent "(?i)(ClaudeBot|GPTBot|CCBot|Bytespider|AhrefsBot|SemrushBot|MJ12bot|DotBot|PetalBot|Amazonbot|anthropic-ai|ChatGPT-User|cohere-ai|FacebookBot|Google-Extended|PerplexityBot)"
|
||||
respond @bots 403
|
||||
|
||||
# robots.txt — block everything on .onion (no reason for bots to index)
|
||||
handle /robots.txt {
|
||||
respond "User-agent: *
|
||||
Disallow: /
|
||||
"
|
||||
}
|
||||
|
||||
# Security headers (no HSTS — no TLS over .onion)
|
||||
header {
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
Referrer-Policy "no-referrer"
|
||||
X-Wiki-Access "tor"
|
||||
}
|
||||
|
||||
php_fastcgi unix//run/phpfpm/mediawiki.sock {
|
||||
root ${config.services.mediawiki.finalPackage}/share/mediawiki
|
||||
}
|
||||
|
||||
file_server {
|
||||
root ${config.services.mediawiki.finalPackage}/share/mediawiki
|
||||
}
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Port 8080 is only for local Tor daemon — not public
|
||||
# (firewall already blocks it since it's not in allowedTCPPorts)
|
||||
}
|
||||
41
modules/wiki-primary/grafana.nix
Normal file
41
modules/wiki-primary/grafana.nix
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
{
|
||||
services.grafana = {
|
||||
enable = true;
|
||||
settings = {
|
||||
server = {
|
||||
http_addr = "127.0.0.1";
|
||||
http_port = 3000;
|
||||
domain = "grafana.noisebridge.net";
|
||||
root_url = "https://grafana.noisebridge.net";
|
||||
};
|
||||
security = {
|
||||
admin_user = "admin";
|
||||
admin_password = "$__file{${config.age.secrets.grafana-admin.path}}";
|
||||
disable_gravatar = true;
|
||||
};
|
||||
analytics.reporting_enabled = false;
|
||||
"auth.anonymous".enabled = false;
|
||||
users.allow_sign_up = false;
|
||||
};
|
||||
|
||||
provision = {
|
||||
enable = true;
|
||||
datasources.settings.datasources = [
|
||||
{
|
||||
name = "Prometheus";
|
||||
type = "prometheus";
|
||||
url = "http://127.0.0.1:9090";
|
||||
isDefault = true;
|
||||
editable = false;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
age.secrets.grafana-admin = {
|
||||
file = ../../secrets/grafana-admin.age;
|
||||
owner = "grafana";
|
||||
group = "grafana";
|
||||
};
|
||||
}
|
||||
123
modules/wiki-primary/mediawiki.nix
Normal file
123
modules/wiki-primary/mediawiki.nix
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
{
|
||||
services.mediawiki.extraConfig = lib.mkAfter ''
|
||||
# ----- Invite-only accounts -----
|
||||
$wgGroupPermissions['*']['createaccount'] = false;
|
||||
$wgGroupPermissions['bureaucrat']['createaccount'] = true;
|
||||
|
||||
# ----- File cache for anonymous readers -----
|
||||
$wgUseFileCache = true;
|
||||
$wgFileCacheDirectory = "/var/cache/mediawiki";
|
||||
$wgShowIPinHeader = false;
|
||||
|
||||
# ----- Rate limit exemption for logged-in users -----
|
||||
$wgGroupPermissions['user']['noratelimit'] = true;
|
||||
|
||||
# ----- Email -----
|
||||
$wgEnableEmail = true;
|
||||
$wgEnableUserEmail = true;
|
||||
$wgEmergencyContact = "wiki@noisebridge.net";
|
||||
$wgPasswordSender = "wiki@noisebridge.net";
|
||||
# Mail sent via local Postfix, which relays through m3.noisebridge.net
|
||||
|
||||
# ----- ReCaptcha (login brute-force only) -----
|
||||
wfLoadExtension( 'ConfirmEdit/ReCaptchaNoCaptcha' );
|
||||
$wgReCaptchaSiteKey = '6Le_REPLACE_SITE_KEY';
|
||||
$wgReCaptchaSecretKey = trim(file_get_contents('${config.age.secrets.mediawiki-recaptcha.path}'));
|
||||
$wgCaptchaTriggers['badlogin'] = true;
|
||||
$wgCaptchaTriggers['createaccount'] = false;
|
||||
$wgCaptchaTriggers['edit'] = false;
|
||||
$wgCaptchaTriggers['create'] = false;
|
||||
'';
|
||||
|
||||
# PHP-FPM: static pool for maximum performance
|
||||
# Use individual mkForce to override defaults without clobbering
|
||||
# required settings (listen, user, group) set by the mediawiki module
|
||||
services.phpfpm.pools.mediawiki.settings = {
|
||||
"pm" = lib.mkForce "static";
|
||||
"pm.max_children" = lib.mkForce 30;
|
||||
"pm.max_requests" = lib.mkForce 500;
|
||||
"request_terminate_timeout" = lib.mkForce "30s";
|
||||
"catch_workers_output" = lib.mkForce true;
|
||||
"pm.status_path" = "/fpm-status";
|
||||
|
||||
# OPcache
|
||||
"php_admin_value[opcache.enable]" = 1;
|
||||
"php_admin_value[opcache.memory_consumption]" = 256;
|
||||
"php_admin_value[opcache.max_accelerated_files]" = 10000;
|
||||
"php_admin_value[opcache.revalidate_freq]" = 60;
|
||||
"php_admin_value[opcache.jit]" = 1255;
|
||||
"php_admin_value[opcache.jit_buffer_size]" = "64M";
|
||||
|
||||
# Memory & execution
|
||||
"php_admin_value[memory_limit]" = "256M";
|
||||
"php_admin_value[max_execution_time]" = 30;
|
||||
"php_admin_value[upload_max_filesize]" = "10M";
|
||||
"php_admin_value[post_max_size]" = "12M";
|
||||
};
|
||||
|
||||
# File cache directory
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/cache/mediawiki 0755 mediawiki mediawiki -"
|
||||
];
|
||||
|
||||
# MediaWiki job runner (since wgJobRunRate=0)
|
||||
systemd.services.mediawiki-jobrunner = {
|
||||
description = "MediaWiki job runner";
|
||||
after = [ "mysql.service" "phpfpm-mediawiki.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = "mediawiki";
|
||||
Group = "mediawiki";
|
||||
ExecStart = "${pkgs.php}/bin/php ${config.services.mediawiki.finalPackage}/share/mediawiki/maintenance/runJobs.php --wait --maxjobs=10";
|
||||
Restart = "always";
|
||||
RestartSec = "30s";
|
||||
};
|
||||
};
|
||||
|
||||
# Sync uploaded images to replica over Tailscale (hourly)
|
||||
# Writes textfile metrics so Prometheus can alert on stale syncs
|
||||
systemd.services.wiki-image-sync = {
|
||||
description = "Sync wiki images to replica";
|
||||
after = [ "tailscale-autoconnect.service" ];
|
||||
path = [ pkgs.rsync ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = "root";
|
||||
};
|
||||
script = ''
|
||||
TEXTFILE_DIR="/var/lib/prometheus-node-exporter/textfile"
|
||||
|
||||
if rsync -az --delete /var/lib/mediawiki/images/ wiki-replica:/var/lib/mediawiki/images/; then
|
||||
SYNC_OK=1
|
||||
else
|
||||
SYNC_OK=0
|
||||
fi
|
||||
|
||||
cat > "$TEXTFILE_DIR/imagesync.prom" <<EOF
|
||||
# HELP imagesync_latest_timestamp_seconds Unix timestamp of latest image sync attempt
|
||||
# TYPE imagesync_latest_timestamp_seconds gauge
|
||||
imagesync_latest_timestamp_seconds $(date +%s)
|
||||
# HELP imagesync_success Whether the last image sync succeeded (1=success, 0=failure)
|
||||
# TYPE imagesync_success gauge
|
||||
imagesync_success $SYNC_OK
|
||||
EOF
|
||||
'';
|
||||
};
|
||||
|
||||
systemd.timers.wiki-image-sync = {
|
||||
description = "Hourly wiki image sync to replica";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnCalendar = "hourly";
|
||||
Persistent = true;
|
||||
};
|
||||
};
|
||||
|
||||
age.secrets.mediawiki-recaptcha = {
|
||||
file = ../../secrets/mediawiki-recaptcha.age;
|
||||
owner = "mediawiki";
|
||||
group = "mediawiki";
|
||||
};
|
||||
}
|
||||
92
modules/wiki-primary/mysql.nix
Normal file
92
modules/wiki-primary/mysql.nix
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
{
|
||||
services.mysql = {
|
||||
enable = true;
|
||||
package = pkgs.mariadb;
|
||||
dataDir = "/var/lib/mysql";
|
||||
|
||||
settings.mysqld = {
|
||||
bind-address = "0.0.0.0";
|
||||
|
||||
# InnoDB
|
||||
innodb_buffer_pool_size = "512M";
|
||||
innodb_log_file_size = "128M";
|
||||
innodb_flush_log_at_trx_commit = 1;
|
||||
innodb_file_per_table = 1;
|
||||
|
||||
# GTID replication (required for MASTER_AUTO_POSITION)
|
||||
server-id = 1;
|
||||
log_bin = "mysql-bin";
|
||||
binlog_format = "ROW";
|
||||
sync_binlog = 1;
|
||||
expire_logs_days = 7;
|
||||
binlog_do_db = "noisebridge_mediawiki";
|
||||
gtid_strict_mode = 1;
|
||||
|
||||
# Performance
|
||||
max_connections = 100;
|
||||
tmp_table_size = "64M";
|
||||
max_heap_table_size = "64M";
|
||||
table_open_cache = 400;
|
||||
sort_buffer_size = "2M";
|
||||
read_buffer_size = "2M";
|
||||
|
||||
# Character set
|
||||
character-set-server = "binary";
|
||||
collation-server = "binary";
|
||||
};
|
||||
|
||||
ensureDatabases = [ "noisebridge_mediawiki" ];
|
||||
ensureUsers = [
|
||||
{
|
||||
name = "wiki";
|
||||
ensurePermissions = {
|
||||
"noisebridge_mediawiki.*" = "ALL PRIVILEGES";
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "repl";
|
||||
ensurePermissions = {
|
||||
"*.*" = "REPLICATION SLAVE, REPLICATION CLIENT";
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "mysqld_exporter";
|
||||
ensurePermissions = {
|
||||
"*.*" = "PROCESS, REPLICATION CLIENT, SELECT";
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
# Set repl user password (ensureUsers creates with no password / socket auth,
|
||||
# but the replica connects over TCP and needs a password)
|
||||
systemd.services.mysql-repl-password = {
|
||||
description = "Set MySQL replication user password";
|
||||
after = [ "mysql.service" ];
|
||||
requires = [ "mysql.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
script = ''
|
||||
REPL_PASS=$(cat ${config.age.secrets.mysql-replication.path})
|
||||
${pkgs.mariadb}/bin/mysql -u root -e \
|
||||
"ALTER USER 'repl'@'%' IDENTIFIED BY '$REPL_PASS';"
|
||||
'';
|
||||
};
|
||||
|
||||
# mysqld exporter for Prometheus
|
||||
services.prometheus.exporters.mysqld = {
|
||||
enable = true;
|
||||
port = 9104;
|
||||
runAsLocalSuperUser = true;
|
||||
};
|
||||
|
||||
age.secrets.mysql-replication = {
|
||||
file = ../../secrets/mysql-replication.age;
|
||||
owner = "mysql";
|
||||
group = "mysql";
|
||||
};
|
||||
}
|
||||
15
modules/wiki-primary/postfix.nix
Normal file
15
modules/wiki-primary/postfix.nix
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
{
|
||||
services.postfix = {
|
||||
enable = true;
|
||||
hostname = "wiki.noisebridge.net";
|
||||
origin = "noisebridge.net";
|
||||
relayHost = "m3"; # Tailscale hostname for existing Noisebridge mail server
|
||||
destination = []; # Don't accept mail for local delivery
|
||||
networks = [ "127.0.0.0/8" "[::1]/128" ];
|
||||
config = {
|
||||
inet_interfaces = "loopback-only";
|
||||
smtp_tls_security_level = "may";
|
||||
};
|
||||
};
|
||||
}
|
||||
246
modules/wiki-primary/prometheus.nix
Normal file
246
modules/wiki-primary/prometheus.nix
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
# PHP-FPM exporter wrapper to handle the semicolon-in-URI escaping issue
|
||||
phpfpmExporterScript = pkgs.writeShellScript "phpfpm-exporter-wrapper" ''
|
||||
exec ${pkgs.prometheus-php-fpm-exporter}/bin/php-fpm-exporter server \
|
||||
--phpfpm.scrape-uri 'unix:///run/phpfpm/mediawiki.sock;/fpm-status' \
|
||||
--web.listen-address ':9253'
|
||||
'';
|
||||
in
|
||||
{
|
||||
services.prometheus = {
|
||||
enable = true;
|
||||
port = 9090;
|
||||
listenAddress = "127.0.0.1";
|
||||
|
||||
retentionTime = "90d";
|
||||
extraFlags = [
|
||||
"--storage.tsdb.max-block-duration=2h"
|
||||
"--storage.tsdb.retention.size=5GB"
|
||||
];
|
||||
|
||||
globalConfig = {
|
||||
scrape_interval = "15s";
|
||||
evaluation_interval = "15s";
|
||||
};
|
||||
|
||||
scrapeConfigs = [
|
||||
# ── Primary system metrics ──
|
||||
{
|
||||
job_name = "node";
|
||||
static_configs = [{
|
||||
targets = [ "localhost:9100" ];
|
||||
labels = { instance = "wiki"; };
|
||||
}];
|
||||
}
|
||||
|
||||
# ── Replica system metrics (over Tailscale) ──
|
||||
{
|
||||
job_name = "node-replica";
|
||||
static_configs = [{
|
||||
targets = [ "wiki-replica:9100" ];
|
||||
labels = { instance = "wiki-replica"; };
|
||||
}];
|
||||
}
|
||||
|
||||
# ── Primary MariaDB ──
|
||||
# Queries/s, connections, buffer pool hit ratio, slow queries,
|
||||
# binlog position, table locks, InnoDB row operations
|
||||
{
|
||||
job_name = "mysqld";
|
||||
static_configs = [{
|
||||
targets = [ "localhost:9104" ];
|
||||
labels = { instance = "wiki"; };
|
||||
}];
|
||||
}
|
||||
|
||||
# ── Replica MariaDB (over Tailscale) ──
|
||||
# Replication lag (Seconds_Behind_Master), IO/SQL thread status,
|
||||
# relay log position, read-only query volume
|
||||
{
|
||||
job_name = "mysqld-replica";
|
||||
static_configs = [{
|
||||
targets = [ "wiki-replica:9104" ];
|
||||
labels = { instance = "wiki-replica"; };
|
||||
}];
|
||||
}
|
||||
|
||||
# ── Primary Caddy ──
|
||||
# Requests/s by status code (2xx, 3xx, 4xx, 5xx), response latency
|
||||
# histograms, active connections, bytes in/out
|
||||
{
|
||||
job_name = "caddy";
|
||||
static_configs = [{
|
||||
targets = [ "localhost:2019" ];
|
||||
labels = { instance = "wiki"; };
|
||||
}];
|
||||
}
|
||||
|
||||
# ── Replica Caddy (over Tailscale) ──
|
||||
{
|
||||
job_name = "caddy-replica";
|
||||
static_configs = [{
|
||||
targets = [ "wiki-replica:2019" ];
|
||||
labels = { instance = "wiki-replica"; };
|
||||
}];
|
||||
}
|
||||
|
||||
# ── Primary PHP-FPM ──
|
||||
# Active/idle/total workers, accepted connections, request duration,
|
||||
# slow requests, max_children reached count
|
||||
{
|
||||
job_name = "phpfpm";
|
||||
static_configs = [{
|
||||
targets = [ "localhost:9253" ];
|
||||
labels = { instance = "wiki"; };
|
||||
}];
|
||||
}
|
||||
|
||||
# ── Primary memcached ──
|
||||
# Hit rate, miss rate, evictions, current items, bytes used/limit,
|
||||
# connections, get/set/delete rates
|
||||
{
|
||||
job_name = "memcached";
|
||||
static_configs = [{
|
||||
targets = [ "localhost:9150" ];
|
||||
labels = { instance = "wiki"; };
|
||||
}];
|
||||
}
|
||||
|
||||
# ── Replica memcached (over Tailscale) ──
|
||||
{
|
||||
job_name = "memcached-replica";
|
||||
static_configs = [{
|
||||
targets = [ "wiki-replica:9150" ];
|
||||
labels = { instance = "wiki-replica"; };
|
||||
}];
|
||||
}
|
||||
|
||||
# ── Blackbox HTTP probes ──
|
||||
# End-to-end: DNS resolution time, TCP connect, TLS handshake,
|
||||
# HTTP response time, status code, TLS cert expiry
|
||||
{
|
||||
job_name = "blackbox-http";
|
||||
metrics_path = "/probe";
|
||||
params = { module = [ "http_2xx" ]; };
|
||||
static_configs = [{
|
||||
targets = [
|
||||
# Primary wiki
|
||||
"https://www.noisebridge.net"
|
||||
"https://www.noisebridge.net/wiki/Main_Page"
|
||||
"https://www.noisebridge.net/health"
|
||||
# Replica wiki
|
||||
"https://readonly.noisebridge.net"
|
||||
"https://readonly.noisebridge.net/wiki/Main_Page"
|
||||
"https://readonly.noisebridge.net/health"
|
||||
# Grafana
|
||||
"https://grafana.noisebridge.net"
|
||||
];
|
||||
}];
|
||||
relabel_configs = [
|
||||
{
|
||||
source_labels = [ "__address__" ];
|
||||
target_label = "__param_target";
|
||||
}
|
||||
{
|
||||
source_labels = [ "__param_target" ];
|
||||
target_label = "instance";
|
||||
}
|
||||
{
|
||||
target_label = "__address__";
|
||||
replacement = "localhost:9115";
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
# ── Grafana internal metrics ──
|
||||
{
|
||||
job_name = "grafana";
|
||||
static_configs = [{
|
||||
targets = [ "localhost:3000" ];
|
||||
}];
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
# ── Node exporter ──
|
||||
# System-level: CPU, RAM, disk I/O, filesystem usage, network traffic,
|
||||
# systemd unit states, plus custom textfile metrics from the backup script
|
||||
services.prometheus.exporters.node = {
|
||||
enable = true;
|
||||
port = 9100;
|
||||
enabledCollectors = [
|
||||
"cpu"
|
||||
"diskstats"
|
||||
"filesystem"
|
||||
"loadavg"
|
||||
"meminfo"
|
||||
"netdev"
|
||||
"stat"
|
||||
"time"
|
||||
"vmstat"
|
||||
"systemd"
|
||||
"textfile"
|
||||
];
|
||||
extraFlags = [
|
||||
"--collector.textfile.directory=/var/lib/prometheus-node-exporter/textfile"
|
||||
];
|
||||
};
|
||||
|
||||
# ── Blackbox exporter ──
|
||||
# Makes actual HTTP requests and reports: probe success/failure, response
|
||||
# time broken into phases (DNS, connect, TLS, processing, transfer),
|
||||
# HTTP status code, TLS certificate expiry date
|
||||
services.prometheus.exporters.blackbox = {
|
||||
enable = true;
|
||||
port = 9115;
|
||||
configFile = pkgs.writeText "blackbox.yml" (builtins.toJSON {
|
||||
modules = {
|
||||
http_2xx = {
|
||||
prober = "http";
|
||||
timeout = "10s";
|
||||
http = {
|
||||
valid_http_versions = [ "HTTP/1.1" "HTTP/2.0" ];
|
||||
valid_status_codes = [ 200 ];
|
||||
method = "GET";
|
||||
follow_redirects = true;
|
||||
preferred_ip_protocol = "ip4";
|
||||
};
|
||||
};
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
# ── Memcached exporter ──
|
||||
# Exposes: cmd_get, cmd_set, get_hits, get_misses (→ hit ratio),
|
||||
# evictions, curr_items, bytes (used), limit_maxbytes,
|
||||
# curr_connections, total_connections
|
||||
services.prometheus.exporters.memcached = {
|
||||
enable = true;
|
||||
port = 9150;
|
||||
extraFlags = [ "--memcached.address=localhost:11211" ];
|
||||
};
|
||||
|
||||
# ── PHP-FPM exporter ──
|
||||
# Exposes: active_processes, idle_processes, total_processes,
|
||||
# accepted_conn, listen_queue, max_listen_queue,
|
||||
# slow_requests, max_children_reached
|
||||
# Uses a wrapper script to handle the semicolon in the scrape URI
|
||||
systemd.services.prometheus-phpfpm-exporter = {
|
||||
description = "Prometheus PHP-FPM exporter";
|
||||
after = [ "phpfpm-mediawiki.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
ExecStart = phpfpmExporterScript;
|
||||
User = "mediawiki";
|
||||
Group = "mediawiki";
|
||||
Restart = "always";
|
||||
RestartSec = "5s";
|
||||
};
|
||||
};
|
||||
|
||||
# Textfile collector directory for backup and sync metrics
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/prometheus-node-exporter/textfile 0755 root root -"
|
||||
];
|
||||
}
|
||||
129
modules/wiki-replica/caddy.nix
Normal file
129
modules/wiki-replica/caddy.nix
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
botPattern = ''(?i)(ClaudeBot|GPTBot|CCBot|Bytespider|AhrefsBot|SemrushBot|MJ12bot|DotBot|PetalBot|Amazonbot|anthropic-ai|ChatGPT-User|cohere-ai|FacebookBot|Google-Extended|PerplexityBot)'';
|
||||
|
||||
robotsTxt = ''
|
||||
User-agent: ClaudeBot
|
||||
Disallow: /
|
||||
User-agent: GPTBot
|
||||
Disallow: /
|
||||
User-agent: CCBot
|
||||
Disallow: /
|
||||
User-agent: Bytespider
|
||||
Disallow: /
|
||||
User-agent: anthropic-ai
|
||||
Disallow: /
|
||||
User-agent: ChatGPT-User
|
||||
Disallow: /
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Sitemap: https://www.noisebridge.net/sitemap.xml
|
||||
'';
|
||||
|
||||
# Shared clearnet config for www and readonly vhosts
|
||||
commonConfig = ''
|
||||
# Health check endpoint
|
||||
handle /health {
|
||||
respond "ok" 200
|
||||
}
|
||||
|
||||
# Bot blocking
|
||||
@bots header_regexp User-Agent "${botPattern}"
|
||||
respond @bots 403
|
||||
|
||||
# robots.txt
|
||||
handle /robots.txt {
|
||||
respond "${robotsTxt}"
|
||||
}
|
||||
|
||||
# Rate limiting for anonymous users
|
||||
# {client_ip} works with or without a reverse proxy in front
|
||||
@anon {
|
||||
not header_regexp Cookie "nb_wiki_session="
|
||||
}
|
||||
rate_limit @anon {
|
||||
zone replica_zone {
|
||||
key {client_ip}
|
||||
events 60
|
||||
window 1m
|
||||
}
|
||||
}
|
||||
|
||||
# Cache headers (read-only replica — everything is public-cacheable)
|
||||
header Cache-Control "public, max-age=7200"
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
X-Wiki-Mode "read-only"
|
||||
}
|
||||
|
||||
php_fastcgi unix//run/phpfpm/mediawiki.sock {
|
||||
root ${config.services.mediawiki.finalPackage}/share/mediawiki
|
||||
}
|
||||
|
||||
file_server {
|
||||
root ${config.services.mediawiki.finalPackage}/share/mediawiki
|
||||
}
|
||||
'';
|
||||
in
|
||||
{
|
||||
services.caddy = {
|
||||
enable = true;
|
||||
package = pkgs.caddy-custom;
|
||||
|
||||
globalConfig = ''
|
||||
order rate_limit before basicauth
|
||||
servers {
|
||||
# Trust Cloudflare's edge IPs so {client_ip} resolves to the real visitor
|
||||
trusted_proxies static 173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 141.101.64.0/18 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22 2400:cb00::/32 2606:4700::/32 2803:f800::/32 2405:b500::/32 2405:8100::/32 2a06:98c0::/29 2c0f:f248::/32
|
||||
metrics
|
||||
}
|
||||
'';
|
||||
|
||||
virtualHosts = {
|
||||
"readonly.noisebridge.net" = {
|
||||
extraConfig = commonConfig;
|
||||
};
|
||||
|
||||
# ── Tor .onion vhost ──
|
||||
# Tor daemon forwards port 80 → localhost:8080
|
||||
# HTTP only, no TLS (.onion v3 is end-to-end encrypted)
|
||||
# Read-only (same as clearnet replica)
|
||||
":8080" = {
|
||||
extraConfig = ''
|
||||
# Bot blocking
|
||||
@bots header_regexp User-Agent "${botPattern}"
|
||||
respond @bots 403
|
||||
|
||||
# robots.txt — block everything on .onion
|
||||
handle /robots.txt {
|
||||
respond "User-agent: *
|
||||
Disallow: /
|
||||
"
|
||||
}
|
||||
|
||||
# Security headers (no HSTS — no TLS over .onion)
|
||||
header {
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
Referrer-Policy "no-referrer"
|
||||
X-Wiki-Mode "read-only"
|
||||
X-Wiki-Access "tor"
|
||||
}
|
||||
|
||||
php_fastcgi unix//run/phpfpm/mediawiki.sock {
|
||||
root ${config.services.mediawiki.finalPackage}/share/mediawiki
|
||||
}
|
||||
|
||||
file_server {
|
||||
root ${config.services.mediawiki.finalPackage}/share/mediawiki
|
||||
}
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
46
modules/wiki-replica/mediawiki.nix
Normal file
46
modules/wiki-replica/mediawiki.nix
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
{
|
||||
services.mediawiki.extraConfig = lib.mkAfter ''
|
||||
# ----- Read-only mode -----
|
||||
$wgReadOnly = "This is a read-only mirror of the Noisebridge wiki.";
|
||||
$wgEnableUploads = false;
|
||||
$wgEnableEmail = false;
|
||||
|
||||
# ----- File cache (still useful for read-only serving) -----
|
||||
$wgUseFileCache = true;
|
||||
$wgFileCacheDirectory = "/var/cache/mediawiki";
|
||||
$wgShowIPinHeader = false;
|
||||
|
||||
# ----- No account creation on replica -----
|
||||
$wgGroupPermissions['*']['createaccount'] = false;
|
||||
'';
|
||||
|
||||
# Smaller PHP-FPM pool for replica
|
||||
# Use individual mkForce to override defaults without clobbering
|
||||
# required settings (listen, user, group) set by the mediawiki module
|
||||
services.phpfpm.pools.mediawiki.settings = {
|
||||
"pm" = lib.mkForce "dynamic";
|
||||
"pm.max_children" = lib.mkForce 8;
|
||||
"pm.start_servers" = lib.mkForce 2;
|
||||
"pm.min_spare_servers" = lib.mkForce 1;
|
||||
"pm.max_spare_servers" = lib.mkForce 4;
|
||||
"pm.max_requests" = lib.mkForce 500;
|
||||
"request_terminate_timeout" = lib.mkForce "30s";
|
||||
"pm.status_path" = "/fpm-status";
|
||||
|
||||
# OPcache
|
||||
"php_admin_value[opcache.enable]" = 1;
|
||||
"php_admin_value[opcache.memory_consumption]" = 128;
|
||||
"php_admin_value[opcache.max_accelerated_files]" = 10000;
|
||||
"php_admin_value[opcache.revalidate_freq]" = 60;
|
||||
"php_admin_value[opcache.jit]" = 1255;
|
||||
"php_admin_value[opcache.jit_buffer_size]" = "32M";
|
||||
|
||||
"php_admin_value[memory_limit]" = "128M";
|
||||
"php_admin_value[max_execution_time]" = 30;
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/cache/mediawiki 0755 mediawiki mediawiki -"
|
||||
];
|
||||
}
|
||||
106
modules/wiki-replica/mysql.nix
Normal file
106
modules/wiki-replica/mysql.nix
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
{
|
||||
services.mysql = {
|
||||
enable = true;
|
||||
package = pkgs.mariadb;
|
||||
dataDir = "/var/lib/mysql";
|
||||
|
||||
settings.mysqld = {
|
||||
bind-address = "127.0.0.1";
|
||||
|
||||
# InnoDB (smaller for replica)
|
||||
innodb_buffer_pool_size = "128M";
|
||||
innodb_log_file_size = "64M";
|
||||
innodb_flush_log_at_trx_commit = 2;
|
||||
innodb_file_per_table = 1;
|
||||
|
||||
# GTID replication (replica)
|
||||
server-id = 2;
|
||||
read_only = 1;
|
||||
relay_log = "relay-bin";
|
||||
log_slave_updates = 1;
|
||||
gtid_strict_mode = 1;
|
||||
|
||||
# Performance
|
||||
max_connections = 50;
|
||||
table_open_cache = 200;
|
||||
|
||||
# Character set
|
||||
character-set-server = "binary";
|
||||
collation-server = "binary";
|
||||
};
|
||||
|
||||
# Do NOT use ensureDatabases or ensureUsers on the replica —
|
||||
# they require writes, but read_only=1 blocks writes.
|
||||
# The database, tables, and users arrive via replication from the primary.
|
||||
};
|
||||
|
||||
# Node exporter (scraped by primary over Tailscale)
|
||||
# Includes textfile collector for failover metrics
|
||||
services.prometheus.exporters.node = {
|
||||
enable = true;
|
||||
port = 9100;
|
||||
enabledCollectors = [
|
||||
"cpu"
|
||||
"diskstats"
|
||||
"filesystem"
|
||||
"loadavg"
|
||||
"meminfo"
|
||||
"netdev"
|
||||
"stat"
|
||||
"time"
|
||||
"vmstat"
|
||||
"systemd"
|
||||
"textfile"
|
||||
];
|
||||
extraFlags = [
|
||||
"--collector.textfile.directory=/var/lib/prometheus-node-exporter/textfile"
|
||||
];
|
||||
};
|
||||
|
||||
# mysqld exporter (scraped by primary over Tailscale for replication metrics)
|
||||
# Key metrics: mysql_slave_status_seconds_behind_master,
|
||||
# mysql_slave_status_slave_io_running, mysql_slave_status_slave_sql_running,
|
||||
# mysql_global_status_queries (read-only query volume)
|
||||
services.prometheus.exporters.mysqld = {
|
||||
enable = true;
|
||||
port = 9104;
|
||||
runAsLocalSuperUser = true;
|
||||
};
|
||||
|
||||
# Memcached exporter (scraped by primary over Tailscale)
|
||||
# Key metrics: hit ratio, evictions, memory usage
|
||||
services.prometheus.exporters.memcached = {
|
||||
enable = true;
|
||||
port = 9150;
|
||||
extraFlags = [ "--memcached.address=localhost:11211" ];
|
||||
};
|
||||
|
||||
# Helper script to initialize replication (run once after provisioning)
|
||||
environment.systemPackages = [
|
||||
(pkgs.writeShellScriptBin "init-replication" ''
|
||||
set -euo pipefail
|
||||
REPL_PASS=$(cat ${config.age.secrets.mysql-replication.path})
|
||||
echo "Configuring GTID-based replication from wiki (primary)..."
|
||||
${pkgs.mariadb}/bin/mysql -u root <<SQL
|
||||
SET GLOBAL read_only = 0;
|
||||
STOP SLAVE;
|
||||
CHANGE MASTER TO
|
||||
MASTER_HOST='wiki',
|
||||
MASTER_USER='repl',
|
||||
MASTER_PASSWORD='$REPL_PASS',
|
||||
MASTER_USE_GTID=slave_pos;
|
||||
START SLAVE;
|
||||
SET GLOBAL read_only = 1;
|
||||
SHOW SLAVE STATUS\G
|
||||
SQL
|
||||
echo "Replication initialized. Check SHOW SLAVE STATUS for errors."
|
||||
'')
|
||||
];
|
||||
|
||||
age.secrets.mysql-replication = {
|
||||
file = ../../secrets/mysql-replication.age;
|
||||
owner = "mysql";
|
||||
group = "mysql";
|
||||
};
|
||||
}
|
||||
10
overlays/caddy.nix
Normal file
10
overlays/caddy.nix
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Custom Caddy build with rate-limit plugin.
|
||||
# Uses nixpkgs' caddy.withPlugins (available since nixpkgs 2024-12-10).
|
||||
# The hash must be updated after the first build attempt —
|
||||
# nix will error and tell you the correct hash.
|
||||
final: prev: {
|
||||
caddy-custom = prev.caddy.withPlugins {
|
||||
plugins = [ "github.com/mholt/caddy-ratelimit@v0.0.3" ];
|
||||
hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
};
|
||||
}
|
||||
28
secrets/secrets.nix
Normal file
28
secrets/secrets.nix
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
let
|
||||
# Admin public keys (for encrypting secrets locally)
|
||||
superq = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA_REPLACE_WITH_SUPERQ_KEY";
|
||||
jet = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA_REPLACE_WITH_JET_KEY";
|
||||
|
||||
admins = [ superq jet ];
|
||||
|
||||
# Host keys (generated after provisioning, replace with real keys)
|
||||
wiki = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA_REPLACE_WITH_WIKI_HOST_KEY";
|
||||
wiki-replica = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA_REPLACE_WITH_REPLICA_HOST_KEY";
|
||||
|
||||
allHosts = [ wiki wiki-replica ];
|
||||
primaryOnly = [ wiki ];
|
||||
in
|
||||
{
|
||||
# Shared secrets (both hosts)
|
||||
"tailscale-auth.age".publicKeys = admins ++ allHosts;
|
||||
"mysql-mediawiki.age".publicKeys = admins ++ allHosts;
|
||||
"mysql-replication.age".publicKeys = admins ++ allHosts;
|
||||
"mediawiki-secret-key.age".publicKeys = admins ++ allHosts;
|
||||
|
||||
# Primary-only secrets
|
||||
"grafana-admin.age".publicKeys = admins ++ primaryOnly;
|
||||
"prometheus-auth.age".publicKeys = admins ++ primaryOnly;
|
||||
"b2-credentials.age".publicKeys = admins ++ primaryOnly;
|
||||
"discord-webhook.age".publicKeys = admins ++ primaryOnly;
|
||||
"mediawiki-recaptcha.age".publicKeys = admins ++ primaryOnly;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue