feat(pm): block missing nginx host connections #98

closed
opened by a.starrysky.fyi targeting main from private/minion/push-zoqyltwplryx

We previously returned One Of The Websites when nginx was accessed from a host that we didn't know about. That included direct IP address access as well as things which have been CNAMEd to us (either through a starred record or due to past services) but which aren't actually hosted by us.

This leads to a number of undesireable effects:

  • User confusion ("why does the aux docs website have Stalwart?")
  • Incorrect SSL certificates ("your blog seems to have an invalid certificate")
  • SSL being offered via direct IPs, which isn't possible to sign on the public internet

We can block this by making a default server to take control whenever nothing matches, and setting that default server to block all connections and reject all SSL handshakes

We need to have a certificate for this, but it needn't actually be valid for anything so let's self sign stuff...

Changed files
+247 -3
packetmix
+42
packetmix/systems/common/nginx.nix
···
··· 1 + # SPDX-FileCopyrightText: 2025 FreshlyBakedCake 2 + # 3 + # SPDX-License-Identifier: MIT 4 + 5 + { config, lib, ... }: 6 + { 7 + # By default, nginx will serve a "best-effort" site even if there is no matching vhost 8 + # We can disable this by making a matching vhost and returning 444... 9 + # Notice how we don't enable nginx here: that makes this safe to deploy even on places that don't currently run nginx. We're effectively changing the default behavior 10 + services.nginx.virtualHosts."missinghost.invalid" = { 11 + default = true; 12 + 13 + addSSL = true; 14 + enableACME = true; 15 + acmeRoot = null; 16 + 17 + locations."/".return = "444"; 18 + 19 + extraConfig = '' 20 + ssl_reject_handshake on; 21 + ''; 22 + }; 23 + 24 + systemd.services."acme-missinghost.invalid".enable = false; 25 + systemd.timers."acme-missinghost.invalid".enable = false; 26 + 27 + systemd.targets."acme-finished-missinghost.invalid" = { 28 + requires = lib.mkForce [ "acme-selfsigned-missinghost.invalid.service" ]; 29 + after = lib.mkForce [ "acme-selfsigned-missinghost.invalid.service" ]; 30 + }; 31 + 32 + security.acme.acceptTerms = true; 33 + security.acme.certs = lib.mkIf config.services.nginx.enable { 34 + "missinghost.invalid" = { 35 + dnsProvider = null; 36 + listenHTTP = null; 37 + s3Bucket = null; 38 + webroot = "/dev/null"; 39 + email = "invalid@missinghost.invalid"; 40 + }; # Nix requires some values, even if we're actually disabling the acme-missinghost.invalid service... that's problematic if there are no defaults for the system 41 + }; 42 + }
+138
packetmix/systems/umber/copyparty.nix
···
··· 1 + # SPDX-FileCopyrightText: 2025 FreshlyBakedCake 2 + # 3 + # SPDX-License-Identifier: MIT 4 + 5 + { 6 + project, 7 + pkgs, 8 + config, 9 + lib, 10 + ... 11 + }: 12 + { 13 + imports = [ 14 + project.inputs.copyparty.result.nixosModules.default 15 + ]; 16 + 17 + config = { 18 + nixpkgs.overlays = [ project.inputs.copyparty.result.overlays.default ]; 19 + 20 + services.copyparty = 21 + let 22 + admins = [ 23 + "minion" 24 + ]; 25 + in 26 + { 27 + enable = true; 28 + 29 + settings = { 30 + i = "127.0.0.1"; # ip 31 + p = 1030; # port 32 + 33 + # we'll be using nginx for this... 34 + http-only = true; 35 + no-crt = true; 36 + 37 + idp-store = 3; 38 + idp-h-usr = "X-Webauth-Login"; 39 + idp-adm = admins; 40 + have-idp-hdrs = 1; # https://github.com/9001/copyparty/issues/849 41 + 42 + shr = "/share"; 43 + shr-db = "/var/lib/copyparty/shares.db"; 44 + shr-adm = admins; 45 + 46 + # as we might have private directories, better to be a bit conservative about permissions... 47 + chmod-f = 700; 48 + chmod-d = 700; 49 + 50 + magic = true; # "enable filetype detection on nameless uploads" 51 + 52 + e2dsa = true; # index files to allow searching, upload undo, etc. 53 + e2ts = true; # and scan metadata... 54 + 55 + rss = true; # allow (experimental) rss support -> useful for antennapod/miniflux/co. 56 + dav-auth = true; # "force auth for all folders" notably "(required by davfs2 when only some folders are world-readable)" 57 + 58 + xvol = true; # don't allow symlinks to break out of confinement... 59 + no-robots = true; # not really meant to be indexed. Maybe we want to add anubis at some point too... 60 + 61 + ah-alg = "argon2"; 62 + 63 + spinner = "⭐"; # [hopefully this isn't too boring for you, tripflag](https://github.com/9001/copyparty/tree/hovudstraum/docs/rice#boring-loader-spinner) 64 + 65 + xm = "aw,f,j,t3600,${project.inputs.copyparty.src}/bin/hooks/wget.py"; # download URLs that are pasted into the message box 66 + 67 + xff-src = "127.0.0.1"; 68 + rproxy = 1; 69 + 70 + exp = true; 71 + }; 72 + 73 + volumes = { 74 + "/" = { 75 + path = "/var/lib/copyparty/data"; 76 + 77 + access = { 78 + r = "*"; 79 + A = admins; 80 + }; 81 + }; 82 + "/private" = { 83 + path = "/var/lib/copyparty/private"; 84 + 85 + access = { 86 + A = [ 87 + "minion" 88 + ]; 89 + }; 90 + }; 91 + }; 92 + }; 93 + 94 + systemd.services.copyparty = { 95 + path = [ pkgs.wget ]; # Needed for downloading files by URL 96 + serviceConfig = { 97 + BindReadOnlyPaths = [ 98 + "/etc/ssl" 99 + "/etc/static/ssl" 100 + ]; # Required for wget to validate SSL for downloads 101 + StateDirectory = 102 + "copyparty " 103 + + (lib.pipe config.services.copyparty.volumes [ 104 + builtins.attrValues 105 + (map (mount: mount.path)) 106 + (map (lib.removePrefix "/var/lib/")) 107 + (lib.concatStringsSep " ") 108 + ]); 109 + }; 110 + }; 111 + 112 + services.nginx.enable = true; 113 + 114 + services.nginx.virtualHosts."copyparty.starrysky.fyi" = { 115 + serverName = "copyparty.starrysky.fyi"; 116 + 117 + addSSL = true; 118 + enableACME = true; 119 + acmeRoot = null; 120 + 121 + locations."/" = { 122 + proxyPass = "http://127.0.0.1:1030"; 123 + recommendedProxySettings = true; 124 + proxyWebsockets = true; 125 + }; 126 + 127 + extraConfig = '' 128 + client_max_body_size 1024M; 129 + ''; 130 + }; 131 + services.nginx.tailscaleAuth = { 132 + enable = true; 133 + virtualHosts = [ "copyparty.starrysky.fyi" ]; 134 + }; 135 + 136 + clicks.storage.impermanence.persist.directories = [ "/var/lib/copyparty" ]; 137 + }; 138 + }
+27
packetmix/systems/umber/grocy/custom_js.html
···
··· 1 + <!-- 2 + SPDX-FileCopyrightText: 2026 Freshly Baked Cake 3 + 4 + SPDX-License-Identifier: MIT 5 + --> 6 + 7 + <script> 8 + { 9 + const locationFilter = document.getElementById("location-filter"); 10 + let desiredLocation = GetUriParam("location"); 11 + if (locationFilter !== null && desiredLocation !== null) 12 + { 13 + if (locationFilter.selectedOptions.length === 0 || locationFilter.selectedOptions[0].value === "all") { 14 + // Don't trigger if we select a different option and refresh the page 15 + desiredLocation = desiredLocation.replaceAll("_", " ") // WikiText needs underscores to be spaces 16 + const option = locationFilter.querySelector(`option[value="${desiredLocation}"]`) 17 + ?? locationFilter.querySelector(`option[value^="${desiredLocation}"]`) 18 + ?? locationFilter.querySelector(`option[value*="${desiredLocation}"]`); 19 + // Search first for an exact match, then a match which starts with, then a match which contains 20 + if (option) { 21 + locationFilter.value = option.value; 22 + locationFilter.dispatchEvent(new Event('change')); 23 + } 24 + } 25 + } 26 + } 27 + </script>
+34
packetmix/systems/umber/grocy.nix
···
··· 1 + # SPDX-FileCopyrightText: 2025 FreshlyBakedCake 2 + # 3 + # SPDX-License-Identifier: MIT 4 + 5 + { pkgs, lib, ... }: 6 + { 7 + services.grocy = { 8 + enable = true; 9 + package = pkgs.stdenv.mkDerivation { 10 + name = "grocy-custom-js"; 11 + src = pkgs.grocy; 12 + 13 + dontBuild = true; 14 + installPhase = '' 15 + mkdir -p $out/ 16 + cp -r * $out/ 17 + 18 + mkdir -p $out/data/ 19 + cp ${./grocy/custom_js.html} $out/data/custom_js.html # we need to specify the filename explicitly, as otherwise this'll have a hash 20 + ''; 21 + }; 22 + hostName = "grocy.starrysky.fyi"; 23 + 24 + settings.currency = "GBP"; 25 + }; 26 + 27 + services.nginx.virtualHosts."grocy.starrysky.fyi" = { 28 + acmeRoot = null; 29 + forceSSL = lib.mkForce false; 30 + onlySSL = true; 31 + }; 32 + 33 + clicks.storage.impermanence.persist.directories = [ "/var/lib/grocy" ]; 34 + }
+5
packetmix/systems/umber/hardware-configuration.nix
··· 29 ]; 30 }; 31 32 clicks.storage.impermanence = { 33 enable = true; 34 devices = {
··· 29 ]; 30 }; 31 32 + boot.swraid.enable = true; 33 + boot.swraid.mdadmConf = '' 34 + PROGRAM=true 35 + ''; # Disable reporting for this system 36 + 37 clicks.storage.impermanence = { 38 enable = true; 39 devices = {
+1 -3
packetmix/systems/umber/silverbullet.nix
··· 28 services.nginx.virtualHosts."silverbullet.starrysky.fyi" = { 29 listenAddresses = [ "localhost.tailscale" ]; 30 31 - addSSL = true; 32 enableACME = true; 33 acmeRoot = null; 34 - 35 - serverAliases = [ "umber.clicks.domains" ]; 36 37 locations."/" = { 38 proxyPass = "http://$silverbullet_upstream_minion_only";
··· 28 services.nginx.virtualHosts."silverbullet.starrysky.fyi" = { 29 listenAddresses = [ "localhost.tailscale" ]; 30 31 + onlySSL = true; 32 enableACME = true; 33 acmeRoot = null; 34 35 locations."/" = { 36 proxyPass = "http://$silverbullet_upstream_minion_only";