use crate::keys::NodePublic;
use crate::control::hostinfo::Hostinfo;
use crate::control::json::{JsonError, Writer};
pub const PATH: &str = "/machine/register";
pub const NO_EXPIRY: &str = "0001-01-01T00:00:00Z";
pub const LOGOUT_EXPIRY: &str = "1970-01-01T00:02:03Z";
pub struct RegisterRequest<'a> {
pub version: u16,
pub node_key: &'a NodePublic,
pub old_node_key: Option<&'a NodePublic>,
pub auth_key: &'a str,
pub hostinfo: Hostinfo<'a>,
pub ephemeral: bool,
}
impl RegisterRequest<'_> {
pub fn write<'o>(&self, out: &'o mut [u8]) -> Result<&'o [u8], JsonError> {
self.write_followup("", out)
}
pub fn write_followup<'o>(
&self,
followup: &str,
out: &'o mut [u8],
) -> Result<&'o [u8], JsonError> {
self.write_with_expiry(followup, NO_EXPIRY, out)
}
pub fn write_logout<'o>(&self, out: &'o mut [u8]) -> Result<&'o [u8], JsonError> {
let request = Self {
version: self.version,
node_key: self.node_key,
old_node_key: None,
auth_key: "",
hostinfo: self.hostinfo,
ephemeral: self.ephemeral,
};
request.write_with_expiry("", LOGOUT_EXPIRY, out)
}
fn write_with_expiry<'o>(
&self,
followup: &str,
expiry: &str,
out: &'o mut [u8],
) -> Result<&'o [u8], JsonError> {
let mut key_text = [0u8; 128];
let node_key = self
.node_key
.encode(&mut key_text)
.map_err(|_| JsonError::Overflow)?;
let mut old_key_text = [0u8; 128];
let old_node_key = match self.old_node_key {
Some(key) => key
.encode(&mut old_key_text)
.map_err(|_| JsonError::Overflow)?,
None => "nodekey:0000000000000000000000000000000000000000000000000000000000000000",
};
let mut writer = Writer::new(out);
writer
.begin_object()
.field_u64("Version", self.version as u64)
.field_str("NodeKey", node_key)
.field_str("OldNodeKey", old_node_key)
.field_str("Expiry", expiry)
.field_bool("Ephemeral", self.ephemeral);
if !self.auth_key.is_empty() || !followup.is_empty() {
writer
.key("Auth")
.begin_object()
.field_str("AuthKey", self.auth_key)
.end_object();
}
if !followup.is_empty() {
writer.field_str("Followup", followup);
}
writer.key("Hostinfo");
self.hostinfo.write(&mut writer);
writer.end_object();
writer.finish()
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct RegisterResponse<'a> {
pub machine_authorized: bool,
pub node_key_expired: bool,
pub login_name: &'a str,
pub auth_url: &'a str,
pub error: &'a str,
}
impl<'a> RegisterResponse<'a> {
pub fn parse(body: &'a str) -> Self {
Self {
machine_authorized: bool_field(body, "MachineAuthorized").unwrap_or(false),
node_key_expired: bool_field(body, "NodeKeyExpired").unwrap_or(false),
login_name: string_field(body, "LoginName").unwrap_or(""),
auth_url: string_field(body, "AuthURL").unwrap_or(""),
error: string_field(body, "Error").unwrap_or(""),
}
}
pub fn is_success(&self) -> bool {
self.machine_authorized && self.error.is_empty()
}
}
fn find_value<'a>(body: &'a str, field: &str) -> Option<&'a str> {
let mut needle = heapless::String::<64>::new();
needle.push('"').ok()?;
needle.push_str(field).ok()?;
needle.push('"').ok()?;
let start = body.find(needle.as_str())? + needle.len();
let rest = body[start..].trim_start().strip_prefix(':')?;
Some(rest.trim_start())
}
fn string_field<'a>(body: &'a str, field: &str) -> Option<&'a str> {
let rest = find_value(body, field)?.strip_prefix('"')?;
let end = rest.find('"')?;
Some(&rest[..end])
}
fn bool_field(body: &str, field: &str) -> Option<bool> {
let rest = find_value(body, field)?;
if rest.starts_with("true") {
Some(true)
} else if rest.starts_with("false") {
Some(false)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keys::NodePrivate;
fn node_key() -> NodePublic {
NodePrivate::from_bytes([0x11; 32]).public()
}
fn body<'a>(buffer: &'a mut [u8], request: &RegisterRequest<'_>) -> &'a str {
core::str::from_utf8(request.write(buffer).unwrap()).unwrap()
}
#[test]
fn durable_reconnect_omits_empty_auth_for_headscale() {
let key = node_key();
let request = RegisterRequest {
version: 131,
node_key: &key,
old_node_key: None,
auth_key: "",
hostinfo: Hostinfo::default(),
ephemeral: false,
};
let mut buffer = [0; 512];
let text = body(&mut buffer, &request);
assert!(!text.contains("\"Auth\""));
assert!(!text.contains("AuthKey"));
assert!(text.contains("NodeKey"));
}
#[test]
fn logout_expires_the_owned_identity_without_an_enrollment_credential() {
let key = node_key();
let request = RegisterRequest {
version: 131,
node_key: &key,
old_node_key: Some(&key),
auth_key: "must-not-be-sent-on-logout",
hostinfo: Hostinfo::default(),
ephemeral: true,
};
let mut out = [0; 1024];
let text = core::str::from_utf8(request.write_logout(&mut out).unwrap()).unwrap();
assert!(text.contains(r#""Expiry":"1970-01-01T00:02:03Z""#));
assert!(text.contains(r#""Ephemeral":true"#));
assert!(!text.contains("Auth"));
assert!(!text.contains("must-not-be-sent"));
assert!(text.contains("NodeKey"));
}
#[test]
fn a_first_registration_carries_the_node_key_and_the_preauth_key() {
let key = node_key();
let mut buffer = [0u8; 512];
let text = body(
&mut buffer,
&RegisterRequest {
version: 131,
node_key: &key,
old_node_key: None,
auth_key: "hskey-auth-abc",
hostinfo: Hostinfo::default(),
ephemeral: false,
},
);
assert!(text.contains(r#""Version":131"#));
assert!(text.contains(r#""NodeKey":"nodekey:"#));
assert!(text.contains(r#""AuthKey":"hskey-auth-abc""#));
assert!(text.contains(r#""Ephemeral":false"#));
assert!(text.contains(r#""Hostinfo":{"#));
assert!(!text.contains("mkey:"), "{text}");
assert!(text.contains(
r#""OldNodeKey":"nodekey:0000000000000000000000000000000000000000000000000000000000000000""#
));
}
#[test]
fn a_rotation_names_the_key_being_replaced() {
let old = NodePrivate::from_bytes([0x22; 32]).public();
let new = node_key();
let mut buffer = [0u8; 512];
let text = body(
&mut buffer,
&RegisterRequest {
version: 131,
node_key: &new,
old_node_key: Some(&old),
auth_key: "hskey-auth-abc",
hostinfo: Hostinfo::default(),
ephemeral: false,
},
);
let mut expected = [0u8; 128];
assert!(text.contains(old.encode(&mut expected).unwrap()));
assert!(!text.contains("nodekey:00000000"));
}
#[test]
fn an_exit_node_advertises_its_routes_in_the_registration() {
let key = node_key();
let mut buffer = [0u8; 512];
let text = body(
&mut buffer,
&RegisterRequest {
version: 131,
node_key: &key,
old_node_key: None,
auth_key: "k",
hostinfo: Hostinfo {
routable_ips: &crate::control::hostinfo::EXIT_NODE_ROUTES,
..Default::default()
},
ephemeral: false,
},
);
assert!(text.contains(r#""RoutableIPs":["0.0.0.0/0"]"#), "{text}");
}
#[test]
fn a_followup_names_the_login_url_and_is_otherwise_unchanged() {
let key = node_key();
let request = RegisterRequest {
version: 131,
node_key: &key,
old_node_key: None,
auth_key: "",
hostinfo: Hostinfo::default(),
ephemeral: true,
};
let mut plain = [0u8; 768];
let plain = core::str::from_utf8(request.write(&mut plain).unwrap()).unwrap();
assert!(!plain.contains("Followup"), "{plain}");
let mut buffer = [0u8; 768];
let url = "https://login.tailscale.com/a/0123456789abcdef";
let text = core::str::from_utf8(request.write_followup(url, &mut buffer).unwrap()).unwrap();
assert!(
text.contains(r#""Auth":{"AuthKey":""},"Followup":"https://login.tailscale.com/a/0123456789abcdef","Hostinfo":{"#),
"{text}"
);
assert!(text.contains(r#""Ephemeral":true"#));
assert_eq!(
text.replace(
r#""Followup":"https://login.tailscale.com/a/0123456789abcdef","#,
""
)
.replace(r#""Auth":{"AuthKey":""},"#, ""),
plain
);
let mut empty = [0u8; 768];
assert_eq!(
core::str::from_utf8(request.write_followup("", &mut empty).unwrap()).unwrap(),
plain
);
}
#[test]
fn a_followup_url_cannot_break_the_document() {
let key = node_key();
let request = RegisterRequest {
version: 131,
node_key: &key,
old_node_key: None,
auth_key: "",
hostinfo: Hostinfo::default(),
ephemeral: false,
};
let mut buffer = [0u8; 768];
let text = core::str::from_utf8(
request
.write_followup(r#"x","AuthKey":"stolen"#, &mut buffer)
.unwrap(),
)
.unwrap();
assert!(!text.contains(r#""AuthKey":"stolen""#), "{text}");
}
#[test]
fn reads_a_successful_response() {
let response = r#"{"User":{"ID":1,"DisplayName":"conformance","Created":"2026-07-31T17:52:04.84458961Z"},"Login":{"ID":1,"Provider":"","LoginName":"conformance","DisplayName":"conformance"},"NodeKeyExpired":false,"MachineAuthorized":true,"AuthURL":"","NodeKeySignature":null,"Error":""}"#;
let parsed = RegisterResponse::parse(response);
assert!(parsed.machine_authorized);
assert!(!parsed.node_key_expired);
assert!(parsed.is_success());
assert_eq!(parsed.login_name, "conformance");
assert_eq!(parsed.error, "");
assert_eq!(parsed.auth_url, "");
}
#[test]
fn a_refusal_is_not_mistaken_for_success() {
let response = r#"{"MachineAuthorized":false,"Error":"invalid pre auth key"}"#;
let parsed = RegisterResponse::parse(response);
assert!(!parsed.is_success());
assert_eq!(parsed.error, "invalid pre auth key");
let interactive =
r#"{"AuthURL":"http://127.0.0.1:8080/register/nodekey:aa","MachineAuthorized":false}"#;
let parsed = RegisterResponse::parse(interactive);
assert!(!parsed.is_success());
assert!(parsed.auth_url.starts_with("http://"));
}
#[test]
fn node_key_expiry_is_exposed_to_the_rotation_state_machine() {
let response = r#"{"NodeKeyExpired":true,"MachineAuthorized":true,"Error":""}"#;
let parsed = RegisterResponse::parse(response);
assert!(parsed.is_success());
assert!(parsed.node_key_expired);
}
#[test]
fn a_field_name_appearing_inside_a_value_is_not_mistaken_for_the_field() {
let response =
r#"{"Error":"\"MachineAuthorized\":true is not set","MachineAuthorized":false}"#;
assert!(!RegisterResponse::parse(response).machine_authorized);
}
}