rscale 0.1.0

Self-hosted Rust control plane for operating a single tailnet with Tailscale clients
Documentation
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Node {
    pub id: u64,
    pub stable_id: String,
    pub name: String,
    pub hostname: String,
    pub auth_key_id: Option<String>,
    pub principal_id: Option<u64>,
    pub ipv4: Option<String>,
    pub ipv6: Option<String>,
    pub status: NodeStatus,
    pub tags: Vec<String>,
    #[serde(default)]
    pub tag_source: NodeTagSource,
    pub last_seen_unix_secs: Option<u64>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum NodeStatus {
    Pending,
    Online,
    Offline,
    Expired,
    Disabled,
}

impl NodeStatus {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Pending => "pending",
            Self::Online => "online",
            Self::Offline => "offline",
            Self::Expired => "expired",
            Self::Disabled => "disabled",
        }
    }

    pub fn parse(value: &str) -> Option<Self> {
        match value {
            "pending" => Some(Self::Pending),
            "online" => Some(Self::Online),
            "offline" => Some(Self::Offline),
            "expired" => Some(Self::Expired),
            "disabled" => Some(Self::Disabled),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum NodeTagSource {
    #[default]
    None,
    Request,
    AuthKey,
    Admin,
}

impl NodeTagSource {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::None => "none",
            Self::Request => "request",
            Self::AuthKey => "auth_key",
            Self::Admin => "admin",
        }
    }

    pub fn parse(value: &str) -> Option<Self> {
        match value {
            "none" => Some(Self::None),
            "request" => Some(Self::Request),
            "auth_key" => Some(Self::AuthKey),
            "admin" => Some(Self::Admin),
            _ => None,
        }
    }

    pub fn is_server_forced(self) -> bool {
        matches!(self, Self::AuthKey | Self::Admin)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn node_status_round_trips_to_strings() {
        for (status, expected) in [
            (NodeStatus::Pending, "pending"),
            (NodeStatus::Online, "online"),
            (NodeStatus::Offline, "offline"),
            (NodeStatus::Expired, "expired"),
            (NodeStatus::Disabled, "disabled"),
        ] {
            assert_eq!(status.as_str(), expected);
            assert_eq!(NodeStatus::parse(expected), Some(status));
        }
        assert_eq!(NodeStatus::parse("unknown"), None);
    }

    #[test]
    fn node_tag_source_round_trips_and_marks_server_forced_sources() {
        for (source, expected, is_server_forced) in [
            (NodeTagSource::None, "none", false),
            (NodeTagSource::Request, "request", false),
            (NodeTagSource::AuthKey, "auth_key", true),
            (NodeTagSource::Admin, "admin", true),
        ] {
            assert_eq!(source.as_str(), expected);
            assert_eq!(NodeTagSource::parse(expected), Some(source));
            assert_eq!(source.is_server_forced(), is_server_forced);
        }
        assert_eq!(NodeTagSource::parse("unknown"), None);
    }
}