wip: add config file to webnsupdate
All checks were successful
/ build (push) Successful in 1m7s
/ check (clippy) (push) Successful in 15s
/ check (module-ipv4-only-test) (push) Successful in 32s
/ check (module-ipv4-test) (push) Successful in 30s
/ check (module-ipv6-only-test) (push) Successful in 32s
/ check (module-ipv6-test) (push) Successful in 30s
/ check (module-nginx-test) (push) Successful in 32s
/ check (nextest) (push) Successful in 3s
/ check (treefmt) (push) Successful in 3s
/ report-size (push) Successful in 7s

This commit is contained in:
Jalil David Salamé Messina 2025-02-05 23:59:58 +01:00
parent 29f7315f67
commit 8bfa36b837
Signed by: jalil
GPG key ID: F016B9E770737A0B
15 changed files with 543 additions and 426 deletions

28
Cargo.lock generated
View file

@ -304,7 +304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
dependencies = [ dependencies = [
"nonempty", "nonempty",
"thiserror", "thiserror 1.0.69",
] ]
[[package]] [[package]]
@ -454,6 +454,7 @@ dependencies = [
"linked-hash-map", "linked-hash-map",
"once_cell", "once_cell",
"pin-project", "pin-project",
"serde",
"similar", "similar",
] ]
@ -542,7 +543,7 @@ dependencies = [
"supports-unicode", "supports-unicode",
"terminal_size", "terminal_size",
"textwrap", "textwrap",
"thiserror", "thiserror 1.0.69",
"unicode-width", "unicode-width",
] ]
@ -948,7 +949,16 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc"
dependencies = [
"thiserror-impl 2.0.11",
] ]
[[package]] [[package]]
@ -962,6 +972,17 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "thiserror-impl"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "thread_local" name = "thread_local"
version = "1.1.8" version = "1.1.8"
@ -1162,6 +1183,7 @@ dependencies = [
"ring", "ring",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.11",
"tokio", "tokio",
"tower-http", "tower-http",
"tracing", "tracing",

View file

@ -1,5 +1,3 @@
cargo-features = ["codegen-backend"]
[package] [package]
description = "An HTTP server using HTTP basic auth to make secure calls to nsupdate" description = "An HTTP server using HTTP basic auth to make secure calls to nsupdate"
name = "webnsupdate" name = "webnsupdate"
@ -29,13 +27,14 @@ miette = { version = "7", features = ["fancy"] }
ring = { version = "0.17", features = ["std"] } ring = { version = "0.17", features = ["std"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
thiserror = "2"
tokio = { version = "1", features = ["macros", "rt", "process", "io-util"] } tokio = { version = "1", features = ["macros", "rt", "process", "io-util"] }
tower-http = { version = "0.6", features = ["validate-request"] } tower-http = { version = "0.6", features = ["validate-request"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies] [dev-dependencies]
insta = "1" insta = { version = "1", features = ["json"] }
[profile.release] [profile.release]
opt-level = "s" opt-level = "s"
@ -46,4 +45,3 @@ codegen-units = 1
[profile.dev] [profile.dev]
debug = 0 debug = 0
codegen-backend = "cranelift"

View file

@ -14,8 +14,38 @@ let
mkPackageOption mkPackageOption
types types
; ;
format = pkgs.formats.json { };
in in
{ {
# imports = [
# (lib.mkRenamedOptionModule
# [ "services" "webnsupdate" "passwordFile" ]
# [ "services" "webnsupdate" "settings" "password_file" ]
# )
# (lib.mkRenamedOptionModule
# [ "services" "webnsupdate" "keyFile" ]
# [ "services" "webnsupdate" "settings" "key_file" ]
# )
# (lib.mkRemovedOptionModule [ "services" "webnsupdate" "allowedIPVersion" ] ''
# This option was replaced with 'services.webnsupdate.settings.ip_type' which defaults to Both.
# '')
# (lib.mkRemovedOptionModule [ "services" "webnsupdate" "bindIp" ] ''
# This option was replaced with 'services.webnsupdate.settings.address' which defaults to 127.0.0.1:5353.
# '')
# (lib.mkRemovedOptionModule [ "services" "webnsupdate" "bindPort" ] ''
# This option was replaced with 'services.webnsupdate.settings.address' which defaults to 127.0.0.1:5353.
# '')
# (lib.mkRemovedOptionModule [ "services" "webnsupdate" "records" ] ''
# This option was replaced with 'services.webnsupdate.settings.records' which defaults to [].
# '')
# (lib.mkRemovedOptionModule [ "services" "webnsupdate" "recordsFile" ] ''
# This option was replaced with 'services.webnsupdate.settings.records' which defaults to [].
# '')
# (lib.mkRemovedOptionModule [ "services" "webnsupdate" "ttl" ] ''
# This option was replaced with 'services.webnsupdate.settings.ttl' which defaults to 600s.
# '')
# ];
options.services.webnsupdate = mkOption { options.services.webnsupdate = mkOption {
description = "An HTTP server for nsupdate."; description = "An HTTP server for nsupdate.";
default = { }; default = { };
@ -31,34 +61,35 @@ let
example = [ "--ip-source" ]; example = [ "--ip-source" ];
}; };
package = mkPackageOption pkgs "webnsupdate" { }; package = mkPackageOption pkgs "webnsupdate" { };
bindIp = mkOption { settings = mkOption {
description = "The webnsupdate JSON configuration";
default = { };
type = types.submodule {
freeformType = format.type;
options = {
address = mkOption {
description = '' description = ''
IP address to bind to. IP address and port to bind to.
Setting it to anything other than localhost is very insecure as Setting it to anything other than localhost is very
`webnsupdate` only supports plain HTTP and should always be behind a insecure as `webnsupdate` only supports plain HTTP and
reverse proxy. should always be behind a reverse proxy.
''; '';
type = types.str; type = types.str;
default = "localhost"; default = "127.0.0.1:5353";
example = "0.0.0.0"; example = "[::1]:5353";
}; };
bindPort = mkOption { ip_type = mkOption {
description = "Port to bind to.";
type = types.port;
default = 5353;
};
allowedIPVersion = mkOption {
description = ''The allowed IP versions to accept updates from.''; description = ''The allowed IP versions to accept updates from.'';
type = types.enum [ type = types.enum [
"both" "Both"
"ipv4-only" "Ipv4Only"
"ipv6-only" "Ipv6Only"
]; ];
default = "both"; default = "Both";
example = "ipv4-only"; example = "Ipv4Only";
}; };
passwordFile = mkOption { password_file = mkOption {
description = '' description = ''
The file where the password is stored. The file where the password is stored.
@ -67,7 +98,7 @@ let
type = types.path; type = types.path;
example = "/secrets/webnsupdate.pass"; example = "/secrets/webnsupdate.pass";
}; };
keyFile = mkOption { key_file = mkOption {
description = '' description = ''
The TSIG key that `nsupdate` should use. The TSIG key that `nsupdate` should use.
@ -79,34 +110,43 @@ let
}; };
ttl = mkOption { ttl = mkOption {
description = "The TTL that should be set on the zone records created by `nsupdate`."; description = "The TTL that should be set on the zone records created by `nsupdate`.";
type = types.ints.positive; default = {
default = 60; secs = 600;
};
example = {
secs = 600;
nanos = 50000;
};
type = types.submodule {
options = {
secs = mkOption {
description = "The TTL (in seconds) that should be set on the zone records created by `nsupdate`.";
example = 3600; example = 3600;
}; };
nanos = mkOption {
description = "The TTL (in nanoseconds) that should be set on the zone records created by `nsupdate`.";
default = 0;
example = 50000;
};
};
};
};
records = mkOption { records = mkOption {
description = '' description = ''
The fqdn of records that should be updated. The fqdn of records that should be updated.
Empty lines will be ignored, but whitespace will not be. Empty lines will be ignored, but whitespace will not be.
''; '';
type = types.nullOr types.lines; type = types.listOf types.str;
default = null; default = [ ];
example = '' example = [
example.com. "example.com."
"example.org."
example.org. "ci.example.org."
ci.example.org. ];
''; };
};
}; };
recordsFile = mkOption {
description = ''
The fqdn of records that should be updated.
Empty lines will be ignored, but whitespace will not be.
'';
type = types.nullOr types.path;
default = null;
example = "/secrets/webnsupdate.records";
}; };
user = mkOption { user = mkOption {
description = "The user to run as."; description = "The user to run as.";
@ -124,41 +164,14 @@ let
config = config =
let let
recordsFile = configFile = format.generate "webnsupdate.json" cfg.settings;
if cfg.recordsFile != null then cfg.recordsFile else pkgs.writeText "webnsrecords" cfg.records; args = lib.strings.escapeShellArgs ([ "--config=${configFile}" ] ++ cfg.extraArgs);
args = lib.strings.escapeShellArgs (
[
"--records"
recordsFile
"--key-file"
cfg.keyFile
"--password-file"
cfg.passwordFile
"--address"
cfg.bindIp
"--ip-type"
cfg.allowedIPVersion
"--port"
(builtins.toString cfg.bindPort)
"--ttl"
(builtins.toString cfg.ttl)
"--data-dir=%S/webnsupdate"
]
++ cfg.extraArgs
);
cmd = "${lib.getExe cfg.package} ${args}"; cmd = "${lib.getExe cfg.package} ${args}";
in in
lib.mkIf cfg.enable { lib.mkIf cfg.enable {
# FIXME: re-enable once I stop using the patched version of bind
# warnings = # warnings =
# lib.optional (!config.services.bind.enable) "`webnsupdate` is expected to be used alongside `bind`. This is an unsupported configuration."; # lib.optional (!config.services.bind.enable) "`webnsupdate` is expected to be used alongside `bind`. This is an unsupported configuration.";
assertions = [
{
assertion =
(cfg.records != null || cfg.recordsFile != null)
&& !(cfg.records != null && cfg.recordsFile != null);
message = "Exactly one of `services.webnsupdate.records` and `services.webnsupdate.recordsFile` must be set.";
}
];
systemd.services.webnsupdate = { systemd.services.webnsupdate = {
description = "Web interface for nsupdate."; description = "Web interface for nsupdate.";
@ -167,9 +180,10 @@ let
"network.target" "network.target"
"bind.service" "bind.service"
]; ];
preStart = "${cmd} verify"; preStart = "${lib.getExe cfg.package} verify ${configFile}";
path = [ pkgs.dig ]; path = [ pkgs.dig ];
startLimitIntervalSec = 60; startLimitIntervalSec = 60;
environment.DATA_DIR = "%S/webnsupdate";
serviceConfig = { serviceConfig = {
ExecStart = [ cmd ]; ExecStart = [ cmd ];
Type = "exec"; Type = "exec";

View file

@ -9,7 +9,7 @@
lastIPPath = "/var/lib/webnsupdate/last-ip.json"; lastIPPath = "/var/lib/webnsupdate/last-ip.json";
zoneFile = pkgs.writeText "${testDomain}.zoneinfo" '' zoneFile = pkgs.writeText "${testDomain}.zoneinfo" ''
$TTL 60 ; 1 minute $TTL 600 ; 10 minutes
$ORIGIN ${testDomain}. $ORIGIN ${testDomain}.
@ IN SOA ns1.${testDomain}. admin.${testDomain}. ( @ IN SOA ns1.${testDomain}. admin.${testDomain}. (
1 ; serial 1 ; serial
@ -73,20 +73,19 @@
webnsupdate = { webnsupdate = {
enable = true; enable = true;
bindIp = lib.mkDefault "127.0.0.1";
keyFile = "/etc/bind/rndc.key";
# test:test (user:password)
passwordFile = pkgs.writeText "webnsupdate.pass" "FQoNmuU1BKfg8qsU96F6bK5ykp2b0SLe3ZpB3nbtfZA";
package = self'.packages.webnsupdate; package = self'.packages.webnsupdate;
extraArgs = [ extraArgs = [ "-vvv" ]; # debug messages
"-vvv" # debug messages settings = {
"--ip-source=ConnectInfo" address = lib.mkDefault "127.0.0.1:5353";
key_file = "/etc/bind/rndc.key";
password_file = pkgs.writeText "webnsupdate.pass" "FQoNmuU1BKfg8qsU96F6bK5ykp2b0SLe3ZpB3nbtfZA"; # test:test
ip_source = lib.mkDefault "ConnectInfo";
records = [
"test1.${testDomain}."
"test2.${testDomain}."
"test3.${testDomain}."
]; ];
records = '' };
test1.${testDomain}.
test2.${testDomain}.
test3.${testDomain}.
'';
}; };
}; };
}; };
@ -97,7 +96,7 @@
webnsupdate-ipv4-machine webnsupdate-ipv4-machine
]; ];
config.services.webnsupdate.bindIp = "::1"; config.services.webnsupdate.settings.address = "[::1]:5353";
}; };
webnsupdate-nginx-machine = webnsupdate-nginx-machine =
@ -109,26 +108,26 @@
config.services = { config.services = {
# Use default IP Source # Use default IP Source
webnsupdate.extraArgs = lib.mkForce [ "-vvv" ]; # debug messages webnsupdate.settings.ip_source = "RightmostXForwardedFor";
nginx = { nginx = {
enable = true; enable = true;
recommendedProxySettings = true; recommendedProxySettings = true;
virtualHosts.webnsupdate.locations."/".proxyPass = virtualHosts.webnsupdate.locations."/".proxyPass =
"http://${config.services.webnsupdate.bindIp}:${builtins.toString config.services.webnsupdate.bindPort}"; "http://${config.services.webnsupdate.settings.address}";
}; };
}; };
}; };
webnsupdate-ipv4-only-machine = { webnsupdate-ipv4-only-machine = {
imports = [ webnsupdate-nginx-machine ]; imports = [ webnsupdate-nginx-machine ];
config.services.webnsupdate.allowedIPVersion = "ipv4-only"; config.services.webnsupdate.settings.ip_type = "Ipv4Only";
}; };
webnsupdate-ipv6-only-machine = { webnsupdate-ipv6-only-machine = {
imports = [ webnsupdate-nginx-machine ]; imports = [ webnsupdate-nginx-machine ];
config.services.webnsupdate.allowedIPVersion = "ipv6-only"; config.services.webnsupdate.settings.ip_type = "Ipv6Only";
}; };
# "A" for IPv4, "AAAA" for IPv6, "ANY" for any # "A" for IPv4, "AAAA" for IPv6, "ANY" for any
@ -158,9 +157,9 @@
STATIC_DOMAINS: list[str] = ["${testDomain}", "ns1.${testDomain}", "nsupdate.${testDomain}"] STATIC_DOMAINS: list[str] = ["${testDomain}", "ns1.${testDomain}", "nsupdate.${testDomain}"]
DYNAMIC_DOMAINS: list[str] = ["test1.${testDomain}", "test2.${testDomain}", "test3.${testDomain}"] DYNAMIC_DOMAINS: list[str] = ["test1.${testDomain}", "test2.${testDomain}", "test3.${testDomain}"]
def dig_cmd(domain: str, record: str, ip: str | None) -> str: def dig_cmd(domain: str, record: str, ip: str | None) -> tuple[str, str]:
match_ip = "" if ip is None else f"\\s\\+60\\s\\+IN\\s\\+{record}\\s\\+{ip}$" match_ip = "" if ip is None else f"\\s\\+600\\s\\+IN\\s\\+{record}\\s\\+{ip}$"
return f"dig @localhost {record} {domain} +noall +answer | grep '^{domain}.{match_ip}'" return f"dig @localhost {record} {domain} +noall +answer", f"grep '^{domain}.{match_ip}'"
def curl_cmd(domain: str, identity: str, path: str, query: dict[str, str]) -> str: def curl_cmd(domain: str, identity: str, path: str, query: dict[str, str]) -> str:
from urllib.parse import urlencode from urllib.parse import urlencode
@ -168,10 +167,16 @@
return f"{CURL} -u {identity} -X GET 'http://{domain}{"" if NGINX else ":5353"}/{path}{q}'" return f"{CURL} -u {identity} -X GET 'http://{domain}{"" if NGINX else ":5353"}/{path}{q}'"
def domain_available(domain: str, record: str, ip: str | None=None): def domain_available(domain: str, record: str, ip: str | None=None):
machine.succeed(dig_cmd(domain, record, ip)) dig, grep = dig_cmd(domain, record, ip)
rc, output = machine.execute(dig)
print(f"{dig}[{rc}]: {output}")
machine.succeed(f"{dig} | {grep}")
def domain_missing(domain: str, record: str, ip: str | None=None): def domain_missing(domain: str, record: str, ip: str | None=None):
machine.fail(dig_cmd(domain, record, ip)) dig, grep = dig_cmd(domain, record, ip)
rc, output = machine.execute(dig)
print(f"{dig}[{rc}]: {output}")
machine.fail(f"{dig} | {grep}")
def update_records(domain: str="localhost", /, *, path: str="update", **kwargs): def update_records(domain: str="localhost", /, *, path: str="update", **kwargs):
machine.succeed(curl_cmd(domain, "test:test", path, kwargs)) machine.succeed(curl_cmd(domain, "test:test", path, kwargs))

217
src/config.rs Normal file
View file

@ -0,0 +1,217 @@
use std::{
fs::File,
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
path::PathBuf,
time::Duration,
};
use axum_client_ip::SecureClientIpSource;
use miette::{Context, IntoDiagnostic};
#[derive(Debug, Default, Clone, Copy, serde::Deserialize, serde::Serialize)]
pub enum IpType {
#[default]
Both,
Ipv4Only,
Ipv6Only,
}
impl IpType {
pub fn valid_for_type(self, ip: IpAddr) -> bool {
match self {
IpType::Both => true,
IpType::Ipv4Only => ip.is_ipv4(),
IpType::Ipv6Only => ip.is_ipv6(),
}
}
}
impl std::fmt::Display for IpType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IpType::Both => f.write_str("both"),
IpType::Ipv4Only => f.write_str("ipv4-only"),
IpType::Ipv6Only => f.write_str("ipv6-only"),
}
}
}
impl std::str::FromStr for IpType {
type Err = miette::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"both" => Ok(Self::Both),
"ipv4-only" => Ok(Self::Ipv4Only),
"ipv6-only" => Ok(Self::Ipv6Only),
_ => miette::bail!("expected one of 'ipv4-only', 'ipv6-only' or 'both', got '{s}'"),
}
}
}
/// Webserver settings
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct Server {
/// Ip address and port of the server
#[serde(default = "default_address")]
pub address: SocketAddr,
}
/// Password settings
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct Password {
/// File containing password to match against
///
/// Should be of the format `username:password` and contain a single password
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_file: Option<PathBuf>,
/// Salt to get more unique hashed passwords and prevent table based attacks
#[serde(default = "default_salt")]
pub salt: Box<str>,
}
/// Records settings
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct Records {
/// Time To Live (in seconds) to set on the DNS records
#[serde(default = "default_ttl")]
pub ttl: Duration,
/// List of domain names for which to update the IP when an update is requested
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[allow(clippy::struct_field_names)]
pub records: Vec<Box<str>>,
/// If provided, when an IPv6 prefix is provided with an update, this will be used to derive
/// the full IPv6 address of the client
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<Ipv6Addr>,
/// If a client id is provided the ipv6 update will be ignored (only the prefix will be used).
/// This domain will point to the ipv6 address instead of the address derived from the client
/// id (usually this is the router).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub router_domain: Option<Box<str>>,
/// Set client IP source
///
/// see: <https://docs.rs/axum-client-ip/latest/axum_client_ip/enum.SecureClientIpSource.html>
#[serde(default = "default_ip_source")]
pub ip_source: SecureClientIpSource,
/// Set which IPs to allow updating (ipv4, ipv6 or both)
#[serde(default = "default_ip_type")]
pub ip_type: IpType,
/// Keyfile `nsupdate` should use
///
/// If specified, then `webnsupdate` must have read access to the file
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_file: Option<PathBuf>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct Config {
/// Server Configuration
#[serde(flatten)]
pub server: Server,
/// Password Configuration
#[serde(flatten)]
pub password: Password,
/// Records Configuration
#[serde(flatten)]
pub records: Records,
}
impl Config {
/// Load the configuration without verifying it
pub fn load(path: &std::path::Path) -> miette::Result<Self> {
serde_json::from_reader::<File, Self>(
File::open(path)
.into_diagnostic()
.wrap_err_with(|| format!("failed open {}", path.display()))?,
)
.into_diagnostic()
.wrap_err_with(|| format!("failed to load configuration from {}", path.display()))
}
/// Ensure only a verified configuration is returned
pub fn verified(self) -> miette::Result<Self> {
self.verify()?;
Ok(self)
}
/// Verify the configuration
pub fn verify(&self) -> Result<(), Invalid> {
let mut invalid_records: Vec<miette::Error> = self
.records
.records
.iter()
.filter_map(|record| crate::records::validate_record_str(record).err())
.collect();
invalid_records.extend(
self.records
.router_domain
.as_ref()
.and_then(|domain| crate::records::validate_record_str(domain).err()),
);
let err = Invalid { invalid_records };
if err.invalid_records.is_empty() {
Ok(())
} else {
Err(err)
}
}
}
#[derive(Debug, miette::Diagnostic, thiserror::Error)]
#[error("the configuration was invalid")]
pub struct Invalid {
#[related]
pub invalid_records: Vec<miette::Error>,
}
// --- Default Values (sadly serde doesn't have a way to specify a constant as a default value) ---
fn default_ttl() -> Duration {
super::DEFAULT_TTL
}
fn default_salt() -> Box<str> {
super::DEFAULT_SALT.into()
}
fn default_address() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 5353)
}
fn default_ip_source() -> SecureClientIpSource {
SecureClientIpSource::RightmostXForwardedFor
}
fn default_ip_type() -> IpType {
IpType::Both
}
#[test]
fn default_values_config_snapshot() {
let config: Config = serde_json::from_str("{}").unwrap();
insta::assert_json_snapshot!(config, @r#"
{
"address": "127.0.0.1:5353",
"salt": "UpdateMyDNS",
"ttl": {
"secs": 60,
"nanos": 0
},
"ip_source": "RightmostXForwardedFor",
"ip_type": "Both"
}
"#);
}

View file

@ -10,16 +10,18 @@ use axum::{
routing::get, routing::get,
Router, Router,
}; };
use axum_client_ip::{SecureClientIp, SecureClientIpSource}; use axum_client_ip::SecureClientIp;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use clap_verbosity_flag::Verbosity; use clap_verbosity_flag::Verbosity;
use config::Config;
use http::StatusCode; use http::StatusCode;
use miette::{bail, ensure, Context, IntoDiagnostic, Result}; use miette::{bail, ensure, Context, IntoDiagnostic, Result};
use tracing::{debug, error, info}; use tracing::{debug, error, info};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
mod auth; mod auth;
mod config;
mod nsupdate; mod nsupdate;
mod password; mod password;
mod records; mod records;
@ -32,120 +34,52 @@ struct Opts {
#[command(flatten)] #[command(flatten)]
verbosity: Verbosity<clap_verbosity_flag::InfoLevel>, verbosity: Verbosity<clap_verbosity_flag::InfoLevel>,
/// Ip address of the server
#[arg(long, default_value = "127.0.0.1")]
address: IpAddr,
/// Port of the server
#[arg(long, default_value_t = 5353)]
port: u16,
/// File containing password to match against
///
/// Should be of the format `username:password` and contain a single password
#[arg(long)]
password_file: Option<PathBuf>,
/// Salt to get more unique hashed passwords and prevent table based attacks
#[arg(long, default_value = DEFAULT_SALT)]
salt: String,
/// Time To Live (in seconds) to set on the DNS records
#[arg(long, default_value_t = DEFAULT_TTL.as_secs())]
ttl: u64,
/// Data directory /// Data directory
#[arg(long, default_value = ".")] #[arg(long, env, default_value = ".")]
data_dir: PathBuf, data_dir: PathBuf,
/// File containing the records that should be updated when an update request is made
///
/// There should be one record per line:
///
/// ```text
/// example.com.
/// mail.example.com.
/// ```
#[arg(long)]
records: PathBuf,
/// Keyfile `nsupdate` should use
///
/// If specified, then `webnsupdate` must have read access to the file
#[arg(long)]
key_file: Option<PathBuf>,
/// Allow not setting a password /// Allow not setting a password
#[arg(long)] #[arg(long)]
insecure: bool, insecure: bool,
/// Set client IP source #[clap(flatten)]
/// config_or_command: ConfigOrCommand,
/// see: <https://docs.rs/axum-client-ip/latest/axum_client_ip/enum.SecureClientIpSource.html> }
#[clap(long, default_value = "RightmostXForwardedFor")]
ip_source: SecureClientIpSource,
/// Set which IPs to allow updating #[derive(clap::Args, Debug)]
#[clap(long, default_value_t = IpType::Both)] #[group(multiple = false)]
ip_type: IpType, struct ConfigOrCommand {
/// Path to the configuration file
#[arg(long, short)]
config: Option<PathBuf>,
#[clap(subcommand)] #[clap(subcommand)]
subcommand: Option<Cmd>, subcommand: Option<Cmd>,
} }
#[derive(Debug, Default, Clone, Copy)] impl ConfigOrCommand {
enum IpType { pub fn take(&mut self) -> (Option<PathBuf>, Option<Cmd>) {
#[default] (self.config.take(), self.subcommand.take())
Both,
IPv4Only,
IPv6Only,
}
impl IpType {
fn valid_for_type(self, ip: IpAddr) -> bool {
match self {
IpType::Both => true,
IpType::IPv4Only => ip.is_ipv4(),
IpType::IPv6Only => ip.is_ipv6(),
}
}
}
impl std::fmt::Display for IpType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IpType::Both => f.write_str("both"),
IpType::IPv4Only => f.write_str("ipv4-only"),
IpType::IPv6Only => f.write_str("ipv6-only"),
}
}
}
impl std::str::FromStr for IpType {
type Err = miette::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"both" => Ok(Self::Both),
"ipv4-only" => Ok(Self::IPv4Only),
"ipv6-only" => Ok(Self::IPv6Only),
_ => bail!("expected one of 'ipv4-only', 'ipv6-only' or 'both', got '{s}'"),
}
} }
} }
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
enum Cmd { enum Cmd {
Mkpasswd(password::Mkpasswd), Mkpasswd(password::Mkpasswd),
/// Verify the records file /// Verify the configuration file
Verify, Verify {
/// Path to the configuration file
config: PathBuf,
},
} }
impl Cmd { impl Cmd {
pub fn process(self, args: &Opts) -> Result<()> { pub fn process(self, args: &Opts) -> Result<()> {
match self { match self {
Cmd::Mkpasswd(mkpasswd) => mkpasswd.process(args), Cmd::Mkpasswd(mkpasswd) => mkpasswd.process(args),
Cmd::Verify => records::load(&args.records).map(drop), Cmd::Verify { config } => config::Config::load(&config) // load config
.and_then(Config::verified) // verify config
.map(drop), // ignore config data
} }
} }
} }
@ -168,7 +102,7 @@ struct AppState<'a> {
last_ips: std::sync::Arc<tokio::sync::Mutex<SavedIPs>>, last_ips: std::sync::Arc<tokio::sync::Mutex<SavedIPs>>,
/// The IP type for which to allow updates /// The IP type for which to allow updates
ip_type: IpType, ip_type: config::IpType,
} }
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
@ -211,33 +145,38 @@ impl SavedIPs {
} }
impl AppState<'static> { impl AppState<'static> {
fn from_args(args: &Opts) -> miette::Result<Self> { fn from_args(args: &Opts, config: &config::Config) -> miette::Result<Self> {
let Opts { let Opts {
verbosity: _, verbosity: _,
address: _,
port: _,
password_file: _,
data_dir, data_dir,
key_file,
insecure, insecure,
subcommand: _, config_or_command: _,
records,
salt: _,
ttl,
ip_source: _,
ip_type,
} = args; } = args;
// Set state let config::Records {
let ttl = Duration::from_secs(*ttl); ttl,
records,
client_id: _,
router_domain: _,
ip_source: _,
ip_type,
key_file,
} = &config.records;
// Use last registered IP address if available // Use last registered IP address if available
let ip_file = Box::leak(data_dir.join("last-ip.json").into_boxed_path()); let ip_file = Box::leak(data_dir.join("last-ip.json").into_boxed_path());
// Leak DNS records
let records: &[&str] = &*Vec::leak(
records
.iter()
.map(|record| &*Box::leak(record.clone()))
.collect(),
);
let state = AppState { let state = AppState {
ttl, ttl: *ttl,
// Load DNS records records,
records: records::load_no_verify(records)?,
// Load keyfile // Load keyfile
key_file: key_file key_file: key_file
.as_deref() .as_deref()
@ -340,34 +279,37 @@ fn main() -> Result<()> {
debug!("{args:?}"); debug!("{args:?}");
let config = match args.config_or_command.take() {
// process subcommand // process subcommand
if let Some(cmd) = args.subcommand.take() { (None, Some(cmd)) => return cmd.process(&args),
return cmd.process(&args); (Some(path), None) => {
let config = config::Config::load(&path)?;
if let Err(err) = config.verify() {
error!("failed to verify configuration: {err}");
} }
config
}
(None, None) | (Some(_), Some(_)) => unreachable!(
"bad state, one of config or subcommand should be available (clap should enforce this)"
),
};
// Initialize state // Initialize state
let state = AppState::from_args(&args)?; let state = AppState::from_args(&args, &config)?;
let Opts { let Opts {
verbosity: _, verbosity: _,
address: ip,
port,
password_file,
data_dir: _, data_dir: _,
key_file: _,
insecure, insecure,
subcommand: _, config_or_command: _,
records: _,
salt,
ttl: _,
ip_source,
ip_type,
} = args; } = args;
info!("checking environment"); info!("checking environment");
// Load password hash // Load password hash
let password_hash = password_file let password_hash = config
.password
.password_file
.map(|path| -> miette::Result<_> { .map(|path| -> miette::Result<_> {
let path = path.as_path(); let path = path.as_path();
let pass = std::fs::read_to_string(path).into_diagnostic()?; let pass = std::fs::read_to_string(path).into_diagnostic()?;
@ -398,11 +340,13 @@ fn main() -> Result<()> {
// Update DNS record with previous IPs (if available) // Update DNS record with previous IPs (if available)
let ips = state.last_ips.lock().await.clone(); let ips = state.last_ips.lock().await.clone();
let actions = ips let mut actions = ips
.ips() .ips()
.filter(|ip| ip_type.valid_for_type(*ip)) .filter(|ip| config.records.ip_type.valid_for_type(*ip))
.flat_map(|ip| nsupdate::Action::from_records(ip, state.ttl, state.records)); .flat_map(|ip| nsupdate::Action::from_records(ip, state.ttl, state.records))
.peekable();
if actions.peek().is_some() {
match nsupdate::nsupdate(state.key_file, actions).await { match nsupdate::nsupdate(state.key_file, actions).await {
Ok(status) => { Ok(status) => {
if !status.success() { if !status.success() {
@ -417,24 +361,30 @@ fn main() -> Result<()> {
.wrap_err("failed to update records with previous IP"); .wrap_err("failed to update records with previous IP");
} }
} }
}
// Create services // Create services
let app = Router::new().route("/update", get(update_records)); let app = Router::new().route("/update", get(update_records));
// if a password is provided, validate it // if a password is provided, validate it
let app = if let Some(pass) = password_hash { let app = if let Some(pass) = password_hash {
app.layer(auth::layer(Box::leak(pass), String::leak(salt))) app.layer(auth::layer(
Box::leak(pass),
Box::leak(config.password.salt),
))
} else { } else {
app app
} }
.layer(ip_source.into_extension()) .layer(config.records.ip_source.into_extension())
.with_state(state); .with_state(state);
let config::Server { address } = config.server;
// Start services // Start services
info!("starting listener on {ip}:{port}"); info!("starting listener on {address}");
let listener = tokio::net::TcpListener::bind(SocketAddr::new(ip, port)) let listener = tokio::net::TcpListener::bind(address)
.await .await
.into_diagnostic()?; .into_diagnostic()?;
info!("listening on {ip}:{port}"); info!("listening on {address}");
axum::serve( axum::serve(
listener, listener,
app.into_make_service_with_connect_info::<SocketAddr>(), app.into_make_service_with_connect_info::<SocketAddr>(),
@ -573,6 +523,15 @@ async fn trigger_update(
state: &AppState<'static>, state: &AppState<'static>,
) -> axum::response::Result<&'static str> { ) -> axum::response::Result<&'static str> {
let actions = nsupdate::Action::from_records(ip, state.ttl, state.records); let actions = nsupdate::Action::from_records(ip, state.ttl, state.records);
if actions.len() == 0 {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"Nothing to do (e.g. we are ipv4-only but an ipv6 update was requested)",
)
.into());
}
match nsupdate::nsupdate(state.key_file, actions).await { match nsupdate::nsupdate(state.key_file, actions).await {
Ok(status) if status.success() => { Ok(status) if status.success() => {
let ips = { let ips = {

View file

@ -25,7 +25,7 @@ impl<'a> Action<'a> {
to: IpAddr, to: IpAddr,
ttl: Duration, ttl: Duration,
records: &'a [&'a str], records: &'a [&'a str],
) -> impl IntoIterator<Item = Self> + 'a { ) -> impl IntoIterator<Item = Self> + std::iter::ExactSizeIterator + 'a {
records records
.iter() .iter()
.map(move |&domain| Action::Reassign { domain, to, ttl }) .map(move |&domain| Action::Reassign { domain, to, ttl })
@ -91,7 +91,7 @@ fn update_ns_records<'a>(
) -> std::io::Result<()> { ) -> std::io::Result<()> {
writeln!(buf, "server 127.0.0.1")?; writeln!(buf, "server 127.0.0.1")?;
for action in actions { for action in actions {
writeln!(buf, "{action}")?; write!(buf, "{action}")?;
} }
writeln!(buf, "send")?; writeln!(buf, "send")?;
writeln!(buf, "quit") writeln!(buf, "quit")

View file

@ -4,7 +4,7 @@
//! records //! records
use std::io::Write; use std::io::Write;
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
use std::path::Path; use std::path::PathBuf;
use base64::prelude::*; use base64::prelude::*;
use miette::{Context, IntoDiagnostic, Result}; use miette::{Context, IntoDiagnostic, Result};
@ -20,11 +20,18 @@ pub struct Mkpasswd {
/// The password /// The password
password: String, password: String,
/// An application specific value
#[arg(long, default_value = crate::DEFAULT_SALT)]
salt: String,
/// The file to write the password to
password_file: Option<PathBuf>,
} }
impl Mkpasswd { impl Mkpasswd {
pub fn process(self, args: &crate::Opts) -> Result<()> { pub fn process(self, _args: &crate::Opts) -> Result<()> {
mkpasswd(self, args.password_file.as_deref(), &args.salt) mkpasswd(self)
} }
} }
@ -45,13 +52,16 @@ pub fn hash_identity(username: &str, password: &str, salt: &str) -> Digest {
} }
pub fn mkpasswd( pub fn mkpasswd(
Mkpasswd { username, password }: Mkpasswd, Mkpasswd {
password_file: Option<&Path>, username,
salt: &str, password,
salt,
password_file,
}: Mkpasswd,
) -> miette::Result<()> { ) -> miette::Result<()> {
let hash = hash_identity(&username, &password, salt); let hash = hash_identity(&username, &password, &salt);
let encoded = BASE64_URL_SAFE_NO_PAD.encode(hash.as_ref()); let encoded = BASE64_URL_SAFE_NO_PAD.encode(hash.as_ref());
let Some(path) = password_file else { let Some(path) = password_file.as_deref() else {
println!("{encoded}"); println!("{encoded}");
return Ok(()); return Ok(());
}; };

View file

@ -1,52 +1,9 @@
//! Deal with the DNS records //! Deal with the DNS records
use std::path::Path; use miette::{ensure, miette, LabeledSpan, Result};
use miette::{ensure, miette, Context, IntoDiagnostic, LabeledSpan, NamedSource, Result}; pub fn validate_record_str(record: &str) -> Result<()> {
validate_line(0, record).map_err(|err| err.with_source_code(String::from(record)))
/// Loads and verifies the records from a file
pub fn load(path: &Path) -> Result<()> {
let records = std::fs::read_to_string(path)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read records from {}", path.display()))?;
verify(&records, path)?;
Ok(())
}
/// Load records without verifying them
pub fn load_no_verify(path: &Path) -> Result<&'static [&'static str]> {
let records = std::fs::read_to_string(path)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read records from {}", path.display()))?;
if let Err(err) = verify(&records, path) {
tracing::error!("Failed to verify records: {err}");
}
// leak memory: we only do this here and it prevents a bunch of allocations
let records: &str = records.leak();
let records: Box<[&str]> = records.lines().collect();
Ok(Box::leak(records))
}
/// Verifies that a list of records is valid
pub fn verify(data: &str, path: &Path) -> Result<()> {
let mut offset = 0usize;
for line in data.lines() {
validate_line(offset, line).map_err(|err| {
err.with_source_code(NamedSource::new(
path.display().to_string(),
data.to_string(),
))
})?;
offset += line.len() + 1;
}
Ok(())
} }
fn validate_line(offset: usize, line: &str) -> Result<()> { fn validate_line(offset: usize, line: &str) -> Result<()> {
@ -156,7 +113,7 @@ fn validate_octet(offset: usize, octet: u8) -> Result<()> {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use crate::records::verify; use crate::records::validate_record_str;
macro_rules! assert_miette_snapshot { macro_rules! assert_miette_snapshot {
($diag:expr) => {{ ($diag:expr) => {{
@ -180,104 +137,51 @@ mod test {
#[test] #[test]
fn valid_records() -> miette::Result<()> { fn valid_records() -> miette::Result<()> {
verify( for record in [
"\ "example.com.",
example.com.\n\ "example.org.",
example.org.\n\ "example.net.",
example.net.\n\ "subdomain.example.com.",
subdomain.example.com.\n\ ] {
", validate_record_str(record)?;
std::path::Path::new("test_records_valid"), }
) Ok(())
} }
#[test] #[test]
fn hostname_too_long() { fn hostname_too_long() {
let err = verify( let err = validate_record_str("example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.net.").unwrap_err();
"\
example.com.\n\
example.org.\n\
example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.net.\n\
subdomain.example.com.\n\
",
std::path::Path::new("test_records_invalid"),
)
.unwrap_err();
assert_miette_snapshot!(err); assert_miette_snapshot!(err);
} }
#[test] #[test]
fn not_fqd() { fn not_fqd() {
let err = verify( let err = validate_record_str("example.net").unwrap_err();
"\
example.com.\n\
example.org.\n\
example.net\n\
subdomain.example.com.\n\
",
std::path::Path::new("test_records_invalid"),
)
.unwrap_err();
assert_miette_snapshot!(err); assert_miette_snapshot!(err);
} }
#[test] #[test]
fn empty_label() { fn empty_label() {
let err = verify( let err = validate_record_str("name..example.org.").unwrap_err();
"\
example.com.\n\
name..example.org.\n\
example.net.\n\
subdomain.example.com.\n\
",
std::path::Path::new("test_records_invalid"),
)
.unwrap_err();
assert_miette_snapshot!(err); assert_miette_snapshot!(err);
} }
#[test] #[test]
fn label_too_long() { fn label_too_long() {
let err = verify( let err = validate_record_str("name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org.").unwrap_err();
"\
example.com.\n\
name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org.\n\
example.net.\n\
subdomain.example.com.\n\
",
std::path::Path::new("test_records_invalid"),
)
.unwrap_err();
assert_miette_snapshot!(err); assert_miette_snapshot!(err);
} }
#[test] #[test]
fn invalid_ascii() { fn invalid_ascii() {
let err = verify( let err = validate_record_str("name.this-is-not-ascii-ß.example.org.").unwrap_err();
"\
example.com.\n\
name.this-is-not-ascii-ß.example.org.\n\
example.net.\n\
subdomain.example.com.\n\
",
std::path::Path::new("test_records_invalid"),
)
.unwrap_err();
assert_miette_snapshot!(err); assert_miette_snapshot!(err);
} }
#[test] #[test]
fn invalid_octet() { fn invalid_octet() {
let err = verify( let err =
"\ validate_record_str("name.this-character:-is-not-allowed.example.org.").unwrap_err();
example.com.\n\
name.this-character:-is-not-allowed.example.org.\n\
example.net.\n\
subdomain.example.com.\n\
",
std::path::Path::new("test_records_invalid"),
)
.unwrap_err();
assert_miette_snapshot!(err); assert_miette_snapshot!(err);
} }
} }

View file

@ -6,11 +6,9 @@ expression: out
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\ ]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
× empty label × empty label
╭─[test_records_invalid:2:6] ╭────
1 │ example.com. 1 │ name..example.org.
2 │ name..example.org.
· ▲ · ▲
· ╰── label · ╰── label
3 │ example.net.
╰──── ╰────
help: each label should have at least one character help: each label should have at least one character

View file

@ -6,11 +6,9 @@ expression: out
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\ ]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
× hostname too long (260 octets) × hostname too long (260 octets)
╭─[test_records_invalid:3:1] ╭────
2 │ example.org. 1 │ example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.net.
3 │ example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.net.
· ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── · ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
· ╰── this line · ╰── this line
4 │ subdomain.example.com.
╰──── ╰────
help: fully qualified domain names can be at most 255 characters long help: fully qualified domain names can be at most 255 characters long

View file

@ -6,11 +6,9 @@ expression: out
]8;;https://en.wikipedia.org/wiki/Hostname#Syntax\(link)]8;;\ ]8;;https://en.wikipedia.org/wiki/Hostname#Syntax\(link)]8;;\
× invalid octet: '\xc3' × invalid octet: '\xc3'
╭─[test_records_invalid:2:24] ╭────
1 │ example.com. 1 │ name.this-is-not-ascii-ß.example.org.
2 │ name.this-is-not-ascii-ß.example.org.
· ┬ · ┬
· ╰── octet · ╰── octet
3 │ example.net.
╰──── ╰────
help: we only accept ascii characters help: we only accept ascii characters

View file

@ -6,11 +6,9 @@ expression: out
]8;;https://en.wikipedia.org/wiki/Hostname#Syntax\(link)]8;;\ ]8;;https://en.wikipedia.org/wiki/Hostname#Syntax\(link)]8;;\
× invalid octet: ':' × invalid octet: ':'
╭─[test_records_invalid:2:20] ╭────
1 │ example.com. 1 │ name.this-character:-is-not-allowed.example.org.
2 │ name.this-character:-is-not-allowed.example.org.
· ┬ · ┬
· ╰── octet · ╰── octet
3 │ example.net.
╰──── ╰────
help: hostnames are only allowed to contain characters in [a-zA-Z0-9_-] help: hostnames are only allowed to contain characters in [a-zA-Z0-9_-]

View file

@ -6,11 +6,9 @@ expression: out
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\ ]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
× label too long (78 octets) × label too long (78 octets)
╭─[test_records_invalid:2:6] ╭────
1 │ example.com. 1 │ name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org.
2 │ name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org.
· ───────────────────────────────────────┬────────────────────────────────────── · ───────────────────────────────────────┬──────────────────────────────────────
· ╰── label · ╰── label
3 │ example.net.
╰──── ╰────
help: labels should be at most 63 octets help: labels should be at most 63 octets

View file

@ -6,11 +6,9 @@ expression: out
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\ ]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
× not a fully qualified domain name × not a fully qualified domain name
╭─[test_records_invalid:3:11] ╭────
2 │ example.org. 1 │ example.net
3 │ example.net
· ┬ · ┬
· ╰── last character · ╰── last character
4 │ subdomain.example.com.
╰──── ╰────
help: hostname should be a fully qualified domain name (end with a '.') help: hostname should be a fully qualified domain name (end with a '.')