diff --git a/README.md b/README.md index c67e0f87626..75042245556 100644 --- a/README.md +++ b/README.md @@ -416,6 +416,13 @@ These settings can be changed at any time. #### Network settings +* `settings.network.hostname`: The desired hostname of the system. + **Important note for all Kubernetes variants:** Changing this setting at runtime (not via user data) can cause issues with kubelet registration, as hostname is closely tied to the identity of the system for both registration and certificates/authorization purposes. + +Most users don't need to change this setting as the following defaults work for the majority of use cases. +If this setting isn't set we attempt to use DNS reverse lookup for the hostname. +If the lookup is unsuccessful, the IP of the node is used in the format `ip-X-X-X-X`. + ##### Proxy settings These settings will configure the proxying behavior of the following services: diff --git a/Release.toml b/Release.toml index 29352249133..bc1eade3554 100644 --- a/Release.toml +++ b/Release.toml @@ -59,3 +59,7 @@ version = "1.1.4" "migrate_v1.1.3_kubelet-cpu-manager.lz4", ] "(1.1.3, 1.1.4)" = [] +"(1.1.4, 1.1.5)" = [ + "migrate_v1.1.5_hostname-setting.lz4", + "migrate_v1.1.5_hostname-setting-metadata.lz4", +] diff --git a/packages/release/hostname-env b/packages/release/hostname-env new file mode 100644 index 00000000000..5286b86197e --- /dev/null +++ b/packages/release/hostname-env @@ -0,0 +1 @@ +HOSTNAME={{settings.network.hostname}} diff --git a/packages/release/release.spec b/packages/release/release.spec index 104d4b0e358..89a7901bb80 100644 --- a/packages/release/release.spec +++ b/packages/release/release.spec @@ -14,6 +14,7 @@ Source99: release-tmpfiles.conf Source200: motd.template Source201: proxy-env +Source202: hostname-env Source1000: eth0.xml Source1001: multi-user.target @@ -21,6 +22,7 @@ Source1002: configured.target Source1003: preconfigured.target Source1004: activate-configured.service Source1005: activate-multi-user.service +Source1011: set-hostname.service # Mounts for writable local storage. Source1006: var.mount @@ -108,7 +110,7 @@ EOF install -d %{buildroot}%{_cross_unitdir} install -p -m 0644 \ %{S:1001} %{S:1002} %{S:1003} %{S:1004} %{S:1005} \ - %{S:1006} %{S:1007} %{S:1008} %{S:1009} %{S:1010} \ + %{S:1006} %{S:1007} %{S:1008} %{S:1009} %{S:1010} %{S:1011} \ %{S:1015} %{S:1040} %{S:1041} %{S:1060} %{S:1061} %{S:1062} \ %{buildroot}%{_cross_unitdir} @@ -129,6 +131,7 @@ install -p -m 0644 ${LICENSEPATH}.mount %{buildroot}%{_cross_unitdir} install -d %{buildroot}%{_cross_templatedir} install -p -m 0644 %{S:200} %{buildroot}%{_cross_templatedir}/motd install -p -m 0644 %{S:201} %{buildroot}%{_cross_templatedir}/proxy-env +install -p -m 0644 %{S:202} %{buildroot}%{_cross_templatedir}/hostname-env install -d %{buildroot}%{_cross_udevrulesdir} install -p -m 0644 %{S:1016} %{buildroot}%{_cross_udevrulesdir}/61-mount-cdrom.rules @@ -163,9 +166,11 @@ ln -s %{_cross_unitdir}/preconfigured.target %{buildroot}%{_cross_unitdir}/defau %{_cross_unitdir}/*-kernels.mount %{_cross_unitdir}/*-licenses.mount %{_cross_unitdir}/var-lib-bottlerocket.mount +%{_cross_unitdir}/set-hostname.service %dir %{_cross_templatedir} %{_cross_templatedir}/motd %{_cross_templatedir}/proxy-env +%{_cross_templatedir}/hostname-env %{_cross_udevrulesdir}/61-mount-cdrom.rules %changelog diff --git a/packages/release/set-hostname.service b/packages/release/set-hostname.service new file mode 100644 index 00000000000..40c765db536 --- /dev/null +++ b/packages/release/set-hostname.service @@ -0,0 +1,14 @@ +[Unit] +Description=Sets the hostname +After=settings-applier.service +Requires=settings-applier.service + +[Service] +Type=oneshot +EnvironmentFile=/etc/network/hostname.env +ExecStart=/usr/bin/netdog set-hostname '${HOSTNAME}' +RemainAfterExit=true +StandardError=journal+console + +[Install] +WantedBy=preconfigured.target diff --git a/sources/Cargo.lock b/sources/Cargo.lock index 2db942b87a8..776ace16275 100644 --- a/sources/Cargo.lock +++ b/sources/Cargo.lock @@ -1313,6 +1313,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "hostname-setting" +version = "0.1.0" +dependencies = [ + "migration-helpers", +] + +[[package]] +name = "hostname-setting-metadata" +version = "0.1.0" +dependencies = [ + "migration-helpers", +] + [[package]] name = "http" version = "0.2.4" @@ -1853,6 +1867,7 @@ dependencies = [ name = "netdog" version = "0.1.0" dependencies = [ + "argh", "cargo-readme", "dns-lookup", "envy", diff --git a/sources/Cargo.toml b/sources/Cargo.toml index 0d21508636c..dcc048af6de 100644 --- a/sources/Cargo.toml +++ b/sources/Cargo.toml @@ -37,6 +37,8 @@ members = [ "api/migration/migrations/v1.1.2/control-container-v0-5-1", "api/migration/migrations/v1.1.3/kubelet-cpu-manager-state", "api/migration/migrations/v1.1.3/kubelet-cpu-manager", + "api/migration/migrations/v1.1.5/hostname-setting", + "api/migration/migrations/v1.1.5/hostname-setting-metadata", "bottlerocket-release", diff --git a/sources/api/migration/migrations/v1.1.5/hostname-setting-metadata/Cargo.toml b/sources/api/migration/migrations/v1.1.5/hostname-setting-metadata/Cargo.toml new file mode 100644 index 00000000000..ad473d247a6 --- /dev/null +++ b/sources/api/migration/migrations/v1.1.5/hostname-setting-metadata/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "hostname-setting-metadata" +version = "0.1.0" +authors = ["Zac Mrowicki "] +license = "Apache-2.0 OR MIT" +edition = "2018" +publish = false +# Don't rebuild crate just because of changes to README. +exclude = ["README.md"] + +[dependencies] +migration-helpers = { path = "../../../migration-helpers" } diff --git a/sources/api/migration/migrations/v1.1.5/hostname-setting-metadata/src/main.rs b/sources/api/migration/migrations/v1.1.5/hostname-setting-metadata/src/main.rs new file mode 100644 index 00000000000..4a906b60c69 --- /dev/null +++ b/sources/api/migration/migrations/v1.1.5/hostname-setting-metadata/src/main.rs @@ -0,0 +1,23 @@ +#![deny(rust_2018_idioms)] + +use migration_helpers::common_migrations::{AddMetadataMigration, SettingMetadata}; +use migration_helpers::{migrate, Result}; +use std::process; + +/// We added a new setting and generator for configuring hostname +fn run() -> Result<()> { + migrate(AddMetadataMigration(&[SettingMetadata { + metadata: &["setting-generator", "affected-services"], + setting: "settings.network.hostname", + }])) +} + +// Returning a Result from main makes it print a Debug representation of the error, but with Snafu +// we have nice Display representations of the error, so we wrap "main" (run) and print any error. +// https://github.com/shepmaster/snafu/issues/110 +fn main() { + if let Err(e) = run() { + eprintln!("{}", e); + process::exit(1); + } +} diff --git a/sources/api/migration/migrations/v1.1.5/hostname-setting/Cargo.toml b/sources/api/migration/migrations/v1.1.5/hostname-setting/Cargo.toml new file mode 100644 index 00000000000..e1288950795 --- /dev/null +++ b/sources/api/migration/migrations/v1.1.5/hostname-setting/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "hostname-setting" +version = "0.1.0" +authors = ["Zac Mrowicki "] +license = "Apache-2.0 OR MIT" +edition = "2018" +publish = false +# Don't rebuild crate just because of changes to README. +exclude = ["README.md"] + +[dependencies] +migration-helpers = { path = "../../../migration-helpers" } diff --git a/sources/api/migration/migrations/v1.1.5/hostname-setting/src/main.rs b/sources/api/migration/migrations/v1.1.5/hostname-setting/src/main.rs new file mode 100644 index 00000000000..6b65ea3d0b2 --- /dev/null +++ b/sources/api/migration/migrations/v1.1.5/hostname-setting/src/main.rs @@ -0,0 +1,24 @@ +#![deny(rust_2018_idioms)] + +use migration_helpers::common_migrations::AddPrefixesMigration; +use migration_helpers::{migrate, Result}; +use std::process; + +/// We added a new setting and generator for configuring hostname +fn run() -> Result<()> { + migrate(AddPrefixesMigration(vec![ + "settings.network.hostname", + "services.hostname", + "configuration-files.hostname", + ])) +} + +// Returning a Result from main makes it print a Debug representation of the error, but with Snafu +// we have nice Display representations of the error, so we wrap "main" (run) and print any error. +// https://github.com/shepmaster/snafu/issues/110 +fn main() { + if let Err(e) = run() { + eprintln!("{}", e); + process::exit(1); + } +} diff --git a/sources/api/netdog/Cargo.toml b/sources/api/netdog/Cargo.toml index 4ec712abbe4..cd3d8be6679 100644 --- a/sources/api/netdog/Cargo.toml +++ b/sources/api/netdog/Cargo.toml @@ -9,6 +9,7 @@ publish = false exclude = ["README.md"] [dependencies] +argh = "0.1.4" dns-lookup = "1.0" ipnet = { version = "2.0", features = ["serde"] } envy = "0.4" diff --git a/sources/api/netdog/README.md b/sources/api/netdog/README.md index 3b9aa1ec4ef..f398cb6e110 100644 --- a/sources/api/netdog/README.md +++ b/sources/api/netdog/README.md @@ -4,11 +4,16 @@ Current version: 0.1.0 ## Introduction -netdog is a small helper program for wicked, to apply network settings received from DHCP. It also -contains a subcommand `node-ip` that returns the node's current IP address in JSON format; this -subcommand is intended for use as a settings generator. +netdog is a small helper program for wicked, to apply network settings received from DHCP. It +generates `/etc/resolv.conf`, generates and sets the hostname, and persists the current IP to a +file. -It generates `/etc/resolv.conf`, sets the hostname, and persists the current IP to file. +It contains two subcommands meant for use as settings generators: +* `node-ip`: returns the node's current IP address in JSON format +* `generate-hostname`: returns the node's hostname in JSON format (it is the resolved IP or the IP + in format "ip-x-x-x-x" if resolving fails) + +The subcommand `set-hostname` sets the hostname for the system. ## Colophon diff --git a/sources/api/netdog/src/main.rs b/sources/api/netdog/src/main.rs index 8e0450cc0c5..455a3811dae 100644 --- a/sources/api/netdog/src/main.rs +++ b/sources/api/netdog/src/main.rs @@ -1,11 +1,16 @@ /*! # Introduction -netdog is a small helper program for wicked, to apply network settings received from DHCP. It also -contains a subcommand `node-ip` that returns the node's current IP address in JSON format; this -subcommand is intended for use as a settings generator. +netdog is a small helper program for wicked, to apply network settings received from DHCP. It +generates `/etc/resolv.conf`, generates and sets the hostname, and persists the current IP to a +file. -It generates `/etc/resolv.conf`, sets the hostname, and persists the current IP to file. +It contains two subcommands meant for use as settings generators: +* `node-ip`: returns the node's current IP address in JSON format +* `generate-hostname`: returns the node's hostname in JSON format (it is the resolved IP or the IP + in format "ip-x-x-x-x" if resolving fails) + +The subcommand `set-hostname` sets the hostname for the system. */ // TODO: @@ -17,6 +22,10 @@ It generates `/etc/resolv.conf`, sets the hostname, and persists the current IP #![deny(rust_2018_idioms)] +#[macro_use] +extern crate serde_plain; + +use argh::FromArgs; use dns_lookup::lookup_addr; use envy; use ipnet::IpNet; @@ -24,15 +33,16 @@ use lazy_static::lazy_static; use rand::seq::SliceRandom; use rand::thread_rng; use regex::Regex; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use snafu::ResultExt; use std::collections::BTreeSet; -use std::fmt::{self, Write}; +use std::fmt::Write; use std::fs::{self, File}; use std::io::{BufRead, BufReader}; use std::net::IpAddr; use std::path::{Path, PathBuf}; -use std::{env, process}; +use std::process; +use std::str::FromStr; static RESOLV_CONF: &str = "/etc/resolv.conf"; static KERNEL_HOSTNAME: &str = "/proc/sys/kernel/hostname"; @@ -44,204 +54,123 @@ lazy_static! { static ref LEASE_PARAM: Regex = Regex::new(r"^(?P[A-Z]+)='(?P.+)'$").unwrap(); } -/// Potential errors during netdog execution -mod error { - use envy; - use snafu::Snafu; - use std::io; - use std::net::IpAddr; - use std::path::PathBuf; - - #[derive(Debug, Snafu)] - #[snafu(visibility = "pub(super)")] - #[allow(clippy::enum_variant_names)] - pub(super) enum Error { - #[snafu(display("Failed to read lease data in '{}': {}", path.display(), source))] - LeaseReadFailed { path: PathBuf, source: io::Error }, - - #[snafu(display("Failed to parse lease data in '{}': {}", path.display(), source))] - LeaseParseFailed { path: PathBuf, source: envy::Error }, - - #[snafu(display("Failed to build resolver configuration: {}", source))] - ResolvConfBuildFailed { source: std::fmt::Error }, - - #[snafu(display("Failed to write resolver configuration to '{}': {}", path.display(), source))] - ResolvConfWriteFailed { path: PathBuf, source: io::Error }, - - #[snafu(display("Failed to resolve '{}' to hostname: {}", ip, source))] - HostnameLookupFailed { ip: IpAddr, source: io::Error }, - - #[snafu(display("Failed to write hostname to '{}': {}", path.display(), source))] - HostnameWriteFailed { path: PathBuf, source: io::Error }, - - #[snafu(display("Failed to write current IP to '{}': {}", path.display(), source))] - CurrentIpWriteFailed { path: PathBuf, source: io::Error }, - - #[snafu(display("Failed to read current IP data in '{}': {}", path.display(), source))] - CurrentIpReadFailed { path: PathBuf, source: io::Error }, - - #[snafu(display("Error serializing to JSON: '{}': {}", output, source))] - JsonSerialize { - output: String, - source: serde_json::error::Error, - }, - } -} - -type Result = std::result::Result; - -#[derive(Debug, Deserialize, PartialEq)] -#[serde(rename_all = "kebab-case")] -enum SubCommand { - Install, - Remove, - NodeIp, -} - -impl fmt::Display for SubCommand { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match *self { - SubCommand::Install => write!(f, "install"), - SubCommand::Remove => write!(f, "remove"), - SubCommand::NodeIp => write!(f, "node-ip"), - } - } +/// Stores fields extracted from a DHCP lease. +#[derive(Debug, Deserialize)] +struct LeaseInfo { + #[serde(rename = "ipaddr")] + ip_address: IpNet, + #[serde(rename = "dnsservers")] + dns_servers: BTreeSet, + #[serde(rename = "dnsdomain")] + dns_domain: Option, + #[serde(rename = "dnssearch")] + dns_search: Option>, } -#[derive(Debug, Deserialize)] +#[derive(Debug, PartialEq, Deserialize)] #[serde(rename_all = "kebab-case")] enum InterfaceName { Eth0, } -#[derive(Debug, Deserialize)] +#[derive(Debug, PartialEq, Deserialize)] #[serde(rename_all = "kebab-case")] enum InterfaceType { Dhcp, } -#[derive(Debug, Deserialize)] +#[derive(Debug, PartialEq, Deserialize)] #[serde(rename_all = "kebab-case")] enum InterfaceFamily { Ipv4, Ipv6, } +// Implement `from_str()` so argh can attempt to deserialize args into their proper types +forward_from_str_to_serde!(InterfaceName); +forward_from_str_to_serde!(InterfaceType); +forward_from_str_to_serde!(InterfaceFamily); + /// Stores user-supplied arguments. -#[derive(Debug)] +#[derive(FromArgs, PartialEq, Debug)] struct Args { - interface_name: InterfaceName, - interface_type: InterfaceType, - interface_family: InterfaceFamily, - data_file: PathBuf, + #[argh(subcommand)] + subcommand: SubCommand, } -/// Stores fields extracted from a DHCP lease. -#[derive(Debug, Deserialize)] -struct LeaseInfo { - #[serde(rename = "ipaddr")] - ip_address: IpNet, - #[serde(rename = "dnsservers")] - dns_servers: BTreeSet, - #[serde(rename = "dnsdomain")] - dns_domain: Option, - #[serde(rename = "dnssearch")] - dns_search: Option>, +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand)] +enum SubCommand { + Install(InstallArgs), + Remove(RemoveArgs), + NodeIp(NodeIpArgs), + GenerateHostname(GenerateHostnameArgs), + SetHostname(SetHostnameArgs), } -/// Informs the user about proper usage of the program and exits. -fn usage() -> ! { - let program_name = env::args().next().unwrap_or_else(|| "program".to_string()); - eprintln!( - r"Usage: {} - [ node-ip | install | remove ] - - Required for 'install' and 'remove' subcommands: - -i INTERFACE_NAME - -t INTERFACE_TYPE - -f INTERFACE_FAMILY - DATA_FILE", - program_name - ); - process::exit(2); -} +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand, name = "install")] +/// Write resolv.conf and current IP to disk +struct InstallArgs { + #[argh(option, short = 'i')] + /// name of the network interface + interface_name: InterfaceName, -/// Prints a more specific message before exiting through usage(). -fn usage_msg>(msg: S) -> ! { - eprintln!("{}\n", msg.as_ref()); - usage(); -} + #[argh(option, short = 't')] + /// network interface type + interface_type: InterfaceType, -/// Parses user arguments into a Subcommand and an Args structure. -fn parse_args(args: env::Args) -> Result<(SubCommand, Option)> { - let mut iter = args.skip(1); - let value = iter - .next() - .unwrap_or_else(|| usage_msg("Did not specify command")); - let sub_command = serde_plain::from_str::(&value) - .unwrap_or_else(|_| usage_msg(format!("Unknown command {}", value))); - - // The `node-ip` subcommand doesn't require any arguments - if sub_command == SubCommand::NodeIp { - return Ok((sub_command, None)); - }; + #[argh(option, short = 'f')] + /// network interface family (ipv4/6) + interface_family: InterfaceFamily, - let mut interface_name = None; - let mut interface_type = None; - let mut interface_family = None; - let mut data_file = None; - - while let Some(arg) = iter.next() { - match arg.as_ref() { - "-i" => { - let value = iter - .next() - .unwrap_or_else(|| usage_msg("Did not give argument to -i")); - interface_name = Some( - serde_plain::from_str::(&value) - .unwrap_or_else(|_| usage_msg(format!("Unknown interface name {}", value))), - ); - } + #[argh(positional)] + /// lease info data file + data_file: PathBuf, - "-t" => { - let value = iter - .next() - .unwrap_or_else(|| usage_msg("Did not give argument to -t")); - interface_type = Some( - serde_plain::from_str::(&value) - .unwrap_or_else(|_| usage_msg(format!("Unknown interface type {}", value))), - ); - } + #[argh(positional)] + // wicked adds `info` to the call to this program. We don't do anything with it but must + // be able to parse the option to avoid failing + /// ignored + info: Option, +} - "-f" => { - let value = iter - .next() - .unwrap_or_else(|| usage_msg("Did not give argument to -f")); - interface_family = Some( - serde_plain::from_str::(&value).unwrap_or_else(|_| { - usage_msg(format!("Unknown interface family {}", value)) - }), - ); - } +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand, name = "remove")] +// `wicked` calls `remove` with the below args and failing to parse them can cause an error in +// `wicked`. +/// Does nothing +struct RemoveArgs { + #[argh(option, short = 'i')] + /// name of the network interface + interface_name: InterfaceName, - // `wicked` may call this with "info" as the final argument, so if - // we already have a data file then we're done. - p => match data_file { - None => data_file = Some(PathBuf::from(p)), - Some(_) => break, - }, - } - } + #[argh(option, short = 't')] + /// network interface type + interface_type: InterfaceType, + + #[argh(option, short = 'f')] + /// network interface family (ipv4/6) + interface_family: InterfaceFamily, +} - Ok(( - sub_command, - Some(Args { - interface_name: interface_name.unwrap_or_else(|| usage()), - interface_type: interface_type.unwrap_or_else(|| usage()), - interface_family: interface_family.unwrap_or_else(|| usage()), - data_file: data_file.unwrap_or_else(|| usage()), - }), - )) +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand, name = "node-ip")] +/// Return the current IP address +struct NodeIpArgs {} + +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand, name = "generate-hostname")] +/// Generate hostname from DNS reverse lookup or use current IP +struct GenerateHostnameArgs {} + +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand, name = "set-hostname")] +/// Sets the hostname +struct SetHostnameArgs { + #[argh(positional)] + /// hostname for the system + hostname: String, } /// Parse lease data file into a LeaseInfo structure. @@ -290,22 +219,12 @@ fn write_resolv_conf(dns_servers: &[&IpAddr], dns_search: &Option>) Ok(()) } -/// Resolve assigned IP address and persist the result as hostname. -fn update_hostname(ip: &IpNet) -> Result<()> { - let host = - lookup_addr(&ip.addr()).with_context(|| error::HostnameLookupFailed { ip: ip.addr() })?; - fs::write(KERNEL_HOSTNAME, host).context(error::HostnameWriteFailed { - path: KERNEL_HOSTNAME, - })?; - Ok(()) -} - /// Persist the current IP address to file fn write_current_ip(ip: &IpAddr) -> Result<()> { fs::write(CURRENT_IP, ip.to_string()).context(error::CurrentIpWriteFailed { path: CURRENT_IP }) } -fn install(args: &Args) -> Result<()> { +fn install(args: InstallArgs) -> Result<()> { match ( &args.interface_name, &args.interface_type, @@ -319,14 +238,13 @@ fn install(args: &Args) -> Result<()> { dns_servers.shuffle(&mut thread_rng()); write_resolv_conf(&dns_servers, &info.dns_search)?; write_current_ip(&info.ip_address.addr())?; - update_hostname(&info.ip_address)?; } _ => eprintln!("Unhandled 'install' command: {:?}", &args), } Ok(()) } -fn remove(args: &Args) -> Result<()> { +fn remove(args: RemoveArgs) -> Result<()> { match ( &args.interface_name, &args.interface_type, @@ -342,20 +260,65 @@ fn node_ip() -> Result<()> { let ip = fs::read_to_string(CURRENT_IP).context(error::CurrentIpReadFailed { path: CURRENT_IP })?; // sundog expects JSON-serialized output - let output = serde_json::to_string(&ip).context(error::JsonSerialize { output: ip })?; + Ok(print_json(ip)?) +} + +/// Attempt to resolve assigned IP address, if unsuccessful use "ip-X-X-X-X" where X's are the +/// octets of the IP. For example, IP address 1.2.3.4 becomes the hostname "ip-1-2-3-4". No dots +/// in the hostname (hopefully) avoids any confusion for programs that may read this value. +/// +/// The result is returned as JSON. (intended for use as a settings generator) +fn generate_hostname() -> Result<()> { + let ip_string = + fs::read_to_string(CURRENT_IP).context(error::CurrentIpReadFailed { path: CURRENT_IP })?; + let ip = IpAddr::from_str(&ip_string).context(error::IpFromString { ip: &ip_string })?; + let hostname = match lookup_addr(&ip) { + Ok(hostname) => { + // if `lookup_addr()` returns the same IP as we passed it we didn't resolve anything, + // so return the string "ip-x-x-x-x" in this case + if hostname == ip_string { + format!("ip-{}", ip_string.replace(".", "-")) + } else { + hostname + } + } + Err(e) => { + eprintln!("Reverse DNS lookup failed: {}", e); + format!("ip-{}", ip_string.replace(".", "-")) + } + }; + + // sundog expects JSON-serialized output + Ok(print_json(hostname)?) +} + +/// Helper function that serializes the input to JSON and prints it +fn print_json(val: S) -> Result<()> +where + S: AsRef + Serialize, +{ + let val = val.as_ref(); + let output = serde_json::to_string(val).context(error::JsonSerialize { output: val })?; println!("{}", output); Ok(()) } +/// Sets the hostname for the system +fn set_hostname(args: SetHostnameArgs) -> Result<()> { + fs::write(KERNEL_HOSTNAME, args.hostname).context(error::HostnameWriteFailed { + path: KERNEL_HOSTNAME, + })?; + Ok(()) +} + fn run() -> Result<()> { - match parse_args(env::args())? { - (SubCommand::NodeIp, None) => node_ip()?, - (SubCommand::NodeIp, Some(_)) => { - usage_msg("Subcommand 'node-ip' doesn't support arguments") - } - (SubCommand::Install, Some(args)) => install(&args)?, - (SubCommand::Remove, Some(args)) => remove(&args)?, - (subcommand, None) => usage_msg(format!("Subcommand '{}' requires arguments", subcommand)), + let args: Args = argh::from_env(); + match args.subcommand { + SubCommand::Install(args) => install(args)?, + SubCommand::Remove(args) => remove(args)?, + SubCommand::NodeIp(_) => node_ip()?, + SubCommand::GenerateHostname(_) => generate_hostname()?, + SubCommand::SetHostname(args) => set_hostname(args)?, } Ok(()) } @@ -369,3 +332,51 @@ fn main() { process::exit(1); } } + +/// Potential errors during netdog execution +mod error { + use envy; + use snafu::Snafu; + use std::io; + use std::path::PathBuf; + + #[derive(Debug, Snafu)] + #[snafu(visibility = "pub(super)")] + #[allow(clippy::enum_variant_names)] + pub(super) enum Error { + #[snafu(display("Failed to read lease data in '{}': {}", path.display(), source))] + LeaseReadFailed { path: PathBuf, source: io::Error }, + + #[snafu(display("Failed to parse lease data in '{}': {}", path.display(), source))] + LeaseParseFailed { path: PathBuf, source: envy::Error }, + + #[snafu(display("Failed to build resolver configuration: {}", source))] + ResolvConfBuildFailed { source: std::fmt::Error }, + + #[snafu(display("Failed to write resolver configuration to '{}': {}", path.display(), source))] + ResolvConfWriteFailed { path: PathBuf, source: io::Error }, + + #[snafu(display("Failed to write hostname to '{}': {}", path.display(), source))] + HostnameWriteFailed { path: PathBuf, source: io::Error }, + + #[snafu(display("Invalid IP address '{}': {}", ip, source))] + IpFromString { + ip: String, + source: std::net::AddrParseError, + }, + + #[snafu(display("Failed to write current IP to '{}': {}", path.display(), source))] + CurrentIpWriteFailed { path: PathBuf, source: io::Error }, + + #[snafu(display("Failed to read current IP data in '{}': {}", path.display(), source))] + CurrentIpReadFailed { path: PathBuf, source: io::Error }, + + #[snafu(display("Error serializing to JSON: '{}': {}", output, source))] + JsonSerialize { + output: String, + source: serde_json::error::Error, + }, + } +} + +type Result = std::result::Result; diff --git a/sources/models/shared-defaults/defaults.toml b/sources/models/shared-defaults/defaults.toml index e6130995fc9..4d7880b3f8d 100644 --- a/sources/models/shared-defaults/defaults.toml +++ b/sources/models/shared-defaults/defaults.toml @@ -76,6 +76,18 @@ template-path = "/usr/share/templates/proxy-env" [metadata.settings.network] affected-services = ["containerd", "host-containerd", "host-containers"] +[metadata.settings.network.hostname] +affected-services = ["hostname"] +setting-generator = "netdog generate-hostname" + +[services.hostname] +configuration-files = ["hostname"] +restart-commands = ["/bin/systemctl try-restart set-hostname.service"] + +[configuration-files.hostname] +path = "/etc/network/hostname.env" +template-path = "/usr/share/templates/hostname-env" + # NTP [settings.ntp] diff --git a/sources/models/src/lib.rs b/sources/models/src/lib.rs index fc58facc1cb..b3c547c01dd 100644 --- a/sources/models/src/lib.rs +++ b/sources/models/src/lib.rs @@ -108,12 +108,13 @@ use std::collections::HashMap; use std::net::Ipv4Addr; use crate::modeled_types::{ - BootstrapContainerMode, CpuManagerPolicy, DNSDomain, ECSAgentLogLevel, ECSAttributeKey, ECSAttributeValue, - FriendlyVersion, Identifier, KubernetesAuthenticationMode, KubernetesBootstrapToken, - KubernetesCloudProvider, KubernetesClusterName, KubernetesDurationValue, KubernetesEvictionHardKey, KubernetesLabelKey, - KubernetesLabelValue, KubernetesQuantityValue, KubernetesReservedResourceKey, - KubernetesTaintValue, KubernetesThresholdValue, Lockdown, SingleLineString, SysctlKey, Url, - ValidBase64, + BootstrapContainerMode, CpuManagerPolicy, DNSDomain, ECSAgentLogLevel, ECSAttributeKey, + ECSAttributeValue, FriendlyVersion, Identifier, KubernetesAuthenticationMode, + KubernetesBootstrapToken, KubernetesCloudProvider, KubernetesClusterName, + KubernetesDurationValue, KubernetesEvictionHardKey, KubernetesLabelKey, KubernetesLabelValue, + KubernetesQuantityValue, KubernetesReservedResourceKey, KubernetesTaintValue, + KubernetesThresholdValue, Lockdown, SingleLineString, SysctlKey, Url, ValidBase64, + ValidLinuxHostname, }; // Kubernetes static pod manifest settings @@ -199,6 +200,7 @@ struct HostContainer { // Network settings. These settings will affect host service components' network behavior #[model] struct NetworkSettings { + hostname: ValidLinuxHostname, https_proxy: Url, // We allow some flexibility in NO_PROXY values because different services support different formats. no_proxy: Vec, diff --git a/sources/models/src/modeled_types/mod.rs b/sources/models/src/modeled_types/mod.rs index 25344122df2..93ce2009f48 100644 --- a/sources/models/src/modeled_types/mod.rs +++ b/sources/models/src/modeled_types/mod.rs @@ -54,6 +54,9 @@ pub mod error { #[snafu(display("Invalid domain name '{}': {}", input, msg))] InvalidDomainName { input: String, msg: String }, + #[snafu(display("Invalid hostname '{}': {}", input, msg))] + InvalidLinuxHostname { input: String, msg: String }, + #[snafu(display("Invalid Linux lockdown mode '{}'", input))] InvalidLockdown { input: String }, diff --git a/sources/models/src/modeled_types/shared.rs b/sources/models/src/modeled_types/shared.rs index 9eb3434f9e4..3e957faa7f7 100644 --- a/sources/models/src/modeled_types/shared.rs +++ b/sources/models/src/modeled_types/shared.rs @@ -130,6 +130,105 @@ mod test_single_line_string { // =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= +/// ValidLinuxHostname represents a string that contains a valid Linux hostname as defined by +/// https://man7.org/linux/man-pages/man7/hostname.7.html. It stores the original form and makes +/// it accessible through standard traits. +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct ValidLinuxHostname { + inner: String, +} + +lazy_static! { + // According to the man page above, hostnames must be between 1-253 characters long consisting + // of characters [0-9a-z.-]. + pub(crate) static ref VALID_LINUX_HOSTNAME: Regex = Regex::new(r"^[0-9a-z.-]{1,253}$").unwrap(); +} + +impl TryFrom<&str> for ValidLinuxHostname { + type Error = error::Error; + + fn try_from(input: &str) -> Result { + ensure!( + VALID_LINUX_HOSTNAME.is_match(input), + error::InvalidLinuxHostname { + input, + msg: "must only be [0-9a-z.-], and 1-253 chars long" + } + ); + + // Though the man page doesn't explicitly disallow hostnames that start with dots, dots are + // used as separators so starting with a separator would imply an empty domain, which isn't + // allowed (must be at least one character). + ensure!( + !input.starts_with("-") && !input.starts_with("."), + error::InvalidLinuxHostname { + input, + msg: "must not start with '-' or '.'" + } + ); + + // Each segment must be from 1-63 chars long and shouldn't start with "-" + ensure!( + input + .split(".") + .all(|x| x.len() >= 1 && x.len() <= 63 && !x.starts_with("-")), + error::InvalidLinuxHostname { + input, + msg: "segment is less than 1 or greater than 63 chars" + } + ); + + Ok(Self { + inner: input.to_string(), + }) + } +} + +string_impls_for!(ValidLinuxHostname, "ValidLinuxHostname"); + +#[cfg(test)] +mod test_valid_linux_hostname { + use super::ValidLinuxHostname; + use std::convert::TryFrom; + + #[test] + fn valid_linux_hostname() { + assert!(ValidLinuxHostname::try_from("hello").is_ok()); + assert!(ValidLinuxHostname::try_from("hello1234567890").is_ok()); + + let segment_limit = std::iter::repeat("a").take(63).collect::(); + assert!(ValidLinuxHostname::try_from(segment_limit.clone()).is_ok()); + + let segment = std::iter::repeat("a").take(61).collect::(); + let long_name = format!( + "{}.{}.{}.{}", + &segment_limit, &segment_limit, &segment_limit, &segment + ); + assert!(ValidLinuxHostname::try_from(long_name).is_ok()); + } + + #[test] + fn invalid_linux_hostname() { + assert!(ValidLinuxHostname::try_from(" ").is_err()); + assert!(ValidLinuxHostname::try_from("-a").is_err()); + assert!(ValidLinuxHostname::try_from(".a").is_err()); + assert!(ValidLinuxHostname::try_from("@a").is_err()); + assert!(ValidLinuxHostname::try_from("a..a").is_err()); + assert!(ValidLinuxHostname::try_from("a.a.-a.a1234").is_err()); + + let long_segment = std::iter::repeat("a").take(64).collect::(); + assert!(ValidLinuxHostname::try_from(long_segment.clone()).is_err()); + + let long_name = format!( + "{}.{}.{}.{}", + &long_segment, &long_segment, &long_segment, &long_segment + ); + assert!(ValidLinuxHostname::try_from(long_name).is_err()); + } +} + +// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= + /// Identifier can only be created by deserializing from a string that contains /// ASCII alphanumeric characters, plus hyphens, which we use as our standard word separator /// character in user-facing identifiers. It stores the original form and makes it accessible