diff --git a/clanServices/dyndns/README.md b/clanServices/dyndns/README.md new file mode 100644 index 000000000..d935e01bd --- /dev/null +++ b/clanServices/dyndns/README.md @@ -0,0 +1,86 @@ + +A Dynamic-DNS (DDNS) service continuously keeps one or more DNS records in sync with the current public IP address of your machine. +In *clan* this service is backed by [qdm12/ddns-updater](https://github.com/qdm12/ddns-updater). + +> Info +> ddns-updater itself is **heavily opinionated and version-specific**. Whenever you need the exhaustive list of flags or +> provider-specific fields refer to its *versioned* documentation – **not** the GitHub README +--- + +# 1. Configuration model + +Internally ddns-updater consumes a single file named `config.json`. +A minimal configuration for the registrar *Namecheap* looks like: + +```json +{ + "settings": [ + { + "provider": "namecheap", + "domain": "sub.example.com", + "password": "e5322165c1d74692bfa6d807100c0310" + } + ] +} +``` + +Another example for *Porkbun*: + +```json +{ + "settings": [ + { + "provider": "porkbun", + "domain": "domain.com", + "api_key": "sk1_…", + "secret_api_key": "pk1_…", + "ip_version": "ipv4", + "ipv6_suffix": "" + } + ] +} +``` + +When you write a `clan.nix` the **common** fields (`provider`, `domain`, `period`, …) are already exposed as typed +*Nix options*. +Registrar-specific or very new keys can be passed through an open attribute set called **extraSettings**. + +--- + +# 2. Full Porkbun example + +Manage three records – `@`, `home` and `test` – of the domain +`jon.blog` and refresh them every 15 minutes: + +```nix title="clan.nix" hl_lines="10-11" +inventory.instances = { + dyndns = { + roles.default.machines."jon" = { }; + roles.default.settings = { + period = 15; # minutes + settings = { + "all-jon-blog" = { + provider = "porkbun"; + domain = "jon.blog"; + + # (1) tell the secret-manager which key we are going to store + secret_field_name = "secret_api_key"; + + # everything below is copied verbatim into config.json + extraSettings = { + host = "@,home,test"; # (2) comma-separated list of sub-domains + ip_version = "ipv4"; + ipv6_suffix = ""; + api_key = "pk1_4bb2b231275a02fdc23b7e6f3552s01S213S"; # (3) public – safe to commit + }; + }; + }; + }; + }; +}; +``` + +1. `secret_field_name` tells the *vars-generator* to store the entered secret under the specified JSON field name in the configuration. +2. ddns-updater allows multiple hosts by separating them with a comma. +3. The `api_key` above is *public*; the corresponding **private key** is retrieved through `secret_field_name`. + diff --git a/clanServices/dyndns/default.nix b/clanServices/dyndns/default.nix new file mode 100644 index 000000000..c8326023a --- /dev/null +++ b/clanServices/dyndns/default.nix @@ -0,0 +1,277 @@ +{ ... }: +{ + _class = "clan.service"; + manifest.name = "clan-core/dyndns"; + manifest.description = "A dynamic DNS service to update domain IPs"; + manifest.categories = [ "Network" ]; + manifest.readme = builtins.readFile ./README.md; + + roles.default = { + interface = + { lib, ... }: + { + options = { + server = { + enable = lib.mkEnableOption "dyndns webserver"; + domain = lib.mkOption { + type = lib.types.str; + description = "Domain to serve the webservice on"; + }; + port = lib.mkOption { + type = lib.types.int; + default = 54805; + description = "Port to listen on"; + }; + acmeEmail = lib.mkOption { + type = lib.types.str; + description = '' + Email address for account creation and correspondence from the CA. + It is recommended to use the same email for all certs to avoid account + creation limits. + ''; + }; + }; + + period = lib.mkOption { + type = lib.types.int; + default = 5; + description = "Domain update period in minutes"; + }; + + settings = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.submodule ( + { ... }: + { + options = { + provider = lib.mkOption { + example = "namecheap"; + type = lib.types.str; + description = "The dyndns provider to use"; + }; + domain = lib.mkOption { + type = lib.types.str; + example = "example.com"; + description = "The top level domain to update."; + }; + secret_field_name = lib.mkOption { + example = "api_key"; + + type = lib.types.enum [ + "password" + "token" + "api_key" + "secret_api_key" + ]; + default = "password"; + description = "The field name for the secret"; + }; + extraSettings = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + description = '' + Extra settings for the provider. + Provider specific settings: https://github.com/qdm12/ddns-updater#configuration + ''; + }; + }; + } + ) + ); + default = { }; + description = "Configuration for which domains to update"; + }; + }; + }; + + perInstance = + { settings, ... }: + { + nixosModule = + { + config, + lib, + pkgs, + ... + }: + let + name = "dyndns"; + cfg = settings; + + # We dedup secrets if they have the same provider + base domain + secret_id = opt: "${name}-${opt.provider}-${opt.domain}"; + secret_path = + opt: config.clan.core.vars.generators."${secret_id opt}".files."${secret_id opt}".path; + + # We check that a secret has not been set in extraSettings. + extraSettingsSafe = + opt: + if (builtins.hasAttr opt.secret_field_name opt.extraSettings) then + throw "Please do not set ${opt.secret_field_name} in extraSettings, it is automatically set by the dyndns module." + else + opt.extraSettings; + + service_config = { + settings = builtins.catAttrs "value" ( + builtins.attrValues ( + lib.mapAttrs (_: opt: { + value = + (extraSettingsSafe opt) + // { + domain = opt.domain; + provider = opt.provider; + } + // { + "${opt.secret_field_name}" = secret_id opt; + }; + }) cfg.settings + ) + ); + }; + + secret_generator = _: opt: { + name = secret_id opt; + value = { + share = true; + migrateFact = "${secret_id opt}"; + prompts.${secret_id opt} = { + type = "hidden"; + persist = true; + }; + }; + }; + in + { + imports = lib.optional cfg.server.enable ( + lib.modules.importApply ./nginx.nix { + inherit config; + inherit settings; + inherit lib; + } + ); + + clan.core.vars.generators = lib.mkIf (cfg.settings != { }) ( + lib.mapAttrs' secret_generator cfg.settings + ); + + users.groups.${name} = lib.mkIf (cfg.settings != { }) { }; + users.users.${name} = lib.mkIf (cfg.settings != { }) { + group = name; + isSystemUser = true; + description = "User for ${name} service"; + home = "/var/lib/${name}"; + createHome = true; + }; + + services.nginx = lib.mkIf cfg.server.enable { + virtualHosts = { + "${cfg.server.domain}" = { + forceSSL = true; + enableACME = true; + locations."/" = { + proxyPass = "http://localhost:${toString cfg.server.port}"; + }; + }; + }; + }; + + systemd.services.${name} = lib.mkIf (cfg.settings != { }) { + path = [ ]; + description = "Dynamic DNS updater"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + environment = { + MYCONFIG = "${builtins.toJSON service_config}"; + SERVER_ENABLED = if cfg.server.enable then "yes" else "no"; + PERIOD = "${toString cfg.period}m"; + LISTENING_ADDRESS = ":${toString cfg.server.port}"; + GODEBUG = "netdns=go"; # We need to set this untill this has been merged. https://github.com/NixOS/nixpkgs/pull/432758 + }; + + serviceConfig = + let + pyscript = + pkgs.writers.writePython3Bin "generate_secret_config.py" + { + libraries = [ ]; + doCheck = false; + } + '' + import json + from pathlib import Path + import os + + cred_dir = Path(os.getenv("CREDENTIALS_DIRECTORY")) + config_str = os.getenv("MYCONFIG") + + + def get_credential(name): + secret_p = cred_dir / name + with open(secret_p, 'r') as f: + return f.read().strip() + + + config = json.loads(config_str) + print(f"Config: {config}") + for attrset in config["settings"]: + if "password" in attrset: + attrset['password'] = get_credential(attrset['password']) + elif "token" in attrset: + attrset['token'] = get_credential(attrset['token']) + elif "secret_api_key" in attrset: + attrset['secret_api_key'] = get_credential(attrset['secret_api_key']) + elif "api_key" in attrset: + attrset['api_key'] = get_credential(attrset['api_key']) + else: + raise ValueError(f"Missing secret field in {attrset}") + + # create directory data if it does not exist + data_dir = Path('data') + data_dir.mkdir(mode=0o770, exist_ok=True) + + # Create a temporary config file + # with appropriate permissions + tmp_config_path = data_dir / '.config.json' + tmp_config_path.touch(mode=0o660, exist_ok=False) + + # Write the config with secrets back + with open(tmp_config_path, 'w') as f: + f.write(json.dumps(config, indent=4)) + + # Move config into place + config_path = data_dir / 'config.json' + tmp_config_path.rename(config_path) + + # Set file permissions to read + # and write only by the user and group + for file in data_dir.iterdir(): + file.chmod(0o660) + ''; + in + { + ExecStartPre = lib.getExe pyscript; + ExecStart = lib.getExe pkgs.ddns-updater; + LoadCredential = lib.mapAttrsToList (_: opt: "${secret_id opt}:${secret_path opt}") cfg.settings; + User = name; + Group = name; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectSystem = "strict"; + ReadOnlyPaths = "/"; + PrivateDevices = "yes"; + ProtectKernelModules = "yes"; + ProtectKernelTunables = "yes"; + WorkingDirectory = "/var/lib/${name}"; + ReadWritePaths = [ + "/proc/self" + "/var/lib/${name}" + ]; + + Restart = "always"; + RestartSec = 60; + }; + }; + }; + }; + }; +} diff --git a/clanServices/dyndns/flake-module.nix b/clanServices/dyndns/flake-module.nix new file mode 100644 index 000000000..f88507a51 --- /dev/null +++ b/clanServices/dyndns/flake-module.nix @@ -0,0 +1,19 @@ +{ lib, ... }: +let + module = lib.modules.importApply ./default.nix { }; +in +{ + clan.modules = { + dyndns = module; + }; + + perSystem = + { ... }: + { + clan.nixosTests.dyndns = { + imports = [ ./tests/vm/default.nix ]; + + clan.modules."@clan/dyndns" = module; + }; + }; +} diff --git a/clanServices/dyndns/nginx.nix b/clanServices/dyndns/nginx.nix new file mode 100644 index 000000000..c74376628 --- /dev/null +++ b/clanServices/dyndns/nginx.nix @@ -0,0 +1,50 @@ +{ + config, + lib, + settings, + ... +}: +{ + security.acme.acceptTerms = true; + security.acme.defaults.email = settings.server.acmeEmail; + + networking.firewall.allowedTCPPorts = [ + 443 + 80 + ]; + + services.nginx = { + enable = true; + + statusPage = lib.mkDefault true; + recommendedBrotliSettings = lib.mkDefault true; + recommendedGzipSettings = lib.mkDefault true; + recommendedOptimisation = lib.mkDefault true; + recommendedProxySettings = lib.mkDefault true; + recommendedTlsSettings = lib.mkDefault true; + + # Nginx sends all the access logs to /var/log/nginx/access.log by default. + # instead of going to the journal! + commonHttpConfig = "access_log syslog:server=unix:/dev/log;"; + + resolver.addresses = + let + isIPv6 = addr: builtins.match ".*:.*:.*" addr != null; + escapeIPv6 = addr: if isIPv6 addr then "[${addr}]" else addr; + cloudflare = [ + "1.1.1.1" + "2606:4700:4700::1111" + ]; + resolvers = + if config.networking.nameservers == [ ] then cloudflare else config.networking.nameservers; + in + map escapeIPv6 resolvers; + + sslDhparam = config.security.dhparams.params.nginx.path; + }; + + security.dhparams = { + enable = true; + params.nginx = { }; + }; +} diff --git a/clanServices/dyndns/tests/vm/default.nix b/clanServices/dyndns/tests/vm/default.nix new file mode 100644 index 000000000..8c1e8f919 --- /dev/null +++ b/clanServices/dyndns/tests/vm/default.nix @@ -0,0 +1,77 @@ +{ + pkgs, + ... +}: +{ + name = "service-dyndns"; + + clan = { + directory = ./.; + inventory = { + machines.server = { }; + + instances = { + dyndns-test = { + module.name = "@clan/dyndns"; + module.input = "self"; + roles.default.machines."server".settings = { + server = { + enable = true; + domain = "test.example.com"; + port = 54805; + acmeEmail = "test@example.com"; + }; + period = 1; + settings = { + "test.example.com" = { + provider = "namecheap"; + domain = "example.com"; + secret_field_name = "password"; + extraSettings = { + host = "test"; + server = "dynamicdns.park-your-domain.com"; + }; + }; + }; + }; + }; + }; + }; + }; + + nodes = { + server = { + # Disable firewall for testing + networking.firewall.enable = false; + + # Mock ACME for testing (avoid real certificate requests) + security.acme.defaults.server = "https://localhost:14000/dir"; + }; + }; + + testScript = '' + start_all() + + # Test that dyndns service starts (will fail without secrets, but that's expected) + server.wait_for_unit("multi-user.target") + + # Test that nginx service is running + server.wait_for_unit("nginx.service") + + # Test that nginx is listening on expected ports + server.wait_for_open_port(80) + server.wait_for_open_port(443) + + # Test that the dyndns user was created + # server.succeed("getent passwd dyndns") + # server.succeed("getent group dyndns") + # + # Test that the home directory was created + server.succeed("test -d /var/lib/dyndns") + + # Test that nginx configuration includes our domain + server.succeed("${pkgs.nginx}/bin/nginx -t") + + print("All tests passed!") + ''; +} diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 3f762b601..7baca7159 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -96,6 +96,7 @@ nav: - reference/clanServices/admin.md - reference/clanServices/borgbackup.md - reference/clanServices/data-mesher.md + - reference/clanServices/dyndns.md - reference/clanServices/emergency-access.md - reference/clanServices/garage.md - reference/clanServices/hello-world.md