From ceb436149fae7a97fae2feb83b629fbc1db2ede4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Thu, 14 May 2026 16:08:53 +1200 Subject: [PATCH 1/2] feat: attach_tailscale auto-detaches the identity from an Untrusted claimant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tailnet first-contact auto-creates an Untrusted Device row holding the node id. When the operator subsequently binds that node id to a real device (mTLS-based or otherwise non-Untrusted), the placeholder claim should not stand in the way — detach the identity from the Untrusted row and proceed. Trusted-role conflicts still error out so the operator goes through the merge flow. --- crates/database/src/devices.rs | 73 ++++++++++++++++++++++------------ 1 file changed, 47 insertions(+), 26 deletions(-) diff --git a/crates/database/src/devices.rs b/crates/database/src/devices.rs index 50d492d6..5d69a325 100644 --- a/crates/database/src/devices.rs +++ b/crates/database/src/devices.rs @@ -165,10 +165,18 @@ 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, @@ -176,29 +184,42 @@ impl Device { ) -> Result<()> { use crate::schema::devices::dsl; - // Pre-check: another device already claiming this node id? - let conflict: Option = 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 = 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::), + dsl::tailscale_node_name.eq(None::), + dsl::tailscale_tailnet.eq(None::), + )) + .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 From 0ac57709e27fd5fe6c0cf4298c0950ff2b7afcf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Thu, 14 May 2026 16:12:03 +1200 Subject: [PATCH 2/2] test: attach_tailscale detaches an Untrusted claimant --- .../tests/device_admin_endpoints.rs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/crates/private-server/tests/device_admin_endpoints.rs b/crates/private-server/tests/device_admin_endpoints.rs index e10125fa..df326a0e 100644 --- a/crates/private-server/tests/device_admin_endpoints.rs +++ b/crates/private-server/tests/device_admin_endpoints.rs @@ -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| {