diff --git a/.gitignore b/.gitignore index c00b98f..e298463 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /data/ /caddy-opnsense-blocker +/result +/result-* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1de6ae7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Richard Dern + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b67a2db..9ab0468 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,10 @@ This keeps the application usable immediately while leaving room for a more adva - `internal/service`: runs concurrent log followers and applies automatic decisions - `internal/web`: serves the local review UI and JSON API +## License + +This project is licensed under the MIT License. See `LICENSE`. + ## Quick start 1. Generate or provision OPNsense API credentials. @@ -88,6 +92,58 @@ CGO_ENABLED=0 go build ./cmd/caddy-opnsense-blocker `CGO_ENABLED=0` is useful on systems without a C toolchain. The application itself only relies on pure-Go dependencies. +## Nix packaging + +The repository ships with first-class Nix files: + +- `package.nix`: reusable package definition +- `default.nix`: convenience entry point for `nix-build` +- `module.nix`: reusable NixOS module + +Build the package directly from the repository root: + +```bash +nix-build +``` + +Use the NixOS module from another configuration: + +```nix +{ + imports = [ /path/to/caddy-opnsense-blocker/module.nix ]; + + services.caddy-opnsense-blocker = { + enable = true; + credentials.opnsenseApiKeyFile = "/run/secrets/opnsense-api-key"; + credentials.opnsenseApiSecretFile = "/run/secrets/opnsense-api-secret"; + + settings = { + opnsense = { + enabled = true; + base_url = "https://router.example.test"; + ensure_alias = true; + alias.name = "blocked-ips"; + }; + + profiles.public-web = { + auto_block = true; + block_unexpected_posts = true; + block_php_paths = true; + suspicious_path_prefixes = [ "/wp-admin" "/wp-login.php" "/.env" ]; + }; + + sources = [ + { + name = "public-web"; + path = "/var/log/caddy/public-web.json"; + profile = "public-web"; + } + ]; + }; + }; +} +``` + ## Roadmap - richer decision engine diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..b217c69 --- /dev/null +++ b/default.nix @@ -0,0 +1,3 @@ +let + pkgs = import { }; +in pkgs.callPackage ./package.nix { } diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..142727c --- /dev/null +++ b/module.nix @@ -0,0 +1,158 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.caddy-opnsense-blocker; + yamlFormat = pkgs.formats.yaml { }; + credentialsDirectory = "/run/credentials/caddy-opnsense-blocker.service"; + + credentialSettings = lib.optionalAttrs (cfg.credentials.opnsenseApiKeyFile != null) { + opnsense.api_key_file = "${credentialsDirectory}/opnsense_api_key"; + } // lib.optionalAttrs (cfg.credentials.opnsenseApiSecretFile != null) { + opnsense.api_secret_file = "${credentialsDirectory}/opnsense_api_secret"; + }; + + defaultSettings = { + server = { + listen_address = "127.0.0.1:9080"; + read_timeout = "5s"; + write_timeout = "10s"; + shutdown_timeout = "15s"; + }; + + storage.path = "/var/lib/${cfg.stateDirectoryName}/caddy-opnsense-blocker.db"; + }; + + mergedSettings = lib.recursiveUpdate defaultSettings (lib.recursiveUpdate credentialSettings cfg.settings); + configFile = yamlFormat.generate "caddy-opnsense-blocker.yaml" mergedSettings; + + loadCredential = + lib.optional (cfg.credentials.opnsenseApiKeyFile != null) + "opnsense_api_key:${toString cfg.credentials.opnsenseApiKeyFile}" + ++ lib.optional (cfg.credentials.opnsenseApiSecretFile != null) + "opnsense_api_secret:${toString cfg.credentials.opnsenseApiSecretFile}"; + + opnsenseEnabled = lib.attrByPath [ "opnsense" "enabled" ] false mergedSettings; + hasOPNsenseAPIKey = + cfg.credentials.opnsenseApiKeyFile != null + || lib.hasAttrByPath [ "opnsense" "api_key" ] cfg.settings + || lib.hasAttrByPath [ "opnsense" "api_key_file" ] cfg.settings; + hasOPNsenseAPISecret = + cfg.credentials.opnsenseApiSecretFile != null + || lib.hasAttrByPath [ "opnsense" "api_secret" ] cfg.settings + || lib.hasAttrByPath [ "opnsense" "api_secret_file" ] cfg.settings; +in { + options.services.caddy-opnsense-blocker = { + enable = lib.mkEnableOption "caddy-opnsense-blocker"; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.callPackage ./package.nix { }; + defaultText = lib.literalExpression "pkgs.callPackage ./package.nix { }"; + description = "Package used to run caddy-opnsense-blocker."; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "caddy"; + description = "User account used by the service."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "caddy"; + description = "Primary group used by the service."; + }; + + stateDirectoryName = lib.mkOption { + type = lib.types.str; + default = "caddy-opnsense-blocker"; + description = "Systemd state directory name for the service."; + }; + + credentials = { + opnsenseApiKeyFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to the OPNsense API key loaded through systemd credentials."; + }; + + opnsenseApiSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to the OPNsense API secret loaded through systemd credentials."; + }; + }; + + settings = lib.mkOption { + type = yamlFormat.type; + default = { }; + example = { + opnsense = { + enabled = true; + base_url = "https://router.example.test"; + ensure_alias = true; + alias.name = "blocked-ips"; + }; + + profiles.public-web = { + auto_block = true; + block_unexpected_posts = true; + suspicious_path_prefixes = [ "/wp-admin" "/wp-login.php" "/.env" ]; + }; + + sources = [ + { + name = "public-web"; + path = "/var/log/caddy/public-web.json"; + profile = "public-web"; + } + ]; + }; + description = "YAML-equivalent application settings written to a generated runtime configuration file."; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = (cfg.credentials.opnsenseApiKeyFile == null) == (cfg.credentials.opnsenseApiSecretFile == null); + message = "services.caddy-opnsense-blocker.credentials.opnsenseApiKeyFile and opnsenseApiSecretFile must either both be set or both be null."; + } + { + assertion = !opnsenseEnabled || (hasOPNsenseAPIKey && hasOPNsenseAPISecret); + message = "services.caddy-opnsense-blocker requires OPNsense credentials when settings.opnsense.enabled = true."; + } + ]; + + systemd.services.caddy-opnsense-blocker = { + description = "Caddy OPNsense blocker"; + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + StateDirectory = cfg.stateDirectoryName; + WorkingDirectory = "/var/lib/${cfg.stateDirectoryName}"; + ExecStart = "${cfg.package}/bin/caddy-opnsense-blocker -config ${configFile}"; + LoadCredential = loadCredential; + Restart = "always"; + RestartSec = "5s"; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictSUIDSGID = true; + RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; + LockPersonality = true; + MemoryDenyWriteExecute = true; + SystemCallArchitectures = "native"; + }; + }; + }; +} diff --git a/package.nix b/package.nix new file mode 100644 index 0000000..259b2f9 --- /dev/null +++ b/package.nix @@ -0,0 +1,20 @@ +{ buildGoModule, lib }: + +buildGoModule { + pname = "caddy-opnsense-blocker"; + version = "0.1.0"; + + src = lib.cleanSource ./.; + + subPackages = [ "cmd/caddy-opnsense-blocker" ]; + vendorHash = "sha256-xS1nuEjnpkKbmresj35UtNOps0dotgPCQn/bjRYp8Xk="; + env.CGO_ENABLED = 0; + + meta = with lib; { + description = "Real-time Caddy log ingestion with manual review and OPNsense blocking"; + homepage = "https://git.dern.ovh/infrastructure/caddy-opnsense-blocker"; + license = licenses.mit; + mainProgram = "caddy-opnsense-blocker"; + platforms = platforms.linux; + }; +}