From 597f023af5805e9ed50f93ea6d8edc89726b8473 Mon Sep 17 00:00:00 2001
From: gm852 <184552300+gm852@users.noreply.github.com>
Date: Tue, 25 Aug 2026 18:55:58 +0000
Subject: [PATCH 1/2] fix: HS v0.28 token changes; minor toast fixes
---
app/(tabs)/apikeys.tsx | 2 +-
app/_layout.tsx | 51 +++++++++++++++-
app/accounts.tsx | 6 +-
app/api/acl.ts | 3 +-
app/api/apikeys.ts | 14 +++--
app/api/login.ts | 116 +++++++++++++++++++++++++++++-------
app/config/apiVersions.ts | 8 ++-
app/funcs/accounts.ts | 27 +++++----
app/funcs/apikeys.ts | 120 ++++++++++++++++++++------------------
app/funcs/index.ts | 32 +++++-----
app/index.tsx | 9 ++-
app/utils/apiKeyUtils.ts | 75 ++++++++++++++++++++++++
app/utils/apiUtils.ts | 7 ++-
app/utils/getServer.ts | 9 ++-
14 files changed, 355 insertions(+), 124 deletions(-)
create mode 100644 app/utils/apiKeyUtils.ts
diff --git a/app/(tabs)/apikeys.tsx b/app/(tabs)/apikeys.tsx
index 27c402b..9cb3270 100644
--- a/app/(tabs)/apikeys.tsx
+++ b/app/(tabs)/apikeys.tsx
@@ -259,7 +259,7 @@ export default function ApiKeysScreen() {
{
text: "Expire Key",
style: "destructive",
- onPress: () => handleExpireKey(key.prefix),
+ onPress: () => handleExpireKey({ id: key.id, prefix: key.prefix }),
},
]
);
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 91d496b..56246d0 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,18 +1,65 @@
import { Slot } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context";
+import Toast, {
+ BaseToast,
+ ErrorToast,
+ InfoToast,
+ type ToastConfig,
+} from "react-native-toast-message";
import "../global.css";
-import Toast from "react-native-toast-message";
+
+// Clear the library's fixed 60px height so long text2 messages can wrap fully.
+const toastBaseStyle = {
+ height: null as unknown as number,
+ minHeight: 60,
+ paddingVertical: 12,
+ width: "90%" as const,
+};
+
+const toastConfig: ToastConfig = {
+ success: (props) => (
+
+ ),
+ error: (props) => (
+
+ ),
+ info: (props) => (
+
+ ),
+};
export default function RootLayout() {
return (
- )
+ );
}
diff --git a/app/accounts.tsx b/app/accounts.tsx
index 965043d..b2e377b 100644
--- a/app/accounts.tsx
+++ b/app/accounts.tsx
@@ -288,8 +288,10 @@ export default function Accounts() {
autoCorrect={false}
/>
- Generate with: {'\n'}
- headscale apikey create --expiration 90d
+ Generate a management API key (not a pre-auth key):{"\n"}
+ headscale apikeys create --expiration 90d
+ {"\n"}
+ v0.28+ format: hskey-api-…
diff --git a/app/api/acl.ts b/app/api/acl.ts
index 7fe9025..8e67e25 100644
--- a/app/api/acl.ts
+++ b/app/api/acl.ts
@@ -22,7 +22,7 @@ export async function updateACLPolicy(policy: any) {
const config = await getApiEndpoints();
if (!config) return null;
- const { endpoints, serverConf } = config;
+ const { endpoints } = config;
const policyString = typeof policy === 'string' ? policy : JSON.stringify(policy);
@@ -36,7 +36,6 @@ export async function updateACLPolicy(policy: any) {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
- 'Authorization': `Bearer ${serverConf.apiKey}`,
},
body: requestBody,
});
diff --git a/app/api/apikeys.ts b/app/api/apikeys.ts
index 00c0e19..3286ac1 100644
--- a/app/api/apikeys.ts
+++ b/app/api/apikeys.ts
@@ -1,4 +1,5 @@
import { getServerConfig } from "../utils/getServer";
+import { buildExpireApiKeyBody, normalizeApiKey } from "../utils/apiKeyUtils";
import { fetchWithFallback } from "../utils/apiUtils";
export async function getAPIKeys() {
@@ -11,7 +12,7 @@ export async function getAPIKeys() {
}
const server = serverConf.server;
- const authKey = serverConf.apiKey;
+ const authKey = normalizeApiKey(serverConf.apiKey);
const response = await fetchWithFallback(server, authKey, `/api/v1/apikey`, {
method: 'GET',
@@ -41,7 +42,7 @@ export async function createAPIKey(expiration: string) {
}
const server = serverConf.server;
- const authKey = serverConf.apiKey;
+ const authKey = normalizeApiKey(serverConf.apiKey);
const response = await fetchWithFallback(server, authKey, `/api/v1/apikey`, {
method: 'POST',
@@ -61,7 +62,8 @@ export async function createAPIKey(expiration: string) {
}
}
-export async function expireAPIKey(prefix: string) {
+/** Expire an API key by id (preferred on v0.28+) or listed prefix. */
+export async function expireAPIKey(key: { id?: number | string; prefix?: string }) {
try {
const serverConf = await getServerConfig();
@@ -71,11 +73,12 @@ export async function expireAPIKey(prefix: string) {
}
const server = serverConf.server;
- const authKey = serverConf.apiKey;
+ const authKey = normalizeApiKey(serverConf.apiKey);
+ const body = buildExpireApiKeyBody(key);
const response = await fetchWithFallback(server, authKey, `/api/v1/apikey/expire`, {
method: 'POST',
- body: JSON.stringify({ prefix }),
+ body: JSON.stringify(body),
});
if (!response.ok) {
@@ -90,4 +93,3 @@ export async function expireAPIKey(prefix: string) {
return null;
}
}
-
diff --git a/app/api/login.ts b/app/api/login.ts
index 7fb9257..ae2d18a 100644
--- a/app/api/login.ts
+++ b/app/api/login.ts
@@ -1,30 +1,102 @@
import { fetchWithFallback } from "../utils/apiUtils";
+import {
+ getApiKeyKind,
+ isUsableApiKey,
+ normalizeApiKey,
+} from "../utils/apiKeyUtils";
-export async function testAPIKey(server: string, apiKey: string): Promise {
- try {
- // Uses the fallback-aware fetch so a singular/plural endpoint mismatch
- // (which returns 404) doesn't get misreported as an invalid API key.
- const response = await fetchWithFallback(server, apiKey, "/api/v1/apikey", {
- method: "GET",
- });
-
- if (response.ok) {
- return true;
- }
-
- // 401/403 means the key really is invalid; anything else (e.g. a 404 on
- // both endpoint spellings, or a 5xx) is logged so it isn't silently
- // treated as an auth problem.
- console.error("API Error:", response.status, await response.text());
- return false;
- } catch (error) {
- console.error("Fetch error:", error);
- return false;
- }
+export type ApiKeyTestResult = {
+ ok: boolean;
+ /** Short user-facing reason when ok is false */
+ message?: string;
+ status?: number;
+};
+
+export async function testAPIKey(
+ server: string,
+ apiKey: string,
+): Promise {
+ const result = await testAPIKeyDetailed(server, apiKey);
+ return result.ok;
}
-
+export async function testAPIKeyDetailed(
+ server: string,
+ apiKey: string,
+): Promise {
+ const key = normalizeApiKey(apiKey);
+
+ if (!key) {
+ return { ok: false, message: "API key is empty." };
+ }
+
+ const kind = getApiKeyKind(key);
+ if (kind === "preauth") {
+ return {
+ ok: false,
+ message:
+ "That looks like a pre-auth key (hskey-auth-…), not an API key. Create an API key with: headscale apikeys create",
+ };
+ }
+ if (kind === "registration") {
+ return {
+ ok: false,
+ message:
+ "That looks like a registration key (hskey-reg-…), not an API key. Use a management API key (hskey-api-…).",
+ };
+ }
+ if (!isUsableApiKey(key)) {
+ return {
+ ok: false,
+ message:
+ "Unrecognized key format. Headscale v0.28+ API keys look like hskey-api-{prefix}-{secret}.",
+ };
+ }
+
+ try {
+ // Uses the fallback-aware fetch so a singular/plural endpoint mismatch
+ // (which returns 404) doesn't get misreported as an invalid API key.
+ const response = await fetchWithFallback(server, key, "/api/v1/apikey", {
+ method: "GET",
+ });
+ if (response.ok) {
+ return { ok: true };
+ }
+ const body = await response.text();
+ console.error("API Error:", response.status, body);
+ if (response.status === 401 || response.status === 403) {
+ return {
+ ok: false,
+ status: response.status,
+ message:
+ kind === "api-legacy"
+ ? "API key rejected. If this server is Headscale v0.28+, create a new key with: headscale apikeys create"
+ : "API key rejected by the server. Confirm you pasted the full hskey-api-… key.",
+ };
+ }
+ if (response.status === 404) {
+ return {
+ ok: false,
+ status: response.status,
+ message: "Could not reach the Headscale API (404). Check the server URL.",
+ };
+ }
+
+ return {
+ ok: false,
+ status: response.status,
+ message: `Server returned ${response.status}. Check URL and API key.`,
+ };
+ } catch (error) {
+ console.error("Fetch error:", error);
+ return {
+ ok: false,
+ message:
+ "Could not reach the server. Check the URL, HTTPS/HTTP, and network connectivity.",
+ };
+ }
+}
diff --git a/app/config/apiVersions.ts b/app/config/apiVersions.ts
index 3031864..1fac324 100644
--- a/app/config/apiVersions.ts
+++ b/app/config/apiVersions.ts
@@ -519,6 +519,8 @@ export const API_VERSION_MAP: Record = {
},
'v0.28': {
// v0.28 follows v0.27 for most endpoints.
+ // API keys use format hskey-api-{prefix}-{secret}; list returns masked prefixes.
+ // Expire/delete accept id (preferred) or prefix — see app/api/apikeys.ts.
apikeys: {
get: '/api/v1/apikey',
createApiKey: (expiration: string) => ({
@@ -526,10 +528,12 @@ export const API_VERSION_MAP: Record = {
method: 'POST',
body: { expiration }
}),
- expireApiKey: (prefix: string) => ({
+ expireApiKey: (prefixOrId: string | number) => ({
url: `/api/v1/apikey/expire`,
method: 'POST',
- body: { prefix }
+ body: typeof prefixOrId === 'number' || /^\d+$/.test(String(prefixOrId))
+ ? { id: Number(prefixOrId) }
+ : { prefix: String(prefixOrId) }
}),
},
diff --git a/app/funcs/accounts.ts b/app/funcs/accounts.ts
index 04efa82..1d56efb 100644
--- a/app/funcs/accounts.ts
+++ b/app/funcs/accounts.ts
@@ -2,7 +2,8 @@ import { useState, useEffect } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useRouter } from "expo-router";
import Toast from "react-native-toast-message";
-import { testAPIKey } from "../api/login";
+import { testAPIKeyDetailed } from "../api/login";
+import { normalizeApiKey } from "../utils/apiKeyUtils";
import { parseVersion } from "../utils/getServer";
export type HeadscaleVersion = "0.23.x" | "0.24.x" | "0.25.x" | "0.26.x" | "0.27.x" | "0.28.x";
@@ -143,23 +144,27 @@ export function useAccountsManager() {
}
setLoading(true);
-
- let isValid = false;
+
+ const normalizedKey = normalizeApiKey(apiKey);
+ let authResult: Awaited>;
// temp for apple login demo, will do nothing
- if (server === "https://appledemo.login.ieouiudhmpac.com" && apiKey === "WlEB2D3t4fdash89LQW65KDsaD9oq0d2npso78uJolmOod2jp7") {
- isValid = true;
+ if (
+ server === "https://appledemo.login.ieouiudhmpac.com" &&
+ normalizedKey === "WlEB2D3t4fdash89LQW65KDsaD9oq0d2npso78uJolmOod2jp7"
+ ) {
+ authResult = { ok: true };
} else {
- isValid = await testAPIKey(server, apiKey);
+ authResult = await testAPIKeyDetailed(server, normalizedKey);
}
-
+
setLoading(false);
- if (!isValid) {
+ if (!authResult.ok) {
Toast.show({
type: "error",
position: "top",
- text1: "⚠️ Invalid API Key",
- text2: "Check the key and try again.",
+ text1: "⚠️ Connection Failed",
+ text2: authResult.message || "Check the key and try again.",
});
if (onFail) onFail();
return;
@@ -168,7 +173,7 @@ export function useAccountsManager() {
const newEntry: ServerAccount = {
name: customName.trim(),
server: server.trim(),
- apiKey: apiKey.trim(),
+ apiKey: normalizedKey,
addedOn: new Date().toISOString(),
version: parseVersion(version),
};
diff --git a/app/funcs/apikeys.ts b/app/funcs/apikeys.ts
index 0472910..6bbc00d 100644
--- a/app/funcs/apikeys.ts
+++ b/app/funcs/apikeys.ts
@@ -1,6 +1,7 @@
import { useState } from "react";
import Toast from "react-native-toast-message";
import { getAPIKeys, createAPIKey, expireAPIKey } from "../api/apikeys";
+import { apiKeyMatchesListedPrefix } from "../utils/apiKeyUtils";
import { calculateExpirationDate } from "../utils/time";
import { getServerConfig } from "../utils/getServer";
@@ -24,7 +25,10 @@ export function useApiKeys() {
const currentKey = serverConfig.apiKey;
if (currentKey) {
- const matchedKey = allKeys.find((key) => key.prefix && currentKey.startsWith(key.prefix));
+ // v0.28 lists prefixes as `hskey-api-{prefix}-***` — match with asterisks stripped
+ const matchedKey = allKeys.find((key: any) =>
+ apiKeyMatchesListedPrefix(currentKey, key.prefix),
+ );
if (matchedKey?.expiration) {
const expirationDate = new Date(matchedKey.expiration).toLocaleString();
setActiveKeyExpire(expirationDate);
@@ -42,53 +46,56 @@ export function useApiKeys() {
}
};
-const handleCreateKey = async () => {
- if (!newKeyExpire.trim()) {
- Toast.show({
- type: "error",
- position: "top",
- text1: "⚠️ Expiration Required",
- text2: "Please enter an expiration time",
- });
- return null;
- }
-
- // Validate expiration format
- const regex = /^(0|[1-9]\d*)([smhdy])$/;
- if (!regex.test(newKeyExpire)) {
- Toast.show({
- type: "error",
- position: "top",
- text1: "⚠️ Invalid Format",
- text2: "Use format like: 24h, 7d, 30d, 1y",
- });
- return null;
- }
+ const handleCreateKey = async () => {
+ if (!newKeyExpire.trim()) {
+ Toast.show({
+ type: "error",
+ position: "top",
+ text1: "⚠️ Expiration Required",
+ text2: "Please enter an expiration time",
+ });
+ return null;
+ }
- try {
- // Convert expiration to timestamp format your API expects
- const expirationTimestamp = calculateExpirationDate(newKeyExpire);
-
- // Call your API to create the key
- const result = await createAPIKey(expirationTimestamp);
-
- if (result && result.apiKey) {
+ // Validate expiration format
+ const regex = /^(0|[1-9]\d*)([smhdy])$/;
+ if (!regex.test(newKeyExpire)) {
Toast.show({
- type: "success",
+ type: "error",
position: "top",
- text1: "✅ API Key Created",
- text2: "New API key generated successfully",
+ text1: "⚠️ Invalid Format",
+ text2: "Use format like: 24h, 7d, 30d, 1y",
});
-
- // Refresh the keys list
- await fetchApiKeys();
-
- // Clear the input
- setNewKeyExpire("");
-
- // Return the result so the component can display the key
- return result;
- } else {
+ return null;
+ }
+
+ try {
+ const expirationTimestamp = calculateExpirationDate(newKeyExpire);
+ if (!expirationTimestamp) {
+ Toast.show({
+ type: "error",
+ position: "top",
+ text1: "⚠️ Invalid Format",
+ text2: "Use format like: 24h, 7d, 30d",
+ });
+ return null;
+ }
+
+ const result = await createAPIKey(expirationTimestamp);
+
+ if (result && result.apiKey) {
+ Toast.show({
+ type: "success",
+ position: "top",
+ text1: "✅ API Key Created",
+ text2: "New API key generated successfully",
+ });
+
+ await fetchApiKeys();
+ setNewKeyExpire("");
+ return result;
+ }
+
Toast.show({
type: "error",
position: "top",
@@ -96,21 +103,20 @@ const handleCreateKey = async () => {
text2: "Failed to create API key",
});
return null;
+ } catch (error) {
+ console.error("Error creating API key:", error);
+ Toast.show({
+ type: "error",
+ position: "top",
+ text1: "❌ Creation Failed",
+ text2: "An error occurred while creating the key",
+ });
+ return null;
}
- } catch (error) {
- console.error("Error creating API key:", error);
- Toast.show({
- type: "error",
- position: "top",
- text1: "❌ Creation Failed",
- text2: "An error occurred while creating the key",
- });
- return null;
- }
-};
+ };
- const handleExpireKey = async (prefix: string) => {
- const result = await expireAPIKey(prefix);
+ const handleExpireKey = async (key: { id?: number | string; prefix?: string }) => {
+ const result = await expireAPIKey(key);
if (result) {
Toast.show({
type: "success",
diff --git a/app/funcs/index.ts b/app/funcs/index.ts
index 8e04dc6..73aa234 100644
--- a/app/funcs/index.ts
+++ b/app/funcs/index.ts
@@ -2,7 +2,8 @@ import { useState } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useRouter } from "expo-router";
import Toast from "react-native-toast-message";
-import { testAPIKey } from "../api/login";
+import { testAPIKeyDetailed } from "../api/login";
+import { normalizeApiKey } from "../utils/apiKeyUtils";
import { parseVersion } from "../utils/getServer";
export type HeadscaleVersion = "0.23.x" | "0.24.x" | "0.25.x" | "0.26.x" | "0.27.x" | "0.28.x";
@@ -33,16 +34,16 @@ export function useLogin() {
}
const servers = JSON.parse(serversJson);
- const selected = servers.find(s => s.name === selectedName);
+ const selected = servers.find((s: { name: string }) => s.name === selectedName);
if (!selected) {
setLoading(false);
return;
}
- const isValid = await testAPIKey(selected.server, selected.apiKey);
+ const result = await testAPIKeyDetailed(selected.server, selected.apiKey);
- if (isValid) {
+ if (result.ok) {
Toast.show({
type: "success",
position: "top",
@@ -77,20 +78,25 @@ export function useLogin() {
return;
}
- let isValid = false
+ const normalizedKey = normalizeApiKey(apiKey);
+
+ let authResult: Awaited>;
// temp for apple login demo, will do nothing
- if (server === "https://appledemo.login.ieouiudhmpac.com" && apiKey === "WlEB2D3t4fdash89LQW65KDsaD9oq0d2npso78uJolmOod2jp7"){
- isValid = true
+ if (
+ server === "https://appledemo.login.ieouiudhmpac.com" &&
+ normalizedKey === "WlEB2D3t4fdash89LQW65KDsaD9oq0d2npso78uJolmOod2jp7"
+ ) {
+ authResult = { ok: true };
} else {
- isValid = await testAPIKey(server, apiKey);
+ authResult = await testAPIKeyDetailed(server, normalizedKey);
}
- if (!isValid) {
+ if (!authResult.ok) {
Toast.show({
type: "error",
position: "top",
- text1: "⚠️ Invalid API Key",
- text2: "Check your API key and try again.",
+ text1: "⚠️ Connection Failed",
+ text2: authResult.message || "Check your API key and try again.",
});
return;
}
@@ -104,7 +110,7 @@ export function useLogin() {
const newEntry = {
name: customName.trim(),
server: server.trim(),
- apiKey: apiKey.trim(),
+ apiKey: normalizedKey,
addedOn: new Date().toISOString(),
version: parseVersion(headscaleVersion)
};
@@ -120,7 +126,7 @@ export function useLogin() {
}
const updated = [
- ...parsed.filter((item) => item.name !== newEntry.name),
+ ...parsed.filter((item: { name: string }) => item.name !== newEntry.name),
newEntry,
];
diff --git a/app/index.tsx b/app/index.tsx
index 9a691de..fc93cb4 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -185,9 +185,12 @@ export default function LoginScreen() {
autoCorrect={false}
/>
- Generate an API key using Headscale's CLI command: {"\n"}
- headscale apikey create --expiration 90d
- {"\n\n"}The API key provides secure access to your Headscale server's management functions.
+ Generate a management API key (not a pre-auth key):{"\n"}
+ headscale apikeys create --expiration 90d
+ {"\n\n"}
+ Headscale v0.28+ keys look like{" "}
+ hskey-api-…
+ . Pre-auth keys (hskey-auth-…) cannot log in.
diff --git a/app/utils/apiKeyUtils.ts b/app/utils/apiKeyUtils.ts
new file mode 100644
index 0000000..ce09f89
--- /dev/null
+++ b/app/utils/apiKeyUtils.ts
@@ -0,0 +1,75 @@
+/**
+ * Helpers for Headscale *API keys* (management auth).
+ *
+ * Do not confuse with pre-auth keys used by Tailscale clients:
+ * - API key (v0.28+): hskey-api-{prefix}-{secret}
+ * - API key (legacy): {prefix}.{secret}
+ * - Pre-auth key: hskey-auth-{prefix}-{secret}
+ * - Registration key: hskey-reg-{random}
+ *
+ * @see https://github.com/juanfont/headscale/releases/tag/v0.28.0
+ */
+
+export type ApiKeyKind =
+ | "api-v028"
+ | "api-legacy"
+ | "preauth"
+ | "registration"
+ | "unknown";
+
+/** Strip whitespace and an accidental "Bearer " prefix from a pasted key. */
+export function normalizeApiKey(raw: string): string {
+ let key = (raw ?? "").trim();
+ if (/^bearer\s+/i.test(key)) {
+ key = key.replace(/^bearer\s+/i, "").trim();
+ }
+ // Remove any remaining whitespace/newlines from paste
+ return key.replace(/\s+/g, "");
+}
+
+/** Classify a pasted token so we can reject pre-auth keys used as API keys. */
+export function getApiKeyKind(key: string): ApiKeyKind {
+ const normalized = normalizeApiKey(key);
+ if (normalized.startsWith("hskey-api-")) return "api-v028";
+ if (normalized.startsWith("hskey-auth-")) return "preauth";
+ if (normalized.startsWith("hskey-reg-")) return "registration";
+ // Legacy Headscale API keys: 7-char prefix + "." + secret
+ if (/^[A-Za-z0-9_-]{7}\.[A-Za-z0-9_-]+$/.test(normalized)) return "api-legacy";
+ return "unknown";
+}
+
+export function isUsableApiKey(key: string): boolean {
+ const kind = getApiKeyKind(key);
+ return kind === "api-v028" || kind === "api-legacy" || kind === "unknown";
+}
+
+/**
+ * Headscale v0.28 lists API key prefixes masked, e.g. `hskey-api-AbCdEfGhIjKl-***`.
+ * Match a full key against that listed prefix (asterisks stripped).
+ */
+export function apiKeyMatchesListedPrefix(
+ fullKey: string,
+ listedPrefix: string | undefined | null,
+): boolean {
+ if (!listedPrefix) return false;
+ const key = normalizeApiKey(fullKey);
+ const unmasked = listedPrefix.replace(/\*/g, "");
+ return key.startsWith(unmasked);
+}
+
+/** Prefer numeric id for expire/delete on v0.28+; fall back to listed prefix. */
+export function buildExpireApiKeyBody(key: {
+ id?: number | string;
+ prefix?: string;
+}): { id: number } | { prefix: string } {
+ if (key.id !== undefined && key.id !== null && key.id !== "") {
+ const id = typeof key.id === "string" ? Number(key.id) : key.id;
+ if (Number.isFinite(id) && id > 0) {
+ return { id };
+ }
+ }
+ if (!key.prefix) {
+ throw new Error("API key expire requires id or prefix");
+ }
+ return { prefix: key.prefix };
+}
diff --git a/app/utils/apiUtils.ts b/app/utils/apiUtils.ts
index ef8831a..7a6eb24 100644
--- a/app/utils/apiUtils.ts
+++ b/app/utils/apiUtils.ts
@@ -1,5 +1,6 @@
import { getServerConfig } from "../utils/getServer";
import { API_VERSION_MAP, ApiEndpoints } from "../config/apiVersions";
+import { normalizeApiKey } from "./apiKeyUtils";
import { getVersionKey } from "./headscaleVersion";
// Headscale's REST API uses singular resource names (e.g. /api/v1/node), but
@@ -57,14 +58,18 @@ export async function fetchWithFallback(
const candidates = buildEndpointCandidates(path);
let lastResponse: Response | null = null;
+ const token = normalizeApiKey(apiKey);
+
for (const candidate of candidates) {
const response = await fetch(`${server}${candidate}`, {
...options,
headers: {
Accept: "application/json",
- Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...options.headers,
+ // Always win over caller headers so we never drop the Bearer scheme
+ // required by Headscale (missing "Bearer " is logged as an auth error).
+ Authorization: `Bearer ${token}`,
},
});
diff --git a/app/utils/getServer.ts b/app/utils/getServer.ts
index eb3cc6c..7cbe7e1 100644
--- a/app/utils/getServer.ts
+++ b/app/utils/getServer.ts
@@ -1,4 +1,5 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
+import { normalizeApiKey } from "./apiKeyUtils";
export async function getServerConfig() {
const selectedName = await AsyncStorage.getItem("selectedServer");
@@ -8,8 +9,12 @@ export async function getServerConfig() {
try {
const servers = JSON.parse(serversJson);
- const config = servers.find(s => s.name === selectedName);
- return config || null;
+ const config = servers.find((s: { name: string }) => s.name === selectedName);
+ if (!config) return null;
+ return {
+ ...config,
+ apiKey: normalizeApiKey(config.apiKey ?? ""),
+ };
} catch (err) {
console.error("Error parsing server config:", err);
return null;
From 356bdd677322e6a46d37c20374a523d002a51f88 Mon Sep 17 00:00:00 2001
From: gm852 <184552300+gm852@users.noreply.github.com>
Date: Tue, 25 Aug 2026 19:09:14 +0000
Subject: [PATCH 2/2] feat: support for HS v0.29
---
app/(tabs)/acl.tsx | 6 +
app/(tabs)/devices.tsx | 6 +
app/accounts.tsx | 4 +-
app/api/acl.ts | 37 +++-
app/api/auth.ts | 35 ++++
app/api/devices.ts | 24 ++-
app/components/RegisterDeviceModal.tsx | 74 ++++++--
app/config/apiVersions.ts | 128 ++++++++++++++
app/customScreens/[id].tsx | 23 ++-
app/funcs/accounts.ts | 2 +-
app/funcs/deviceDetail.ts | 8 +
app/funcs/devices.ts | 227 +++++++++++++++++++------
app/funcs/index.ts | 4 +-
app/index.tsx | 2 +-
app/utils/apiUtils.ts | 4 +-
app/utils/headscaleVersion.ts | 7 +
app/utils/registrationUtils.ts | 70 ++++++++
hooks/useACL.ts | 56 +++---
18 files changed, 599 insertions(+), 118 deletions(-)
create mode 100644 app/api/auth.ts
create mode 100644 app/utils/registrationUtils.ts
diff --git a/app/(tabs)/acl.tsx b/app/(tabs)/acl.tsx
index 160b085..ccc06d1 100644
--- a/app/(tabs)/acl.tsx
+++ b/app/(tabs)/acl.tsx
@@ -39,6 +39,7 @@ export default function ACLScreen() {
setShowVersions,
setShowSetupGuide,
setEditText,
+ serverVersion,
} = useACL();
return (
@@ -54,6 +55,11 @@ export default function ACLScreen() {
ACL Policy
Access Control List Management
+ {serverVersion?.startsWith("0.29") && (
+
+ v0.29: supports grants, nodeAttrs, tests & sshTests. Policy is checked before save.
+
+ )}
diff --git a/app/(tabs)/devices.tsx b/app/(tabs)/devices.tsx
index 7f47f19..9d70fd1 100644
--- a/app/(tabs)/devices.tsx
+++ b/app/(tabs)/devices.tsx
@@ -33,6 +33,9 @@ export default function DevicesScreen() {
setDeviceKey,
handleModalClose,
handleModalRegister,
+ handleModalApprove,
+ handleModalReject,
+ serverVersion,
} = useDevices();
const [searchQuery, setSearchQuery] = useState("");
@@ -288,6 +291,9 @@ export default function DevicesScreen() {
deviceKey={deviceKey}
onKeyChange={setDeviceKey}
onRegister={handleModalRegister}
+ serverVersion={serverVersion}
+ onApprove={handleModalApprove}
+ onReject={handleModalReject}
/>
);
diff --git a/app/accounts.tsx b/app/accounts.tsx
index b2e377b..26b1e05 100644
--- a/app/accounts.tsx
+++ b/app/accounts.tsx
@@ -15,7 +15,7 @@ import { MaterialIcons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useAccountsManager, HeadscaleVersion } from "@/app/funcs/accounts";
-const VERSION_OPTIONS: HeadscaleVersion[] = ["0.28.x", "0.27.x", "0.26.x", "0.25.x", "0.24.x", "0.23.x"];
+const VERSION_OPTIONS: HeadscaleVersion[] = ["0.29.x", "0.28.x", "0.27.x", "0.26.x", "0.25.x", "0.24.x", "0.23.x"];
export default function Accounts() {
const {
@@ -31,7 +31,7 @@ export default function Accounts() {
const [customName, setCustomName] = useState("");
const [server, setServer] = useState("");
const [apiKey, setApiKey] = useState("");
- const [selectedVersion, setSelectedVersion] = useState("0.26.x");
+ const [selectedVersion, setSelectedVersion] = useState("0.29.x");
const [showInfo, setShowInfo] = useState(null);
const [editingVersion, setEditingVersion] = useState(null);
diff --git a/app/api/acl.ts b/app/api/acl.ts
index 8e67e25..72978c5 100644
--- a/app/api/acl.ts
+++ b/app/api/acl.ts
@@ -23,15 +23,15 @@ export async function updateACLPolicy(policy: any) {
if (!config) return null;
const { endpoints } = config;
-
+
const policyString = typeof policy === 'string' ? policy : JSON.stringify(policy);
-
+
const requestBody = JSON.stringify({
policy: policyString
});
-
+
const updateConfig = endpoints.acl.updatePolicy(policy);
-
+
const response = await makeApiRequest(updateConfig.url, {
method: 'PUT',
headers: {
@@ -45,4 +45,31 @@ export async function updateACLPolicy(policy: any) {
console.error("Update ACL policy error:", error);
throw error;
}
-}
\ No newline at end of file
+}
+
+/**
+ * Validate a policy without applying it (v0.29+).
+ * Runs ACL/grants/ssh tests when present; returns server error payload on failure.
+ */
+export async function checkACLPolicy(policy: any) {
+ try {
+ const config = await getApiEndpoints();
+ if (!config) return null;
+
+ const check = config.endpoints.acl.checkPolicy;
+ if (!check) {
+ return { skipped: true };
+ }
+
+ const policyString = typeof policy === 'string' ? policy : JSON.stringify(policy);
+ const apiCall = check(policyString);
+
+ return await makeApiRequest(apiCall.url, {
+ method: apiCall.method,
+ body: JSON.stringify(apiCall.body),
+ });
+ } catch (error) {
+ console.error("Check ACL policy error:", error);
+ throw error;
+ }
+}
diff --git a/app/api/auth.ts b/app/api/auth.ts
new file mode 100644
index 0000000..2f2b891
--- /dev/null
+++ b/app/api/auth.ts
@@ -0,0 +1,35 @@
+import { getApiEndpoints, makeApiRequest } from "../utils/apiUtils";
+
+/** Approve a pending auth request (SSH check or web auth). v0.29+ */
+export async function approveAuth(authId: string) {
+ const config = await getApiEndpoints();
+ if (!config?.endpoints.auth) {
+ return {
+ error: true,
+ message: "Auth approve requires Headscale v0.29+",
+ };
+ }
+
+ const apiCall = config.endpoints.auth.approve(authId.trim());
+ return await makeApiRequest(apiCall.url, {
+ method: apiCall.method,
+ body: JSON.stringify(apiCall.body),
+ });
+}
+
+/** Reject a pending auth request. v0.29+ */
+export async function rejectAuth(authId: string) {
+ const config = await getApiEndpoints();
+ if (!config?.endpoints.auth) {
+ return {
+ error: true,
+ message: "Auth reject requires Headscale v0.29+",
+ };
+ }
+
+ const apiCall = config.endpoints.auth.reject(authId.trim());
+ return await makeApiRequest(apiCall.url, {
+ method: apiCall.method,
+ body: JSON.stringify(apiCall.body),
+ });
+}
diff --git a/app/api/devices.ts b/app/api/devices.ts
index 03b5d39..e025968 100644
--- a/app/api/devices.ts
+++ b/app/api/devices.ts
@@ -1,5 +1,5 @@
import { getApiEndpoints, makeApiRequest } from "../utils/apiUtils";
-import { isV026OrHigher } from "../utils/headscaleVersion";
+import { isV026OrHigher, isV029OrHigher } from "../utils/headscaleVersion";
export async function getDevices() {
const config = await getApiEndpoints();
@@ -9,15 +9,27 @@ export async function getDevices() {
return await makeApiRequest(endpoints.devices.get, { method: 'GET' });
}
-export async function registerDevice(user: string | number, key: string) {
+/**
+ * Register a device.
+ * - v0.29+: POST /api/v1/auth/register with { user, authId }
+ * - older: POST /api/v1/node/register with { user, key }
+ */
+export async function registerDevice(user: string | number, keyOrAuthId: string) {
const config = await getApiEndpoints();
if (!config) return null;
- const { endpoints } = config;
- const apiCall = endpoints.devices.registerDevice(user as number, key);
-
+ const { endpoints, serverConf } = config;
+ const apiCall = endpoints.devices.registerDevice(user as number, keyOrAuthId);
+
+ // Ensure JSON body is always sent (required for v0.29 auth/register).
+ const body = apiCall.body
+ ? apiCall.body
+ : isV029OrHigher(serverConf.version)
+ ? { user: String(user), authId: keyOrAuthId }
+ : { user, key: keyOrAuthId };
+
return await makeApiRequest(apiCall.url, {
method: apiCall.method,
- body: apiCall.body ? JSON.stringify(apiCall.body) : undefined,
+ body: JSON.stringify(body),
});
}
diff --git a/app/components/RegisterDeviceModal.tsx b/app/components/RegisterDeviceModal.tsx
index 2f20e60..882a954 100644
--- a/app/components/RegisterDeviceModal.tsx
+++ b/app/components/RegisterDeviceModal.tsx
@@ -12,6 +12,7 @@ import {
Keyboard,
} from "react-native";
import Toast from "react-native-toast-message";
+import { isV029OrHigher } from "../utils/headscaleVersion";
interface RegisterDeviceModalProps {
visible: boolean;
@@ -22,6 +23,9 @@ interface RegisterDeviceModalProps {
deviceKey: string;
onKeyChange: (key: string) => void;
onRegister: () => void;
+ serverVersion?: string;
+ onApprove?: () => void;
+ onReject?: () => void;
}
export const RegisterDeviceModal: React.FC = ({
@@ -33,9 +37,14 @@ export const RegisterDeviceModal: React.FC = ({
deviceKey,
onKeyChange,
onRegister,
+ serverVersion,
+ onApprove,
+ onReject,
}) => {
+ const isV029 = isV029OrHigher(serverVersion);
+
const handleRegister = () => {
- if (!selectedUser) {
+ if (!selectedUser && !/headscale\s+auth\s+(approve|reject)/i.test(deviceKey)) {
Toast.show({
type: "error",
position: "top",
@@ -48,8 +57,10 @@ export const RegisterDeviceModal: React.FC = ({
Toast.show({
type: "error",
position: "top",
- text1: "⚠️ No Key",
- text2: "Please enter a device key",
+ text1: isV029 ? "⚠️ No Auth ID" : "⚠️ No Key",
+ text2: isV029
+ ? "Enter an auth ID or paste a headscale auth command"
+ : "Please enter a device key",
});
return;
}
@@ -70,9 +81,16 @@ export const RegisterDeviceModal: React.FC = ({
className="w-full max-w-md"
>
-
- Register Device
+
+ {isV029 ? "Register / Auth" : "Register Device"}
+ {isV029 && (
+
+ v0.29 uses auth IDs. Paste an auth ID or a full{" "}
+ headscale auth …{" "}
+ command.
+
+ )}
{/* User Selection */}
Select User:
@@ -120,11 +138,17 @@ export const RegisterDeviceModal: React.FC = ({
)}
- {/* Key Input */}
- Device Key:
+ {/* Key / Auth ID Input */}
+
+ {isV029 ? "Auth ID or command:" : "Device Key:"}
+
= ({
autoCapitalize="none"
autoCorrect={false}
/>
+ {isV029 && (
+
+ Examples:{"\n"}
+ headscale auth register --user alice --auth-id …{"\n"}
+ headscale auth approve --auth-id …{"\n"}
+ headscale auth reject --auth-id …
+
+ )}
{/* Buttons */}
-
+
= ({
+
+ {isV029 && onApprove && onReject && (
+
+
+
+ Approve
+
+
+
+
+ Reject
+
+
+
+ )}
@@ -160,4 +213,3 @@ export const RegisterDeviceModal: React.FC = ({
);
};
-
diff --git a/app/config/apiVersions.ts b/app/config/apiVersions.ts
index 1fac324..c6eb222 100644
--- a/app/config/apiVersions.ts
+++ b/app/config/apiVersions.ts
@@ -43,6 +43,14 @@ export interface ApiEndpoints {
acl: {
getPolicy: string;
updatePolicy: (policy: any) => { url: string; method: string; body: any };
+ /** Headscale v0.29+: validate policy (incl. tests/grants) before applying */
+ checkPolicy?: (policy: string) => { url: string; method: string; body: any };
+ };
+
+ /** Headscale v0.29+: SSH check / web auth approval flow */
+ auth?: {
+ approve: (authId: string) => { url: string; method: string; body: any };
+ reject: (authId: string) => { url: string; method: string; body: any };
};
}
@@ -619,4 +627,124 @@ export const API_VERSION_MAP: Record = {
}),
},
},
+
+ 'v0.29': {
+ // v0.29 builds on v0.28 with auth routes and policy check.
+ // Device registration prefers /api/v1/auth/register { user, authId } (JSON).
+ // Policy supports grants, nodeAttrs, tests, sshTests; check before save.
+ apikeys: {
+ get: '/api/v1/apikey',
+ createApiKey: (expiration: string) => ({
+ url: `/api/v1/apikey`,
+ method: 'POST',
+ body: { expiration }
+ }),
+ expireApiKey: (prefixOrId: string | number) => ({
+ url: `/api/v1/apikey/expire`,
+ method: 'POST',
+ body: typeof prefixOrId === 'number' || /^\d+$/.test(String(prefixOrId))
+ ? { id: Number(prefixOrId) }
+ : { prefix: String(prefixOrId) }
+ }),
+ },
+
+ devices: {
+ get: '/api/v1/node',
+ // Preferred registration path on v0.29 (nodes register is deprecated).
+ registerDevice: (user: number | string, authId: string) => ({
+ url: `/api/v1/auth/register`,
+ method: 'POST',
+ body: { user: String(user), authId },
+ }),
+ renameDevice: (id: number, newName: string) => ({
+ url: `/api/v1/node/${id}/rename/${newName}`,
+ method: 'POST',
+ }),
+ deleteDevice: (id: number) => ({
+ url: `/api/v1/node/${id}`,
+ method: 'DELETE',
+ }),
+ addTags: (id: number, tags: string[]) => ({
+ url: `/api/v1/node/${id}/tags`,
+ method: 'POST',
+ body: { tags }
+ }),
+ // MoveNode removed in v0.28; kept for type compatibility.
+ changeUser: (id: number, user: number) => ({
+ url: `/api/v1/node/${id}/user`,
+ method: 'POST',
+ body: { user }
+ }),
+ },
+
+ preauthkeys: {
+ get: () => ({
+ url: `/api/v1/preauthkey`,
+ method: 'GET',
+ }),
+ createPreauthKey: (user: number, expiration: string, reusable: boolean) => ({
+ url: `/api/v1/preauthkey`,
+ method: 'POST',
+ body: { user, expiration, reusable },
+ }),
+ expirePreauthKey: (_user: number, keyId: string) => ({
+ url: `/api/v1/preauthkey/expire`,
+ method: 'POST',
+ body: { id: Number(keyId) },
+ }),
+ },
+
+ routes: {
+ get: '/api/v1/routes',
+ update: (id: string, routes: string[]) => ({
+ url: `/api/v1/node/${id}/approve_routes`,
+ method: 'POST',
+ body: { routes }
+ }),
+ },
+
+ users: {
+ get: '/api/v1/user',
+ addUser: (name: string) => ({
+ url: `/api/v1/user`,
+ method: 'POST',
+ body: { name }
+ }),
+ deleteUser: (id: number) => ({
+ url: `/api/v1/user/${id}`,
+ method: 'DELETE',
+ }),
+ renameUser: (id: number, newName: string) => ({
+ url: `/api/v1/user/${id}/rename/${newName}`,
+ method: 'POST',
+ }),
+ },
+
+ acl: {
+ getPolicy: '/api/v1/policy',
+ updatePolicy: (policy: any) => ({
+ url: '/api/v1/policy',
+ method: 'PUT',
+ body: JSON.stringify(policy)
+ }),
+ checkPolicy: (policy: string) => ({
+ url: '/api/v1/policy/check',
+ method: 'POST',
+ body: { policy },
+ }),
+ },
+
+ auth: {
+ approve: (authId: string) => ({
+ url: '/api/v1/auth/approve',
+ method: 'POST',
+ body: { authId },
+ }),
+ reject: (authId: string) => ({
+ url: '/api/v1/auth/reject',
+ method: 'POST',
+ body: { authId },
+ }),
+ },
+ },
};
\ No newline at end of file
diff --git a/app/customScreens/[id].tsx b/app/customScreens/[id].tsx
index 9f08b98..0a8cdac 100644
--- a/app/customScreens/[id].tsx
+++ b/app/customScreens/[id].tsx
@@ -8,6 +8,7 @@ import { MaterialIcons } from "@expo/vector-icons";
import { useRouter, useLocalSearchParams } from "expo-router";
import { useDeviceDetail } from "../funcs/deviceDetail";
import { formatDate, getTimeAgo, copyToClipboard } from "../utils/deviceUtils";
+import { isNullExpiry } from "../utils/registrationUtils";
import { InfoRow } from "../components/InfoRow";
import { UserSelectionModal } from "../components/UserSelectionModal";
import { TagsModal } from "../components/TagsModal";
@@ -41,6 +42,7 @@ export default function DeviceDetailScreen() {
handleApproveRoutes,
handleRemoveRoute,
handleDelete,
+ canChangeUser,
} = useDeviceDetail(deviceData);
const appliedTags = device?.tags || device?.validTags || [];
@@ -201,12 +203,17 @@ export default function DeviceDetailScreen() {
)}
+ {device.name && device.givenName && device.name !== device.givenName && (
+
+ )}
-
- {device.expiry && device.expiry !== "0001-01-01T00:00:00Z" && (
-
+
+ {!isNullExpiry(device.expiry) ? (
+
+ ) : (
+
)}
@@ -214,9 +221,13 @@ export default function DeviceDetailScreen() {
User Assignment
- setShowUserModal(true)} className="bg-blue-600 px-3 py-1 rounded">
- Change
-
+ {canChangeUser ? (
+ setShowUserModal(true)} className="bg-blue-600 px-3 py-1 rounded">
+ Change
+
+ ) : (
+ Fixed at registration (v0.28+)
+ )}
diff --git a/app/funcs/accounts.ts b/app/funcs/accounts.ts
index 1d56efb..5185921 100644
--- a/app/funcs/accounts.ts
+++ b/app/funcs/accounts.ts
@@ -6,7 +6,7 @@ import { testAPIKeyDetailed } from "../api/login";
import { normalizeApiKey } from "../utils/apiKeyUtils";
import { parseVersion } from "../utils/getServer";
-export type HeadscaleVersion = "0.23.x" | "0.24.x" | "0.25.x" | "0.26.x" | "0.27.x" | "0.28.x";
+export type HeadscaleVersion = "0.23.x" | "0.24.x" | "0.25.x" | "0.26.x" | "0.27.x" | "0.28.x" | "0.29.x";
interface ServerAccount {
name: string;
diff --git a/app/funcs/deviceDetail.ts b/app/funcs/deviceDetail.ts
index 2bca66a..633638b 100644
--- a/app/funcs/deviceDetail.ts
+++ b/app/funcs/deviceDetail.ts
@@ -18,6 +18,7 @@ export function useDeviceDetail(deviceData: string | undefined) {
const [showRoutesModal, setShowRoutesModal] = useState(false);
const [selectedRoutes, setSelectedRoutes] = useState([]);
const [newTags, setNewTags] = useState("");
+ const [canChangeUser, setCanChangeUser] = useState(true);
useEffect(() => {
if (deviceData) {
@@ -25,6 +26,12 @@ export function useDeviceDetail(deviceData: string | undefined) {
const parsedDevice: Device = JSON.parse(deviceData);
setDevice(parsedDevice);
loadUsers();
+ (async () => {
+ const versionInfo = await getVersionInfo();
+ if (versionInfo?.versionKey) {
+ setCanChangeUser(!isV028OrHigher(versionInfo.versionKey.replace(/^v/, "")));
+ }
+ })();
} catch (error) {
console.error("Failed to parse device data:", error);
Toast.show({
@@ -386,5 +393,6 @@ export function useDeviceDetail(deviceData: string | undefined) {
handleApproveRoutes,
handleRemoveRoute,
handleDelete,
+ canChangeUser,
};
}
diff --git a/app/funcs/devices.ts b/app/funcs/devices.ts
index e5ffd4b..a4d139e 100644
--- a/app/funcs/devices.ts
+++ b/app/funcs/devices.ts
@@ -1,10 +1,12 @@
import { useEffect, useState } from "react";
import { getDevices, registerDevice } from "../api/devices";
+import { approveAuth, rejectAuth } from "../api/auth";
import { getUsers } from "../api/users";
import Toast from "react-native-toast-message";
import { useRouter } from "expo-router";
import { getApiEndpoints } from "../utils/apiUtils";
-import { isV026OrHigher } from "../utils/headscaleVersion";
+import { isV026OrHigher, isV029OrHigher } from "../utils/headscaleVersion";
+import { parseRegistrationInput } from "../utils/registrationUtils";
import { Device } from "../types";
export function useDevices() {
@@ -14,11 +16,17 @@ export function useDevices() {
const [showRegisterModal, setShowRegisterModal] = useState(false);
const [selectedUser, setSelectedUser] = useState(null);
const [deviceKey, setDeviceKey] = useState("");
+ const [serverVersion, setServerVersion] = useState("");
const router = useRouter();
const fetchDevices = async () => {
setLoading(true);
try {
+ const config = await getApiEndpoints();
+ if (config?.serverConf?.version) {
+ setServerVersion(config.serverConf.version);
+ }
+
const [devicesData, usersData] = await Promise.all([
getDevices(),
getUsers()
@@ -53,7 +61,6 @@ export function useDevices() {
fetchDevices();
}, []);
- // Get appropriate icon based on device name/type
const getDeviceTypeIcon = (deviceName: string = ""): any => {
const name = deviceName.toLowerCase();
@@ -69,7 +76,6 @@ export function useDevices() {
return 'devices-other';
};
- // Format last seen time
const getLastSeenText = (lastSeen: string): string => {
try {
const lastSeenDate = new Date(lastSeen);
@@ -91,39 +97,39 @@ export function useDevices() {
}
};
- // Get count of online devices
const getOnlineDevicesCount = (): number => {
return devices.filter(device => device.online).length;
};
- // Sort devices by different criteria
const sortDevices = (deviceList: Device[], sortBy: "name" | "lastSeen" | "user"): Device[] => {
return [...deviceList].sort((a, b) => {
switch (sortBy) {
- case "name":
+ case "name": {
const nameA = (a.givenName || a.name || "").toLowerCase();
const nameB = (b.givenName || b.name || "").toLowerCase();
return nameA.localeCompare(nameB);
-
- case "lastSeen":
+ }
+ case "lastSeen": {
const dateA = new Date(a.lastSeen || 0).getTime();
const dateB = new Date(b.lastSeen || 0).getTime();
- return dateB - dateA; // Most recent first
-
- case "user":
+ return dateB - dateA;
+ }
+ case "user": {
const userA = (a.user?.name || "").toLowerCase();
const userB = (b.user?.name || "").toLowerCase();
return userA.localeCompare(userB);
-
+ }
default:
return 0;
}
});
};
- const confirmAndRegister = async (user: any, key: string) => {
+ const registrationFailed = (result: any) =>
+ !result || result.error || (result.code !== undefined && result.code >= 400);
+
+ const confirmAndRegister = async (user: any, keyOrAuthId: string) => {
try {
- // Check server version to determine whether to use ID or name
const config = await getApiEndpoints();
if (!config) {
Toast.show({
@@ -137,20 +143,17 @@ export function useDevices() {
const { serverConf } = config;
const useNumericIds = isV026OrHigher(serverConf.version);
-
- // Use user ID for v0.26+ or name for older versions
const userParam = useNumericIds ? user.id : user.name;
- console.log(userParam, key)
- const result = await registerDevice(userParam, key);
+ const result = await registerDevice(userParam, keyOrAuthId);
- if (result) {
+ if (!registrationFailed(result)) {
Toast.show({
type: "success",
position: "top",
text1: "✅ Device Registered",
text2: `Device registered successfully for ${user.name}!`,
});
- await fetchDevices(); // Refresh device list
+ await fetchDevices();
setShowRegisterModal(false);
setSelectedUser(null);
setDeviceKey("");
@@ -159,7 +162,9 @@ export function useDevices() {
type: "error",
position: "top",
text1: "⚠️ Registration Failed",
- text2: "Failed to register device. Check your credentials.",
+ text2:
+ result?.message ||
+ "Failed to register device. Check the auth ID / key and user.",
});
}
} catch (error) {
@@ -173,6 +178,44 @@ export function useDevices() {
}
};
+ const confirmAuthAction = async (
+ action: "approve" | "reject",
+ authId: string,
+ ) => {
+ try {
+ const result =
+ action === "approve"
+ ? await approveAuth(authId)
+ : await rejectAuth(authId);
+
+ if (!registrationFailed(result)) {
+ Toast.show({
+ type: "success",
+ position: "top",
+ text1: action === "approve" ? "✅ Auth Approved" : "✅ Auth Rejected",
+ text2: `Auth request ${authId} ${action}d.`,
+ });
+ setShowRegisterModal(false);
+ setDeviceKey("");
+ } else {
+ Toast.show({
+ type: "error",
+ position: "top",
+ text1: "⚠️ Auth Action Failed",
+ text2: result?.message || `Failed to ${action} auth request.`,
+ });
+ }
+ } catch (error) {
+ console.error("Auth action error:", error);
+ Toast.show({
+ type: "error",
+ position: "top",
+ text1: "⚠️ Auth Action Error",
+ text2: `An error occurred while trying to ${action}.`,
+ });
+ }
+ };
+
const handleRegisterDevice = () => {
if (users.length === 0) {
Toast.show({
@@ -186,45 +229,78 @@ export function useDevices() {
setShowRegisterModal(true);
};
- const handleKeyInput = (input: string) => {
- // Regex to match "headscale nodes register --user USERNAME --key KEY"
- const fullCommandMatch = input.match(
- /headscale\s+nodes?\s+register\s+--user\s+([^\s]+)\s+--key\s+([A-Za-z0-9:_-]+)/i
+ const resolveUser = (nameOrId?: string) => {
+ if (!nameOrId) return selectedUser;
+ return (
+ users.find(
+ (u) =>
+ u.name === nameOrId ||
+ String(u.id) === String(nameOrId),
+ ) || selectedUser
);
-
- if (fullCommandMatch) {
- //const username = fullCommandMatch[1];
- const preAuthKey = fullCommandMatch[2];
- if (preAuthKey) {
- setDeviceKey(preAuthKey);
- confirmAndRegister(selectedUser, preAuthKey);
- } else {
+ };
+
+ const handleKeyInput = async (input: string) => {
+ const parsed = parseRegistrationInput(input);
+
+ if (parsed.kind === "auth-approve") {
+ await confirmAuthAction("approve", parsed.authId);
+ return;
+ }
+ if (parsed.kind === "auth-reject") {
+ await confirmAuthAction("reject", parsed.authId);
+ return;
+ }
+
+ if (parsed.kind === "auth-register") {
+ const user = resolveUser(parsed.user);
+ if (!user) {
Toast.show({
type: "error",
position: "top",
- text1: "⚠️ User Not Found",
- text2: `User "${selectedUser}" not found in the system`,
+ text1: "⚠️ No User Selected",
+ text2: "Select a user or include --user in the auth register command.",
});
+ return;
}
- } else {
- console.log("Treating as key only");
- // Treat as just a key input
- const trimmedKey = input.trim();
- setDeviceKey(trimmedKey);
-
- if (selectedUser) {
- confirmAndRegister(selectedUser, trimmedKey);
- } else {
+ setDeviceKey(parsed.authId);
+ await confirmAndRegister(user, parsed.authId);
+ return;
+ }
+
+ if (parsed.kind === "node-register") {
+ const user = resolveUser(parsed.user);
+ if (!user) {
Toast.show({
type: "error",
position: "top",
text1: "⚠️ No User Selected",
- text2: "Please select a user before registering with just a key.",
+ text2: "Select a user or include --user in the register command.",
});
+ return;
}
+ setDeviceKey(parsed.key);
+ await confirmAndRegister(user, parsed.key);
+ return;
}
+
+ const trimmedKey = parsed.value;
+ setDeviceKey(trimmedKey);
+
+ if (!selectedUser) {
+ Toast.show({
+ type: "error",
+ position: "top",
+ text1: "⚠️ No User Selected",
+ text2: isV029OrHigher(serverVersion)
+ ? "Select a user before registering with an auth ID."
+ : "Please select a user before registering with just a key.",
+ });
+ return;
+ }
+
+ await confirmAndRegister(selectedUser, trimmedKey);
};
-
const handleDevicePress = (device: Device) => {
router.push({
@@ -235,12 +311,10 @@ export function useDevices() {
});
};
- // Get devices by user
const getDevicesByUser = (userId: string): Device[] => {
return devices.filter(device => device.user?.id === userId);
};
- // Get device statistics
const getDeviceStats = () => {
const totalDevices = devices.length;
const onlineDevices = devices.filter(d => d.online).length;
@@ -274,8 +348,7 @@ export function useDevices() {
};
const handleModalRegister = () => {
- console.log(selectedUser, deviceKey)
- if (!selectedUser) {
+ if (!selectedUser && !/headscale\s+auth\s+(approve|reject)/i.test(deviceKey)) {
Toast.show({
type: "error",
position: "top",
@@ -288,15 +361,57 @@ export function useDevices() {
Toast.show({
type: "error",
position: "top",
- text1: "⚠️ No Key",
- text2: "Please enter a device key",
+ text1: isV029OrHigher(serverVersion) ? "⚠️ No Auth ID" : "⚠️ No Key",
+ text2: isV029OrHigher(serverVersion)
+ ? "Enter an auth ID or paste a headscale auth command"
+ : "Please enter a device key",
});
return;
}
- console.log(deviceKey)
handleKeyInput(deviceKey);
};
+ const extractAuthId = (input: string) => {
+ const parsed = parseRegistrationInput(input);
+ if (
+ parsed.kind === "auth-approve" ||
+ parsed.kind === "auth-reject" ||
+ parsed.kind === "auth-register"
+ ) {
+ return parsed.authId;
+ }
+ if (parsed.kind === "raw") return parsed.value;
+ return "";
+ };
+
+ const handleModalApprove = () => {
+ const authId = extractAuthId(deviceKey);
+ if (!authId) {
+ Toast.show({
+ type: "error",
+ position: "top",
+ text1: "⚠️ No Auth ID",
+ text2: "Enter an auth ID to approve.",
+ });
+ return;
+ }
+ confirmAuthAction("approve", authId);
+ };
+
+ const handleModalReject = () => {
+ const authId = extractAuthId(deviceKey);
+ if (!authId) {
+ Toast.show({
+ type: "error",
+ position: "top",
+ text1: "⚠️ No Auth ID",
+ text2: "Enter an auth ID to reject.",
+ });
+ return;
+ }
+ confirmAuthAction("reject", authId);
+ };
+
return {
devices,
users,
@@ -311,7 +426,7 @@ export function useDevices() {
getDevicesByUser,
getDeviceStats,
router,
- // Modal state and handlers
+ serverVersion,
showRegisterModal,
selectedUser,
deviceKey,
@@ -319,5 +434,7 @@ export function useDevices() {
setDeviceKey,
handleModalClose,
handleModalRegister,
+ handleModalApprove,
+ handleModalReject,
};
-}
\ No newline at end of file
+}
diff --git a/app/funcs/index.ts b/app/funcs/index.ts
index 73aa234..0afd435 100644
--- a/app/funcs/index.ts
+++ b/app/funcs/index.ts
@@ -6,7 +6,7 @@ import { testAPIKeyDetailed } from "../api/login";
import { normalizeApiKey } from "../utils/apiKeyUtils";
import { parseVersion } from "../utils/getServer";
-export type HeadscaleVersion = "0.23.x" | "0.24.x" | "0.25.x" | "0.26.x" | "0.27.x" | "0.28.x";
+export type HeadscaleVersion = "0.23.x" | "0.24.x" | "0.25.x" | "0.26.x" | "0.27.x" | "0.28.x" | "0.29.x";
export function useLogin() {
const router = useRouter();
@@ -14,7 +14,7 @@ export function useLogin() {
const [customName, setCustomName] = useState("");
const [server, setServer] = useState("");
const [apiKey, setApiKey] = useState("");
- const [headscaleVersion, setHeadscaleVersion] = useState("0.26.x");
+ const [headscaleVersion, setHeadscaleVersion] = useState("0.29.x");
const [showInfo, setShowInfo] = useState(null);
const [loading, setLoading] = useState(true);
diff --git a/app/index.tsx b/app/index.tsx
index fc93cb4..9354419 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -13,7 +13,7 @@ import {
import { MaterialIcons } from "@expo/vector-icons";
import { useLogin, HeadscaleVersion } from "@/app/funcs/index";
-const VERSION_OPTIONS: HeadscaleVersion[] = ["0.28.x", "0.27.x", "0.26.x", "0.25.x", "0.24.x", "0.23.x"];
+const VERSION_OPTIONS: HeadscaleVersion[] = ["0.29.x", "0.28.x", "0.27.x", "0.26.x", "0.25.x", "0.24.x", "0.23.x"];
export default function LoginScreen() {
const {
diff --git a/app/utils/apiUtils.ts b/app/utils/apiUtils.ts
index 7a6eb24..3e84a5f 100644
--- a/app/utils/apiUtils.ts
+++ b/app/utils/apiUtils.ts
@@ -103,8 +103,8 @@ export async function getApiEndpoints(): Promise<{ endpoints: ApiEndpoints; serv
const endpoints = API_VERSION_MAP[versionKey];
if (!endpoints) {
- console.warn(`No API endpoints found for version ${versionKey}, using default v0.26`);
- return { endpoints: API_VERSION_MAP['v0.26'], serverConf };
+ console.warn(`No API endpoints found for version ${versionKey}, using default v0.29`);
+ return { endpoints: API_VERSION_MAP['v0.29'], serverConf };
}
return { endpoints, serverConf };
diff --git a/app/utils/headscaleVersion.ts b/app/utils/headscaleVersion.ts
index 808cef7..fffb6eb 100644
--- a/app/utils/headscaleVersion.ts
+++ b/app/utils/headscaleVersion.ts
@@ -16,3 +16,10 @@ export function isV028OrHigher(version?: string): boolean {
if (!Number.isFinite(major) || !Number.isFinite(minor)) return false;
return major > 0 || minor >= 28;
}
+
+export function isV029OrHigher(version?: string): boolean {
+ const key = getVersionKey(version).replace(/^v/, "");
+ const [major, minor] = key.split(".").map((part) => Number(part));
+ if (!Number.isFinite(major) || !Number.isFinite(minor)) return false;
+ return major > 0 || minor >= 29;
+}
diff --git a/app/utils/registrationUtils.ts b/app/utils/registrationUtils.ts
new file mode 100644
index 0000000..81dbec8
--- /dev/null
+++ b/app/utils/registrationUtils.ts
@@ -0,0 +1,70 @@
+/**
+ * Helpers for parsing device registration / auth inputs across Headscale versions.
+ *
+ * v0.29 prefers: headscale auth register --user --auth-id
+ * Older: headscale nodes register --user --key
+ */
+
+export type ParsedRegistrationInput =
+ | { kind: "auth-register"; user?: string; authId: string }
+ | { kind: "node-register"; user?: string; key: string }
+ | { kind: "auth-approve"; authId: string }
+ | { kind: "auth-reject"; authId: string }
+ | { kind: "raw"; value: string };
+
+export function parseRegistrationInput(input: string): ParsedRegistrationInput {
+ const trimmed = input.trim();
+ if (!trimmed) return { kind: "raw", value: "" };
+
+ const authRegister = trimmed.match(
+ /headscale\s+auth\s+register\s+.*?--user\s+([^\s]+).*?--auth-id\s+([^\s]+)/i,
+ ) || trimmed.match(
+ /headscale\s+auth\s+register\s+.*?--auth-id\s+([^\s]+).*?--user\s+([^\s]+)/i,
+ );
+ if (authRegister) {
+ // Groups depend on which pattern matched
+ if (/--user\s+[^\s]+\s+.*?--auth-id/i.test(trimmed)) {
+ return { kind: "auth-register", user: authRegister[1], authId: authRegister[2] };
+ }
+ return { kind: "auth-register", authId: authRegister[1], user: authRegister[2] };
+ }
+
+ const authApprove = trimmed.match(
+ /headscale\s+auth\s+approve\s+.*?--auth-id\s+([^\s]+)/i,
+ );
+ if (authApprove) {
+ return { kind: "auth-approve", authId: authApprove[1] };
+ }
+
+ const authReject = trimmed.match(
+ /headscale\s+auth\s+reject\s+.*?--auth-id\s+([^\s]+)/i,
+ );
+ if (authReject) {
+ return { kind: "auth-reject", authId: authReject[1] };
+ }
+
+ const nodeRegister = trimmed.match(
+ /headscale\s+nodes?\s+register\s+.*?--user\s+([^\s]+).*?--key\s+([A-Za-z0-9:_-]+)/i,
+ ) || trimmed.match(
+ /headscale\s+nodes?\s+register\s+.*?--key\s+([A-Za-z0-9:_-]+).*?--user\s+([^\s]+)/i,
+ );
+ if (nodeRegister) {
+ if (/--user\s+[^\s]+\s+.*?--key/i.test(trimmed)) {
+ return { kind: "node-register", user: nodeRegister[1], key: nodeRegister[2] };
+ }
+ return { kind: "node-register", key: nodeRegister[1], user: nodeRegister[2] };
+ }
+
+ // Bare auth-id / key paste
+ return { kind: "raw", value: trimmed };
+}
+
+/** Zero-value timestamps Headscale used historically for "no expiry". */
+export function isNullExpiry(expiry?: string | null): boolean {
+ if (!expiry) return true;
+ return (
+ expiry === "0001-01-01T00:00:00Z" ||
+ expiry.startsWith("0001-01-01") ||
+ expiry === "null"
+ );
+}
diff --git a/hooks/useACL.ts b/hooks/useACL.ts
index 80fd5a8..a0447c5 100644
--- a/hooks/useACL.ts
+++ b/hooks/useACL.ts
@@ -1,6 +1,8 @@
import { useState, useEffect, useCallback } from "react";
import { Alert } from "react-native";
-import { getACLPolicy, updateACLPolicy } from "@/app/api/acl";
+import { checkACLPolicy, getACLPolicy, updateACLPolicy } from "@/app/api/acl";
+import { getApiEndpoints } from "@/app/utils/apiUtils";
+import { isV029OrHigher } from "@/app/utils/headscaleVersion";
interface PolicyVersion {
id: string;
@@ -21,6 +23,7 @@ interface ACLHookReturn {
showSetupGuide: boolean;
showErrorModal: boolean;
currentError: any;
+ serverVersion: string;
// Actions
fetchPolicy: () => Promise;
@@ -40,7 +43,6 @@ interface ACLHookReturn {
}
export const useACL = (): ACLHookReturn => {
- // State management
const [policy, setPolicy] = useState("");
const [originalPolicy, setOriginalPolicy] = useState("");
const [loading, setLoading] = useState(false);
@@ -52,18 +54,22 @@ export const useACL = (): ACLHookReturn => {
const [showSetupGuide, setShowSetupGuide] = useState(false);
const [showErrorModal, setShowErrorModal] = useState(false);
const [currentError, setCurrentError] = useState(null);
+ const [serverVersion, setServerVersion] = useState("");
- // Fetch policy function
const fetchPolicy = useCallback(async () => {
try {
setLoading(true);
+ const config = await getApiEndpoints();
+ if (config?.serverConf?.version) {
+ setServerVersion(config.serverConf.version);
+ }
+
const response = await getACLPolicy();
console.log('Fetch response:', response);
if (response && "policy" in response) {
let formattedPolicy = response.policy;
- // Parse and format JSON properly
try {
const parsedPolicy = JSON.parse(formattedPolicy);
formattedPolicy = JSON.stringify(parsedPolicy, null, 2);
@@ -79,7 +85,6 @@ export const useACL = (): ACLHookReturn => {
} catch (error: any) {
console.error("Error fetching policy:", error);
- // Check for specific errors and show error modal
const errorMessage = error?.message || error?.toString() || '';
if (errorMessage.includes('acl policy not found') ||
@@ -102,7 +107,6 @@ export const useACL = (): ACLHookReturn => {
return;
}
- // Generic error
setCurrentError({ message: errorMessage });
Alert.alert("Error", "Failed to fetch ACL policy. Check your connection.");
} finally {
@@ -110,13 +114,11 @@ export const useACL = (): ACLHookReturn => {
}
}, []);
- // Save policy function
const savePolicy = useCallback(async () => {
try {
setSaving(true);
console.log("Saving policy:", editText);
- // Validate JSON
let parsedPolicy;
try {
parsedPolicy = JSON.parse(editText);
@@ -126,24 +128,36 @@ export const useACL = (): ACLHookReturn => {
return;
}
- // Save current version to history
+ if (isV029OrHigher(serverVersion)) {
+ const checkResult = await checkACLPolicy(editText);
+ if (
+ checkResult &&
+ !checkResult.skipped &&
+ (checkResult.error || (checkResult.code !== undefined && checkResult.code >= 400))
+ ) {
+ Alert.alert(
+ "Policy Check Failed",
+ checkResult.message ||
+ "Headscale rejected this policy (ACL/grants/tests validation). Fix the errors and try again.",
+ );
+ return;
+ }
+ }
+
if (policy) {
const newVersion: PolicyVersion = {
id: Date.now().toString(),
policy: policy,
timestamp: new Date(),
};
- setPolicyVersions(prev => [newVersion, ...prev].slice(0, 20)); // Keep last 20 versions
+ setPolicyVersions(prev => [newVersion, ...prev].slice(0, 20));
}
- // Update the policy
const response = await updateACLPolicy(parsedPolicy);
- // Check if response shows an error
if (response && response.code) {
let errorMessage = '';
- // Check if it's a server error response
if (response.code !== undefined && response.message) {
errorMessage = `Server Error (Code ${response.code}): ${response.message}`;
} else if (response.error) {
@@ -159,7 +173,6 @@ export const useACL = (): ACLHookReturn => {
}
}
- // Check if response is successful
if (response && !response.error && !response.code) {
setPolicy(editText);
setOriginalPolicy(editText);
@@ -188,15 +201,13 @@ export const useACL = (): ACLHookReturn => {
} finally {
setSaving(false);
}
- }, [editText, policy]);
+ }, [editText, policy, serverVersion]);
- // Start editing function
const startEditing = useCallback(() => {
setEditText(policy);
setEditing(true);
}, [policy]);
- // Cancel editing function
const cancelEditing = useCallback(() => {
if (editText !== policy) {
Alert.alert(
@@ -220,7 +231,6 @@ export const useACL = (): ACLHookReturn => {
}
}, [editText, policy]);
- // Restore version function
const restoreVersion = useCallback((version: PolicyVersion) => {
Alert.alert(
"Restore Policy Version?",
@@ -239,7 +249,6 @@ export const useACL = (): ACLHookReturn => {
);
}, []);
- // Delete version function
const deleteVersion = useCallback((versionId: string) => {
Alert.alert(
"Delete Version",
@@ -257,18 +266,15 @@ export const useACL = (): ACLHookReturn => {
);
}, []);
- // Refresh function
const onRefresh = useCallback(() => {
fetchPolicy();
}, [fetchPolicy]);
- // Initialize on mount
useEffect(() => {
fetchPolicy();
}, [fetchPolicy]);
return {
- // State
policy,
originalPolicy,
loading,
@@ -280,8 +286,7 @@ export const useACL = (): ACLHookReturn => {
showSetupGuide,
showErrorModal,
currentError,
-
- // Actions
+ serverVersion,
fetchPolicy,
savePolicy,
startEditing,
@@ -289,8 +294,6 @@ export const useACL = (): ACLHookReturn => {
restoreVersion,
deleteVersion,
onRefresh,
-
- // Modal controls
setShowVersions,
setShowSetupGuide,
setShowErrorModal,
@@ -298,4 +301,3 @@ export const useACL = (): ACLHookReturn => {
setEditText,
};
};
-