Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 47 additions & 26 deletions crates/database/src/devices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,40 +165,61 @@ impl Device {
/// Pre-attach a tailnet identity to an existing device. Used by the
/// admin "attach Tailscale identity" workflow when the operator
/// knows a device is about to come online over the tailnet (or is
/// being moved off mTLS). Errors with
/// `DeviceTailscaleNodeAlreadyClaimed` if another device already
/// holds this `node_id` — the operator should use the merge flow
/// in that case.
/// being moved off mTLS).
///
/// If another device already holds this `node_id`:
///
/// - If that device is `Untrusted` (the typical case — an
/// auto-created placeholder from a tailnet first-contact), the
/// identity is detached from it so the target can claim it. The
/// placeholder row is left in place but with its tailscale_*
/// columns cleared.
/// - Otherwise (the conflicting device has a real role), this
/// returns `DeviceTailscaleNodeAlreadyClaimed` and the operator
/// must reach for the merge flow.
pub async fn attach_tailscale(
db: &mut AsyncPgConnection,
device_id: Uuid,
identity: TailscaleIdentity,
) -> Result<()> {
use crate::schema::devices::dsl;

// Pre-check: another device already claiming this node id?
let conflict: Option<Uuid> = dsl::devices
.select(dsl::id)
.filter(dsl::tailscale_node_id.eq(&identity.node_id))
.filter(dsl::id.ne(device_id))
.first(db)
.await
.optional()
.map_err(AppError::from)?;
if conflict.is_some() {
return Err(AppError::DeviceTailscaleNodeAlreadyClaimed);
}
db.transaction::<_, AppError, _>(async |conn| {
let conflict: Option<Self> = dsl::devices
.select(Self::as_select())
.filter(dsl::tailscale_node_id.eq(&identity.node_id))
.filter(dsl::id.ne(device_id))
.first(conn)
.await
.optional()
.map_err(AppError::from)?;
if let Some(conflict) = conflict {
if conflict.role != DeviceRole::Untrusted {
return Err(AppError::DeviceTailscaleNodeAlreadyClaimed);
}
diesel::update(dsl::devices.filter(dsl::id.eq(conflict.id)))
.set((
dsl::tailscale_node_id.eq(None::<String>),
dsl::tailscale_node_name.eq(None::<String>),
dsl::tailscale_tailnet.eq(None::<String>),
))
.execute(conn)
.await
.map_err(AppError::from)?;
}

diesel::update(dsl::devices.filter(dsl::id.eq(device_id)))
.set((
dsl::tailscale_node_id.eq(identity.node_id),
dsl::tailscale_node_name.eq(identity.node_name),
dsl::tailscale_tailnet.eq(identity.tailnet),
))
.execute(db)
.await
.map_err(AppError::from)?;
Ok(())
diesel::update(dsl::devices.filter(dsl::id.eq(device_id)))
.set((
dsl::tailscale_node_id.eq(identity.node_id),
dsl::tailscale_node_name.eq(identity.node_name),
dsl::tailscale_tailnet.eq(identity.tailnet),
))
.execute(conn)
.await
.map_err(AppError::from)?;
Ok(())
})
.await
}

/// Clear the tailnet identity from a device. The opposite of
Expand Down
54 changes: 54 additions & 0 deletions crates/private-server/tests/device_admin_endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,60 @@ async fn attach_tailscale_conflict_when_node_id_already_claimed() {
.await
}

#[tokio::test(flavor = "multi_thread")]
async fn attach_tailscale_detaches_untrusted_claimant() {
TestDb::run(async |mut conn, url| {
let (_ip, node_id, dir) = test_directory();
let private = private_with_directory(&url, dir).await;

// Untrusted placeholder, auto-created on first contact, holds the node id.
let placeholder = Uuid::new_v4();
conn.batch_execute(&format!(
"INSERT INTO devices (id, role, tailscale_node_id, tailscale_node_name) \
VALUES ('{placeholder}', 'untrusted', '{node_id}', 'placeholder-name');"
))
.await
.expect("seed placeholder");

// Real (server-role) device the operator wants to bind the node id to.
let target = insert_device(&mut conn).await;

let resp = private
.post("/api/devices/attach_tailscale")
.json(&serde_json::json!({
"device_id": target,
"identifier": node_id,
}))
.await;
resp.assert_status_ok();
let body: serde_json::Value = resp.json();
assert_eq!(
body["device"]["tailscale_node_id"].as_str(),
Some(node_id.as_str()),
);
assert_eq!(
body["device"]["id"].as_str(),
Some(target.to_string().as_str()),
);

// Placeholder row is still there, but its tailscale fields are cleared.
conn.batch_execute(&format!(
"DO $$ BEGIN \
IF NOT EXISTS ( \
SELECT 1 FROM devices \
WHERE id = '{placeholder}' \
AND tailscale_node_id IS NULL \
AND tailscale_node_name IS NULL \
AND tailscale_tailnet IS NULL \
) THEN RAISE EXCEPTION 'placeholder should be detached'; END IF; \
END $$;"
))
.await
.expect("placeholder detached");
})
.await
}

#[tokio::test(flavor = "multi_thread")]
async fn detach_tailscale_clears_columns() {
TestDb::run(async |mut conn, url| {
Expand Down