Files
start-os/core/src/hostname.rs
Aiden McClelland bcdeabfe85 fix: clap CLI definitions and manpage generation
- add #[group(skip)] to all Parser-derived structs
- fix conflicts_with and arg definitions for correct CLI behavior
- refactor bin entry points to support manpage generation
2026-03-18 23:48:13 -06:00

292 lines
8.0 KiB
Rust

use clap::Parser;
use imbl_value::InternedString;
use lazy_format::lazy_format;
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use tracing::instrument;
use ts_rs::TS;
use crate::context::RpcContext;
use crate::db::model::public::ServerInfo;
use crate::prelude::*;
use crate::util::Invoke;
#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize, ts_rs::TS)]
#[ts(type = "string")]
pub struct ServerHostname(InternedString);
impl std::ops::Deref for ServerHostname {
type Target = InternedString;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl AsRef<str> for ServerHostname {
fn as_ref(&self) -> &str {
&***self
}
}
impl ServerHostname {
fn validate(&self) -> Result<(), Error> {
if self.0.is_empty() {
return Err(Error::new(
eyre!("{}", t!("hostname.empty")),
ErrorKind::InvalidRequest,
));
}
if let Some(c) = self
.0
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || c == &'-') || c.is_ascii_uppercase())
{
return Err(Error::new(
eyre!("{}", t!("hostname.invalid-character", char = c)),
ErrorKind::InvalidRequest,
));
}
Ok(())
}
pub fn new(hostname: InternedString) -> Result<Self, Error> {
let res = Self(hostname);
res.validate()?;
Ok(res)
}
pub fn lan_address(&self) -> InternedString {
InternedString::from_display(&lazy_format!("https://{}.local", self.0))
}
pub fn local_domain_name(&self) -> InternedString {
InternedString::from_display(&lazy_format!("{}.local", self.0))
}
pub fn load(server_info: &Model<ServerInfo>) -> Result<Self, Error> {
Ok(Self(server_info.as_hostname().de()?))
}
pub fn save(&self, server_info: &mut Model<ServerInfo>) -> Result<(), Error> {
server_info.as_hostname_mut().ser(&**self)
}
}
#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize, ts_rs::TS)]
#[ts(type = "string")]
pub struct ServerHostnameInfo {
pub name: InternedString,
pub hostname: ServerHostname,
}
lazy_static::lazy_static! {
static ref ADJECTIVES: Vec<String> = include_str!("./assets/adjectives.txt").lines().map(|x| x.to_string()).collect();
static ref NOUNS: Vec<String> = include_str!("./assets/nouns.txt").lines().map(|x| x.to_string()).collect();
}
impl AsRef<str> for ServerHostnameInfo {
fn as_ref(&self) -> &str {
&self.hostname
}
}
fn normalize(s: &str) -> InternedString {
let mut prev_was_dash = true;
let mut normalized = s
.chars()
.filter_map(|c| {
if c.is_alphanumeric() {
prev_was_dash = false;
Some(c.to_ascii_lowercase())
} else if (c == '-' || c.is_whitespace()) && !prev_was_dash {
prev_was_dash = true;
Some('-')
} else {
None
}
})
.collect::<String>();
while normalized.ends_with('-') {
normalized.pop();
}
if normalized.len() < 4 {
generate_hostname().0
} else {
normalized.into()
}
}
fn denormalize(s: &str) -> InternedString {
let mut cap = true;
s.chars()
.map(|c| {
if c == '-' {
cap = true;
' '
} else if cap {
cap = false;
c.to_ascii_uppercase()
} else {
c
}
})
.collect::<String>()
.into()
}
impl ServerHostnameInfo {
pub fn new(
name: Option<InternedString>,
hostname: Option<InternedString>,
) -> Result<Self, Error> {
Self::new_opt(name, hostname)
.map(|h| h.unwrap_or_else(|| ServerHostnameInfo::from_hostname(generate_hostname())))
}
pub fn new_opt(
name: Option<InternedString>,
hostname: Option<InternedString>,
) -> Result<Option<Self>, Error> {
let name = name.filter(|n| !n.is_empty());
let hostname = hostname.filter(|h| !h.is_empty());
Ok(match (name, hostname) {
(Some(name), Some(hostname)) => Some(ServerHostnameInfo {
name,
hostname: ServerHostname::new(hostname)?,
}),
(Some(name), None) => Some(ServerHostnameInfo::from_name(name)),
(None, Some(hostname)) => Some(ServerHostnameInfo::from_hostname(ServerHostname::new(
hostname,
)?)),
(None, None) => None,
})
}
pub fn from_hostname(hostname: ServerHostname) -> Self {
Self {
name: denormalize(&**hostname),
hostname,
}
}
pub fn from_name(name: InternedString) -> Self {
Self {
hostname: ServerHostname(normalize(&*name)),
name,
}
}
pub fn load(server_info: &Model<ServerInfo>) -> Result<Self, Error> {
Ok(Self {
name: server_info.as_name().de()?,
hostname: ServerHostname::load(server_info)?,
})
}
pub fn save(&self, server_info: &mut Model<ServerInfo>) -> Result<(), Error> {
server_info.as_name_mut().ser(&self.name)?;
self.hostname.save(server_info)
}
}
pub fn generate_hostname() -> ServerHostname {
let num = rand::random::<u16>();
ServerHostname(InternedString::from_display(&lazy_format!(
"startos-{num:04x}"
)))
}
pub fn generate_id() -> String {
let id = uuid::Uuid::new_v4();
id.to_string()
}
#[instrument(skip_all)]
pub async fn get_current_hostname() -> Result<InternedString, Error> {
let out = Command::new("hostname")
.invoke(ErrorKind::ParseSysInfo)
.await?;
let out_string = String::from_utf8(out)?;
Ok(out_string.trim().into())
}
#[instrument(skip_all)]
pub async fn set_hostname(hostname: &ServerHostname) -> Result<(), Error> {
let hostname = &***hostname;
Command::new("hostnamectl")
.arg("--static")
.arg("set-hostname")
.arg(hostname)
.invoke(ErrorKind::ParseSysInfo)
.await?;
Command::new("sed")
.arg("-i")
.arg(format!(
"s/\\(\\s\\)localhost\\( {hostname}\\)\\?/\\1localhost {hostname}/g"
))
.arg("/etc/hosts")
.invoke(ErrorKind::ParseSysInfo)
.await?;
Ok(())
}
#[instrument(skip_all)]
pub async fn sync_hostname(hostname: &ServerHostname) -> Result<(), Error> {
set_hostname(hostname).await?;
Command::new("systemctl")
.arg("restart")
.arg("avahi-daemon")
.invoke(crate::ErrorKind::Network)
.await?;
Ok(())
}
#[derive(Deserialize, Serialize, Parser, TS)]
#[group(skip)]
#[serde(rename_all = "camelCase")]
#[command(rename_all = "kebab-case")]
#[ts(export)]
pub struct SetServerHostnameParams {
name: Option<InternedString>,
hostname: Option<InternedString>,
}
pub async fn set_hostname_rpc(
ctx: RpcContext,
SetServerHostnameParams { name, hostname }: SetServerHostnameParams,
) -> Result<(), Error> {
let name = name.filter(|n| !n.is_empty());
let hostname = hostname
.filter(|h| !h.is_empty())
.map(ServerHostname::new)
.transpose()?;
if name.is_none() && hostname.is_none() {
return Err(Error::new(
eyre!("{}", t!("hostname.must-provide-name-or-hostname")),
ErrorKind::InvalidRequest,
));
};
let info = ctx
.db
.mutate(|db| {
let server_info = db.as_public_mut().as_server_info_mut();
if let Some(name) = name {
server_info.as_name_mut().ser(&name)?;
}
if let Some(hostname) = &hostname {
hostname.save(server_info)?;
}
ServerHostnameInfo::load(server_info)
})
.await
.result?;
ctx.account.mutate(|a| a.hostname = info.clone());
if let Some(h) = hostname {
sync_hostname(&h).await?;
}
Ok(())
}
#[test]
fn test_generate_hostname() {
assert_eq!(dbg!(generate_hostname().0).len(), 12);
}