yep, more dotfiles

server: add basic headscale acl

wiro.world ba67f579 0ea7bda6

verified
+705
+1
modules/nixos/default.nix
··· 1 1 { 2 2 geoclue2 = ./geoclue2.nix; 3 + headscale = ./headscale.nix; 3 4 logiops = ./logiops.nix; 4 5 }
+673
modules/nixos/headscale.nix
··· 1 + { config 2 + , lib 3 + , pkgs 4 + , ... 5 + }: 6 + let 7 + cfg = config.services.headscale; 8 + 9 + dataDir = "/var/lib/headscale"; 10 + runDir = "/run/headscale"; 11 + 12 + cliConfig = { 13 + # Turn off update checks since the origin of our package 14 + # is nixpkgs and not Github. 15 + disable_check_updates = true; 16 + 17 + unix_socket = "${runDir}/headscale.sock"; 18 + }; 19 + 20 + settingsFormat = pkgs.formats.yaml { }; 21 + configFile = settingsFormat.generate "headscale.yaml" cfg.settings; 22 + cliConfigFile = settingsFormat.generate "headscale.yaml" cliConfig; 23 + 24 + assertRemovedOption = option: message: { 25 + assertion = !lib.hasAttrByPath option cfg; 26 + message = 27 + "The option `services.headscale.${lib.options.showOption option}` was removed. " + message; 28 + }; 29 + in 30 + { 31 + options = { 32 + services.headscale = { 33 + enable = lib.mkEnableOption "headscale, Open Source coordination server for Tailscale"; 34 + 35 + package = lib.mkPackageOption pkgs "headscale" { }; 36 + 37 + user = lib.mkOption { 38 + default = "headscale"; 39 + type = lib.types.str; 40 + description = '' 41 + User account under which headscale runs. 42 + 43 + ::: {.note} 44 + If left as the default value this user will automatically be created 45 + on system activation, otherwise you are responsible for 46 + ensuring the user exists before the headscale service starts. 47 + ::: 48 + ''; 49 + }; 50 + 51 + group = lib.mkOption { 52 + default = "headscale"; 53 + type = lib.types.str; 54 + description = '' 55 + Group under which headscale runs. 56 + 57 + ::: {.note} 58 + If left as the default value this group will automatically be created 59 + on system activation, otherwise you are responsible for 60 + ensuring the user exists before the headscale service starts. 61 + ::: 62 + ''; 63 + }; 64 + 65 + address = lib.mkOption { 66 + type = lib.types.str; 67 + default = "127.0.0.1"; 68 + description = '' 69 + Listening address of headscale. 70 + ''; 71 + example = "0.0.0.0"; 72 + }; 73 + 74 + port = lib.mkOption { 75 + type = lib.types.port; 76 + default = 8080; 77 + description = '' 78 + Listening port of headscale. 79 + ''; 80 + example = 443; 81 + }; 82 + 83 + settings = lib.mkOption { 84 + description = '' 85 + Overrides to {file}`config.yaml` as a Nix attribute set. 86 + Check the [example config](https://github.com/juanfont/headscale/blob/main/config-example.yaml) 87 + for possible options. 88 + ''; 89 + type = lib.types.submodule { 90 + freeformType = settingsFormat.type; 91 + 92 + options = { 93 + server_url = lib.mkOption { 94 + type = lib.types.str; 95 + default = "http://127.0.0.1:8080"; 96 + description = '' 97 + The url clients will connect to. 98 + ''; 99 + example = "https://myheadscale.example.com:443"; 100 + }; 101 + 102 + noise.private_key_path = lib.mkOption { 103 + type = lib.types.path; 104 + default = "${dataDir}/noise_private.key"; 105 + description = '' 106 + Path to noise private key file, generated automatically if it does not exist. 107 + ''; 108 + }; 109 + 110 + prefixes = 111 + let 112 + prefDesc = '' 113 + Each prefix consists of either an IPv4 or IPv6 address, 114 + and the associated prefix length, delimited by a slash. 115 + It must be within IP ranges supported by the Tailscale 116 + client - i.e., subnets of 100.64.0.0/10 and fd7a:115c:a1e0::/48. 117 + ''; 118 + in 119 + { 120 + v4 = lib.mkOption { 121 + type = lib.types.str; 122 + default = "100.64.0.0/10"; 123 + description = prefDesc; 124 + }; 125 + 126 + v6 = lib.mkOption { 127 + type = lib.types.str; 128 + default = "fd7a:115c:a1e0::/48"; 129 + description = prefDesc; 130 + }; 131 + 132 + allocation = lib.mkOption { 133 + type = lib.types.enum [ 134 + "sequential" 135 + "random" 136 + ]; 137 + example = "random"; 138 + default = "sequential"; 139 + description = '' 140 + Strategy used for allocation of IPs to nodes, available options: 141 + - sequential (default): assigns the next free IP from the previous given IP. 142 + - random: assigns the next free IP from a pseudo-random IP generator (crypto/rand). 143 + ''; 144 + }; 145 + }; 146 + 147 + derp = { 148 + urls = lib.mkOption { 149 + type = lib.types.listOf lib.types.str; 150 + default = [ "https://controlplane.tailscale.com/derpmap/default" ]; 151 + description = '' 152 + List of urls containing DERP maps. 153 + See [How Tailscale works](https://tailscale.com/blog/how-tailscale-works/) for more information on DERP maps. 154 + ''; 155 + }; 156 + 157 + paths = lib.mkOption { 158 + type = lib.types.listOf lib.types.path; 159 + default = [ ]; 160 + description = '' 161 + List of file paths containing DERP maps. 162 + See [How Tailscale works](https://tailscale.com/blog/how-tailscale-works/) for more information on DERP maps. 163 + ''; 164 + }; 165 + 166 + auto_update_enable = lib.mkOption { 167 + type = lib.types.bool; 168 + default = true; 169 + description = '' 170 + Whether to automatically update DERP maps on a set frequency. 171 + ''; 172 + example = false; 173 + }; 174 + 175 + update_frequency = lib.mkOption { 176 + type = lib.types.str; 177 + default = "24h"; 178 + description = '' 179 + Frequency to update DERP maps. 180 + ''; 181 + example = "5m"; 182 + }; 183 + 184 + server.private_key_path = lib.mkOption { 185 + type = lib.types.path; 186 + default = "${dataDir}/derp_server_private.key"; 187 + description = '' 188 + Path to derp private key file, generated automatically if it does not exist. 189 + ''; 190 + }; 191 + }; 192 + 193 + ephemeral_node_inactivity_timeout = lib.mkOption { 194 + type = lib.types.str; 195 + default = "30m"; 196 + description = '' 197 + Time before an inactive ephemeral node is deleted. 198 + ''; 199 + example = "5m"; 200 + }; 201 + 202 + database = { 203 + type = lib.mkOption { 204 + type = lib.types.enum [ 205 + "sqlite" 206 + "sqlite3" 207 + "postgres" 208 + ]; 209 + example = "postgres"; 210 + default = "sqlite"; 211 + description = '' 212 + Database engine to use. 213 + Please note that using Postgres is highly discouraged as it is only supported for legacy reasons. 214 + All new development, testing and optimisations are done with SQLite in mind. 215 + ''; 216 + }; 217 + 218 + sqlite = { 219 + path = lib.mkOption { 220 + type = lib.types.nullOr lib.types.str; 221 + default = "${dataDir}/db.sqlite"; 222 + description = "Path to the sqlite3 database file."; 223 + }; 224 + 225 + write_ahead_log = lib.mkOption { 226 + type = lib.types.bool; 227 + default = true; 228 + description = '' 229 + Enable WAL mode for SQLite. This is recommended for production environments. 230 + <https://www.sqlite.org/wal.html> 231 + ''; 232 + example = true; 233 + }; 234 + }; 235 + 236 + postgres = { 237 + host = lib.mkOption { 238 + type = lib.types.nullOr lib.types.str; 239 + default = null; 240 + example = "127.0.0.1"; 241 + description = "Database host address."; 242 + }; 243 + 244 + port = lib.mkOption { 245 + type = lib.types.nullOr lib.types.port; 246 + default = null; 247 + example = 3306; 248 + description = "Database host port."; 249 + }; 250 + 251 + name = lib.mkOption { 252 + type = lib.types.nullOr lib.types.str; 253 + default = null; 254 + example = "headscale"; 255 + description = "Database name."; 256 + }; 257 + 258 + user = lib.mkOption { 259 + type = lib.types.nullOr lib.types.str; 260 + default = null; 261 + example = "headscale"; 262 + description = "Database user."; 263 + }; 264 + 265 + password_file = lib.mkOption { 266 + type = lib.types.nullOr lib.types.path; 267 + default = null; 268 + example = "/run/keys/headscale-dbpassword"; 269 + description = '' 270 + A file containing the password corresponding to 271 + {option}`database.user`. 272 + ''; 273 + }; 274 + }; 275 + }; 276 + 277 + log = { 278 + level = lib.mkOption { 279 + type = lib.types.str; 280 + default = "info"; 281 + description = '' 282 + headscale log level. 283 + ''; 284 + example = "debug"; 285 + }; 286 + 287 + format = lib.mkOption { 288 + type = lib.types.str; 289 + default = "text"; 290 + description = '' 291 + headscale log format. 292 + ''; 293 + example = "json"; 294 + }; 295 + }; 296 + 297 + dns = { 298 + magic_dns = lib.mkOption { 299 + type = lib.types.bool; 300 + default = true; 301 + description = '' 302 + Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/). 303 + ''; 304 + example = false; 305 + }; 306 + 307 + base_domain = lib.mkOption { 308 + type = lib.types.str; 309 + default = ""; 310 + description = '' 311 + Defines the base domain to create the hostnames for MagicDNS. 312 + This domain must be different from the {option}`server_url` 313 + domain. 314 + {option}`base_domain` must be a FQDN, without the trailing dot. 315 + The FQDN of the hosts will be `hostname.base_domain` (e.g. 316 + `myhost.tailnet.example.com`). 317 + ''; 318 + example = "tailnet.example.com"; 319 + }; 320 + 321 + nameservers = { 322 + global = lib.mkOption { 323 + type = lib.types.listOf lib.types.str; 324 + default = [ ]; 325 + description = '' 326 + List of nameservers to pass to Tailscale clients. 327 + ''; 328 + }; 329 + }; 330 + 331 + search_domains = lib.mkOption { 332 + type = lib.types.listOf lib.types.str; 333 + default = [ ]; 334 + description = '' 335 + Search domains to inject to Tailscale clients. 336 + ''; 337 + example = [ "mydomain.internal" ]; 338 + }; 339 + }; 340 + 341 + oidc = { 342 + issuer = lib.mkOption { 343 + type = lib.types.str; 344 + default = ""; 345 + description = '' 346 + URL to OpenID issuer. 347 + ''; 348 + example = "https://openid.example.com"; 349 + }; 350 + 351 + client_id = lib.mkOption { 352 + type = lib.types.str; 353 + default = ""; 354 + description = '' 355 + OpenID Connect client ID. 356 + ''; 357 + }; 358 + 359 + client_secret_path = lib.mkOption { 360 + type = lib.types.nullOr lib.types.str; 361 + default = null; 362 + description = '' 363 + Path to OpenID Connect client secret file. Expands environment variables in format ''${VAR}. 364 + ''; 365 + }; 366 + 367 + scope = lib.mkOption { 368 + type = lib.types.listOf lib.types.str; 369 + default = [ 370 + "openid" 371 + "profile" 372 + "email" 373 + ]; 374 + description = '' 375 + Scopes used in the OIDC flow. 376 + ''; 377 + }; 378 + 379 + extra_params = lib.mkOption { 380 + type = lib.types.attrsOf lib.types.str; 381 + default = { }; 382 + description = '' 383 + Custom query parameters to send with the Authorize Endpoint request. 384 + ''; 385 + example = { 386 + domain_hint = "example.com"; 387 + }; 388 + }; 389 + 390 + allowed_domains = lib.mkOption { 391 + type = lib.types.listOf lib.types.str; 392 + default = [ ]; 393 + description = '' 394 + Allowed principal domains. if an authenticated user's domain 395 + is not in this list authentication request will be rejected. 396 + ''; 397 + example = [ "example.com" ]; 398 + }; 399 + 400 + allowed_users = lib.mkOption { 401 + type = lib.types.listOf lib.types.str; 402 + default = [ ]; 403 + description = '' 404 + Users allowed to authenticate even if not in allowedDomains. 405 + ''; 406 + example = [ "alice@example.com" ]; 407 + }; 408 + }; 409 + 410 + tls_letsencrypt_hostname = lib.mkOption { 411 + type = lib.types.nullOr lib.types.str; 412 + default = ""; 413 + description = '' 414 + Domain name to request a TLS certificate for. 415 + ''; 416 + }; 417 + 418 + tls_letsencrypt_challenge_type = lib.mkOption { 419 + type = lib.types.enum [ 420 + "TLS-ALPN-01" 421 + "HTTP-01" 422 + ]; 423 + default = "HTTP-01"; 424 + description = '' 425 + Type of ACME challenge to use, currently supported types: 426 + `HTTP-01` or `TLS-ALPN-01`. 427 + ''; 428 + }; 429 + 430 + tls_letsencrypt_listen = lib.mkOption { 431 + type = lib.types.nullOr lib.types.str; 432 + default = ":http"; 433 + description = '' 434 + When HTTP-01 challenge is chosen, letsencrypt must set up a 435 + verification endpoint, and it will be listening on: 436 + `:http = port 80`. 437 + ''; 438 + }; 439 + 440 + tls_cert_path = lib.mkOption { 441 + type = lib.types.nullOr lib.types.path; 442 + default = null; 443 + description = '' 444 + Path to already created certificate. 445 + ''; 446 + }; 447 + 448 + tls_key_path = lib.mkOption { 449 + type = lib.types.nullOr lib.types.path; 450 + default = null; 451 + description = '' 452 + Path to key for already created certificate. 453 + ''; 454 + }; 455 + 456 + policy = { 457 + mode = lib.mkOption { 458 + type = lib.types.enum [ 459 + "file" 460 + "database" 461 + ]; 462 + default = "file"; 463 + description = '' 464 + The mode can be "file" or "database" that defines 465 + where the ACL policies are stored and read from. 466 + ''; 467 + }; 468 + 469 + path = lib.mkOption { 470 + type = lib.types.nullOr lib.types.path; 471 + default = null; 472 + description = '' 473 + If the mode is set to "file", the path to a 474 + HuJSON file containing ACL policies. 475 + ''; 476 + }; 477 + }; 478 + }; 479 + }; 480 + }; 481 + }; 482 + }; 483 + 484 + imports = with lib; [ 485 + (mkRenamedOptionModule 486 + [ "services" "headscale" "derp" "autoUpdate" ] 487 + [ "services" "headscale" "settings" "derp" "auto_update_enable" ] 488 + ) 489 + (mkRenamedOptionModule 490 + [ "services" "headscale" "derp" "paths" ] 491 + [ "services" "headscale" "settings" "derp" "paths" ] 492 + ) 493 + (mkRenamedOptionModule 494 + [ "services" "headscale" "derp" "updateFrequency" ] 495 + [ "services" "headscale" "settings" "derp" "update_frequency" ] 496 + ) 497 + (mkRenamedOptionModule 498 + [ "services" "headscale" "derp" "urls" ] 499 + [ "services" "headscale" "settings" "derp" "urls" ] 500 + ) 501 + (mkRenamedOptionModule 502 + [ "services" "headscale" "ephemeralNodeInactivityTimeout" ] 503 + [ "services" "headscale" "settings" "ephemeral_node_inactivity_timeout" ] 504 + ) 505 + (mkRenamedOptionModule 506 + [ "services" "headscale" "logLevel" ] 507 + [ "services" "headscale" "settings" "log" "level" ] 508 + ) 509 + (mkRenamedOptionModule 510 + [ "services" "headscale" "openIdConnect" "clientId" ] 511 + [ "services" "headscale" "settings" "oidc" "client_id" ] 512 + ) 513 + (mkRenamedOptionModule 514 + [ "services" "headscale" "openIdConnect" "clientSecretFile" ] 515 + [ "services" "headscale" "settings" "oidc" "client_secret_path" ] 516 + ) 517 + (mkRenamedOptionModule 518 + [ "services" "headscale" "openIdConnect" "issuer" ] 519 + [ "services" "headscale" "settings" "oidc" "issuer" ] 520 + ) 521 + (mkRenamedOptionModule 522 + [ "services" "headscale" "serverUrl" ] 523 + [ "services" "headscale" "settings" "server_url" ] 524 + ) 525 + (mkRenamedOptionModule 526 + [ "services" "headscale" "tls" "certFile" ] 527 + [ "services" "headscale" "settings" "tls_cert_path" ] 528 + ) 529 + (mkRenamedOptionModule 530 + [ "services" "headscale" "tls" "keyFile" ] 531 + [ "services" "headscale" "settings" "tls_key_path" ] 532 + ) 533 + (mkRenamedOptionModule 534 + [ "services" "headscale" "tls" "letsencrypt" "challengeType" ] 535 + [ "services" "headscale" "settings" "tls_letsencrypt_challenge_type" ] 536 + ) 537 + (mkRenamedOptionModule 538 + [ "services" "headscale" "tls" "letsencrypt" "hostname" ] 539 + [ "services" "headscale" "settings" "tls_letsencrypt_hostname" ] 540 + ) 541 + (mkRenamedOptionModule 542 + [ "services" "headscale" "tls" "letsencrypt" "httpListen" ] 543 + [ "services" "headscale" "settings" "tls_letsencrypt_listen" ] 544 + ) 545 + 546 + (mkRemovedOptionModule [ "services" "headscale" "openIdConnect" "domainMap" ] '' 547 + Headscale no longer uses domain_map. If you're using an old version of headscale you can still set this option via services.headscale.settings.oidc.domain_map. 548 + '') 549 + ]; 550 + 551 + config = lib.mkIf cfg.enable { 552 + assertions = [ 553 + { 554 + assertion = with cfg.settings; dns.magic_dns -> dns.base_domain != ""; 555 + message = "dns.base_domain must be set when using MagicDNS"; 556 + } 557 + (assertRemovedOption [ "settings" "acl_policy_path" ] "Use `policy.path` instead.") 558 + (assertRemovedOption [ "settings" "db_host" ] "Use `database.postgres.host` instead.") 559 + (assertRemovedOption [ "settings" "db_name" ] "Use `database.postgres.name` instead.") 560 + (assertRemovedOption [ 561 + "settings" 562 + "db_password_file" 563 + ] "Use `database.postgres.password_file` instead.") 564 + (assertRemovedOption [ "settings" "db_path" ] "Use `database.sqlite.path` instead.") 565 + (assertRemovedOption [ "settings" "db_port" ] "Use `database.postgres.port` instead.") 566 + (assertRemovedOption [ "settings" "db_type" ] "Use `database.type` instead.") 567 + (assertRemovedOption [ "settings" "db_user" ] "Use `database.postgres.user` instead.") 568 + (assertRemovedOption [ "settings" "dns_config" ] "Use `dns` instead.") 569 + (assertRemovedOption [ "settings" "dns_config" "domains" ] "Use `dns.search_domains` instead.") 570 + (assertRemovedOption [ 571 + "settings" 572 + "dns_config" 573 + "nameservers" 574 + ] "Use `dns.nameservers.global` instead.") 575 + ]; 576 + 577 + services.headscale.settings = lib.mkMerge [ 578 + cliConfig 579 + { 580 + listen_addr = lib.mkDefault "${cfg.address}:${toString cfg.port}"; 581 + 582 + tls_letsencrypt_cache_dir = "${dataDir}/.cache"; 583 + } 584 + ]; 585 + 586 + environment = { 587 + # Headscale CLI needs a minimal config to be able to locate the unix socket 588 + # to talk to the server instance. 589 + etc."headscale/config.yaml".source = cliConfigFile; 590 + 591 + systemPackages = [ cfg.package ]; 592 + }; 593 + 594 + users.groups.headscale = lib.mkIf (cfg.group == "headscale") { }; 595 + 596 + users.users.headscale = lib.mkIf (cfg.user == "headscale") { 597 + description = "headscale user"; 598 + home = dataDir; 599 + group = cfg.group; 600 + isSystemUser = true; 601 + }; 602 + 603 + systemd.services.headscale = { 604 + description = "headscale coordination server for Tailscale"; 605 + wants = [ "network-online.target" ]; 606 + after = [ "network-online.target" ]; 607 + wantedBy = [ "multi-user.target" ]; 608 + 609 + script = '' 610 + ${lib.optionalString (cfg.settings.database.postgres.password_file != null) '' 611 + export HEADSCALE_DATABASE_POSTGRES_PASS="$(head -n1 ${lib.escapeShellArg cfg.settings.database.postgres.password_file})" 612 + ''} 613 + 614 + exec ${lib.getExe cfg.package} serve --config ${configFile} 615 + ''; 616 + 617 + serviceConfig = 618 + let 619 + capabilityBoundingSet = [ "CAP_CHOWN" ] ++ lib.optional (cfg.port < 1024) "CAP_NET_BIND_SERVICE"; 620 + in 621 + { 622 + Restart = "always"; 623 + Type = "simple"; 624 + User = cfg.user; 625 + Group = cfg.group; 626 + 627 + # Hardening options 628 + RuntimeDirectory = "headscale"; 629 + # Allow headscale group access so users can be added and use the CLI. 630 + RuntimeDirectoryMode = "0750"; 631 + 632 + StateDirectory = "headscale"; 633 + StateDirectoryMode = "0750"; 634 + 635 + ProtectSystem = "strict"; 636 + ProtectHome = true; 637 + PrivateTmp = true; 638 + PrivateDevices = true; 639 + ProtectKernelTunables = true; 640 + ProtectControlGroups = true; 641 + RestrictSUIDSGID = true; 642 + PrivateMounts = true; 643 + ProtectKernelModules = true; 644 + ProtectKernelLogs = true; 645 + ProtectHostname = true; 646 + ProtectClock = true; 647 + ProtectProc = "invisible"; 648 + ProcSubset = "pid"; 649 + RestrictNamespaces = true; 650 + RemoveIPC = true; 651 + UMask = "0077"; 652 + 653 + CapabilityBoundingSet = capabilityBoundingSet; 654 + AmbientCapabilities = capabilityBoundingSet; 655 + NoNewPrivileges = true; 656 + LockPersonality = true; 657 + RestrictRealtime = true; 658 + SystemCallFilter = [ 659 + "@system-service" 660 + "~@privileged" 661 + "@chown" 662 + ]; 663 + SystemCallArchitectures = "native"; 664 + RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX"; 665 + }; 666 + }; 667 + }; 668 + 669 + meta.maintainers = with lib.maintainers; [ 670 + kradalby 671 + misterio77 672 + ]; 673 + }
+31
nixos/profiles/server.nix
··· 8 8 let 9 9 inherit (self.inputs) unixpkgs srvos agenix tangled; 10 10 11 + json-format = pkgs.formats.json { }; 12 + 11 13 ext-if = "eth0"; 12 14 external-ip = "91.99.55.74"; 13 15 external-netmask = 27; ··· 44 46 ] 45 47 } 46 48 ''; 49 + 47 50 website-hostname = "wiro.world"; 48 51 49 52 pds-port = 3001; ··· 89 92 authelia-metrics-port = 9004; 90 93 in 91 94 { 95 + disabledModules = [ "services/networking/headscale.nix" ]; 96 + 92 97 imports = [ 93 98 srvos.nixosModules.server 94 99 srvos.nixosModules.hardware-hetzner-cloud 95 100 srvos.nixosModules.mixins-terminfo 96 101 97 102 agenix.nixosModules.default 103 + 104 + self.nixosModules.headscale 98 105 99 106 tangled.nixosModules.knot 100 107 tangled.nixosModules.spindle ··· 384 391 age.secrets.headscale-oidc-secret = { file = ../../secrets/headscale-oidc-secret.age; owner = config.services.headscale.user; }; 385 392 services.headscale = { 386 393 enable = true; 394 + package = upkgs.headscale; 387 395 388 396 port = headscale-port; 389 397 settings = { 390 398 server_url = "https://${headscale-hostname}"; 391 399 metrics_listen_addr = "127.0.0.1:${toString headscale-metrics-port}"; 392 400 401 + policy.path = json-format.generate "policy.json" { 402 + acls = [ 403 + { 404 + action = "accept"; 405 + src = [ "autogroup:member" ]; 406 + dst = [ "autogroup:self:*" ]; 407 + } 408 + ]; 409 + ssh = [ 410 + { 411 + action = "accept"; 412 + src = [ "autogroup:member" ]; 413 + dst = [ "autogroup:self" ]; 414 + # Adding root here is privilege escalation as a feature :) 415 + users = [ "autogroup:nonroot" ]; 416 + } 417 + ]; 418 + }; 419 + 393 420 # disable TLS 394 421 tls_cert_path = null; 395 422 tls_key_path = null; ··· 397 424 dns = { 398 425 magic_dns = true; 399 426 base_domain = "net.wiro.world"; 427 + 428 + override_local_dns = true; 429 + # Quad9 nameservers 430 + nameservers.global = [ "9.9.9.9" "149.112.112.112" "2620:fe::fe" "2620:fe::9" ]; 400 431 }; 401 432 402 433 oidc = {