pub struct HeadscaleClient { /* private fields */ }Expand description
HTTP client for a running Headscale coordinator.
The Headscale REST API lives at <server_url>/api/v1/…. Requests are
authenticated with an API key in the Authorization: Bearer <key> header.
The API key must be created on the coordinator with headscale apikeys create
or the equivalent API call; store it in the vault as headscale-api-key.
Implementations§
Source§impl HeadscaleClient
impl HeadscaleClient
Sourcepub fn new(
base_url: impl Into<String>,
api_key: impl Into<String>,
) -> Result<Self>
pub fn new( base_url: impl Into<String>, api_key: impl Into<String>, ) -> Result<Self>
Construct a client from explicit credentials.
Sourcepub fn from_vault_or_env() -> Result<Option<Self>>
pub fn from_vault_or_env() -> Result<Option<Self>>
Open a client from the vault (slot headscale-api-key + mesh-url)
with env-var fallbacks (HEADSCALE_API_KEY + HEADSCALE_URL).
Returns Ok(None) when either credential is absent — callers can
decide whether that’s fatal.
Sourcepub async fn create_preauth_key(
&self,
user: &str,
tags: &[String],
) -> Result<PreauthKey>
pub async fn create_preauth_key( &self, user: &str, tags: &[String], ) -> Result<PreauthKey>
Generate a single-use pre-auth key for the given ACL tags.
The key expires in 1 hour and is non-reusable — suitable for one-shot
machine onboarding via cloud-init. Each yah cloud machine provision
call that uses Headscale mesh should request its own key.
Sourcepub async fn create_preauth_key_with(
&self,
req: &PreauthKeyRequest,
) -> Result<PreauthKey>
pub async fn create_preauth_key_with( &self, req: &PreauthKeyRequest, ) -> Result<PreauthKey>
Mint a pre-auth key with an explicit lifetime and reuse policy.
The general form behind create_preauth_key.
A reconciled desired state wants a standing key (reusable, long TTL)
rather than the one-shot hour-long key a single provision needs, and
both shapes are the same POST /api/v1/preauthkey.
Sourcepub async fn list_nodes(&self) -> Result<Vec<NodeInfo>>
pub async fn list_nodes(&self) -> Result<Vec<NodeInfo>>
List all nodes currently in the tailnet.
Uses Headscale’s GET /api/v1/node (the endpoint was renamed from the
pre-v0.23 /api/v1/machine, with the response key machines → nodes,
when Headscale retired “machine” for “node”). The per-node JSON shape is
otherwise unchanged (id, name, ipAddresses, online).
Sourcepub async fn list_users(&self) -> Result<Vec<String>>
pub async fn list_users(&self) -> Result<Vec<String>>
List the Headscale users (namespaces) preauth keys can be minted against.
GET /api/v1/user. Headscale scopes every preauth key to a user, and a
key minted against a user that does not exist fails at mint time.
Sourcepub async fn resolve_user(&self, preferred: Option<&str>) -> Result<String>
pub async fn resolve_user(&self, preferred: Option<&str>) -> Result<String>
Resolve the user to mint a preauth key against, asking the coordinator instead of assuming a name.
R608-B19: this used to be the literal "default" at the
yah cloud machine provision call site, and against the live
coordinator that is simply wrong — POST /api/v1/preauthkey answers
500 for a user that does not exist. The two halves of the system
disagree on the name: crate::mesh’s camp-local yah mesh start path
creates default (its --user default), while yah mesh bootstrap
creates yah (mint_bootstrap_preauth_key in yubaba’s lib.rs runs
headscale users create yah). The production coordinator was
bootstrapped, so it has only yah — every provision against it would
have failed at the preauth step with an opaque 500.
Rather than swap one hardcoded guess for the other:
preferredpresent and known to the coordinator → use it;preferredabsent and the coordinator has exactly one user → use it, which is every yah mesh in existence today;- otherwise → error naming the users that DO exist, so the operator can pick, instead of a 500 that names nothing.
Sourcepub async fn create_user(&self, name: &str) -> Result<()>
pub async fn create_user(&self, name: &str) -> Result<()>
Create a Headscale user (namespace). POST /api/v1/user.
NOT idempotent on headscale’s side — creating a name that already
exists answers 500 with no useful discriminator — so every caller must
list_users first. That list-then-create shape is
what HeadscaleReconciler
does, and it is why this method deliberately does not try to swallow a
conflict itself: a 500 here means something other than “already there”.
Sourcepub async fn list_preauth_keys(
&self,
user: &str,
) -> Result<Vec<PreauthKeyRecord>>
pub async fn list_preauth_keys( &self, user: &str, ) -> Result<Vec<PreauthKeyRecord>>
List the pre-auth keys minted against user.
GET /api/v1/preauthkey?user=<user>.
Sourcepub async fn get_policy(&self) -> Result<String>
pub async fn get_policy(&self) -> Result<String>
Read the ACL policy the coordinator currently has loaded.
GET /api/v1/policy → {"policy": "<HuJSON>", "updatedAt": …}.
Works in BOTH policy modes — measured against the live v0.23.0
coordinator on 2026-09-04, which was still on policy.mode: file and
answered 200 with the file’s contents. That is what makes drift
detectable on a not-yet-migrated coordinator even though
set_policy cannot correct it there.
R861-T2 read the pinned v0.23.0 GetPolicy handler rather than
inferring: in database mode it returns the stored row’s Data string
verbatim, in file mode the file’s bytes as-is. Same field, same
document, so a caller never has to know which mode answered.
One consequence worth knowing: a database-mode coordinator with no
policy row yet answers with a gRPC error, not an empty string — the
migration treats that as “nothing pushed yet”, not as a fault.
Sourcepub async fn set_policy(&self, hujson: &str) -> Result<()>
pub async fn set_policy(&self, hujson: &str) -> Result<()>
Replace the ACL policy. PUT /api/v1/policy.
Only legal when the coordinator runs policy.mode: database — the
mode POLICY_MODE now renders. In file mode the pinned v0.23.0
SetPolicy handler returns ErrPolicyUpdateIsDisabled before looking
at the payload at all, because the file on disk is the source of truth
and a write here would be silently overwritten at the next reload.
Callers get that refusal as an error rather than a no-op success.
Two properties R861-T2 established from that handler, both load-bearing for the migration:
- It validates before storing: the document is parsed with the same
LoadACLPolicyFromBytesa file-mode startup uses, then compiled against the live node list (CompileFilterRules, andCompileSSHPolicywhen any node exists). A policy headscale would refuse to boot on is refused here too, which is why a failed push is safe. - It APPENDS:
db.SetPolicyinserts a newpoliciesrow every call andGetPolicyreadsORDER BY id DESC LIMIT 1. Repeating a push is therefore idempotent in effect but not in storage — which is why the reconciler and the migration both compare before writing.
Sourcepub async fn health(&self) -> HeadscaleHealth
pub async fn health(&self) -> HeadscaleHealth
Light health check — HEAD or GET the Headscale root, no auth required.
Auto Trait Implementations§
impl !RefUnwindSafe for HeadscaleClient
impl !UnwindSafe for HeadscaleClient
impl Freeze for HeadscaleClient
impl Send for HeadscaleClient
impl Sync for HeadscaleClient
impl Unpin for HeadscaleClient
impl UnsafeUnpin for HeadscaleClient
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
Source§impl<T> Downcast for Twhere
T: Any,
impl<T> Downcast for Twhere
T: Any,
Source§fn into_any(self: Box<T>) -> Box<dyn Any>
fn into_any(self: Box<T>) -> Box<dyn Any>
Box<dyn Trait> (where Trait: Downcast) to Box<dyn Any>. Box<dyn Any> can
then be further downcast into Box<ConcreteType> where ConcreteType implements Trait.Source§fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>
fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>
Rc<Trait> (where Trait: Downcast) to Rc<Any>. Rc<Any> can then be
further downcast into Rc<ConcreteType> where ConcreteType implements Trait.Source§fn as_any(&self) -> &(dyn Any + 'static)
fn as_any(&self) -> &(dyn Any + 'static)
&Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot
generate &Any’s vtable from &Trait’s.Source§fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)
fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)
&mut Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot
generate &mut Any’s vtable from &mut Trait’s.Source§impl<T> Downcast for Twhere
T: Any,
impl<T> Downcast for Twhere
T: Any,
Source§fn into_any(self: Box<T>) -> Box<dyn Any>
fn into_any(self: Box<T>) -> Box<dyn Any>
Box<dyn Trait> (where Trait: Downcast) to Box<dyn Any>, which can then be
downcast into Box<dyn ConcreteType> where ConcreteType implements Trait.Source§fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>
fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>
Rc<Trait> (where Trait: Downcast) to Rc<Any>, which can then be further
downcast into Rc<ConcreteType> where ConcreteType implements Trait.Source§fn as_any(&self) -> &(dyn Any + 'static)
fn as_any(&self) -> &(dyn Any + 'static)
&Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot
generate &Any’s vtable from &Trait’s.Source§fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)
fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)
&mut Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot
generate &mut Any’s vtable from &mut Trait’s.Source§impl<T> DowncastSend for T
impl<T> DowncastSend for T
Source§impl<T> DowncastSync for T
impl<T> DowncastSync for T
Source§impl<T> DowncastSync for T
impl<T> DowncastSync for T
impl<T> ErasedDestructor for Twhere
T: 'static,
impl<T> Fruit for T
impl<A, B, T> HttpServerConnExec<A, B> for Twhere
B: Body,
Source§impl<T> Instrument for T
impl<T> Instrument for T
Source§fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
Source§fn in_current_span(self) -> Instrumented<Self> ⓘ
fn in_current_span(self) -> Instrumented<Self> ⓘ
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left is true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left(&self) returns true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read more