Merge pull request 'Migrate dyndns to clanServices' (#4390) from migrate-dyndns into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4390
This commit is contained in:
86
clanServices/dyndns/README.md
Normal file
86
clanServices/dyndns/README.md
Normal file
@@ -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`.
|
||||
|
||||
277
clanServices/dyndns/default.nix
Normal file
277
clanServices/dyndns/default.nix
Normal file
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
19
clanServices/dyndns/flake-module.nix
Normal file
19
clanServices/dyndns/flake-module.nix
Normal file
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
50
clanServices/dyndns/nginx.nix
Normal file
50
clanServices/dyndns/nginx.nix
Normal file
@@ -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 = { };
|
||||
};
|
||||
}
|
||||
77
clanServices/dyndns/tests/vm/default.nix
Normal file
77
clanServices/dyndns/tests/vm/default.nix
Normal file
@@ -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!")
|
||||
'';
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user