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)/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/(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/_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..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); @@ -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..72978c5 100644 --- a/app/api/acl.ts +++ b/app/api/acl.ts @@ -22,21 +22,20 @@ 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); - + const requestBody = JSON.stringify({ policy: policyString }); - + const updateConfig = endpoints.acl.updatePolicy(policy); - + const response = await makeApiRequest(updateConfig.url, { method: 'PUT', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${serverConf.apiKey}`, }, body: requestBody, }); @@ -46,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/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/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/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/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 3031864..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 }; }; } @@ -519,6 +527,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 +536,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) } }), }, @@ -615,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 04efa82..5185921 100644 --- a/app/funcs/accounts.ts +++ b/app/funcs/accounts.ts @@ -2,10 +2,11 @@ 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"; +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; @@ -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/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 8e04dc6..0afd435 100644 --- a/app/funcs/index.ts +++ b/app/funcs/index.ts @@ -2,10 +2,11 @@ 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"; +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(); @@ -13,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); @@ -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..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 { @@ -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..3e84a5f 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}`, }, }); @@ -98,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/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; 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, }; }; -