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