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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/(tabs)/acl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export default function ACLScreen() {
setShowVersions,
setShowSetupGuide,
setEditText,
serverVersion,
} = useACL();

return (
Expand All @@ -54,6 +55,11 @@ export default function ACLScreen() {
<View className="flex-1">
<Text className="text-white text-2xl font-bold">ACL Policy</Text>
<Text className="text-slate-400 text-sm">Access Control List Management</Text>
{serverVersion?.startsWith("0.29") && (
<Text className="text-slate-500 text-xs mt-1">
v0.29: supports grants, nodeAttrs, tests & sshTests. Policy is checked before save.
</Text>
)}
</View>

<View className="flex-row gap-2">
Expand Down
2 changes: 1 addition & 1 deletion app/(tabs)/apikeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
},
]
);
Expand Down
6 changes: 6 additions & 0 deletions app/(tabs)/devices.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export default function DevicesScreen() {
setDeviceKey,
handleModalClose,
handleModalRegister,
handleModalApprove,
handleModalReject,
serverVersion,
} = useDevices();

const [searchQuery, setSearchQuery] = useState("");
Expand Down Expand Up @@ -288,6 +291,9 @@ export default function DevicesScreen() {
deviceKey={deviceKey}
onKeyChange={setDeviceKey}
onRegister={handleModalRegister}
serverVersion={serverVersion}
onApprove={handleModalApprove}
onReject={handleModalReject}
/>
</SafeAreaView>
);
Expand Down
51 changes: 49 additions & 2 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<BaseToast
{...props}
style={[toastBaseStyle, { borderLeftColor: "#69C779" }]}
contentContainerStyle={{ paddingHorizontal: 16 }}
text1NumberOfLines={2}
text2NumberOfLines={0}
text2Style={{ flexWrap: "wrap" }}
/>
),
error: (props) => (
<ErrorToast
{...props}
style={[toastBaseStyle, { borderLeftColor: "#FE6301" }]}
contentContainerStyle={{ paddingHorizontal: 16 }}
text1NumberOfLines={2}
text2NumberOfLines={0}
text2Style={{ flexWrap: "wrap" }}
/>
),
info: (props) => (
<InfoToast
{...props}
style={[toastBaseStyle, { borderLeftColor: "#87CEFA" }]}
contentContainerStyle={{ paddingHorizontal: 16 }}
text1NumberOfLines={2}
text2NumberOfLines={0}
text2Style={{ flexWrap: "wrap" }}
/>
),
};

export default function RootLayout() {
return (
<SafeAreaProvider>
<Slot />
<Toast
config={toastConfig}
position="top"
topOffset={80}
visibilityTime={5000}
autoHide={true}
/>
</SafeAreaProvider>
)
);
}
10 changes: 6 additions & 4 deletions app/accounts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -31,7 +31,7 @@ export default function Accounts() {
const [customName, setCustomName] = useState("");
const [server, setServer] = useState("");
const [apiKey, setApiKey] = useState("");
const [selectedVersion, setSelectedVersion] = useState<HeadscaleVersion>("0.26.x");
const [selectedVersion, setSelectedVersion] = useState<HeadscaleVersion>("0.29.x");
const [showInfo, setShowInfo] = useState<string | null>(null);
const [editingVersion, setEditingVersion] = useState<string | null>(null);

Expand Down Expand Up @@ -288,8 +288,10 @@ export default function Accounts() {
autoCorrect={false}
/>
<InfoText field="key">
Generate with: {'\n'}
<Text className="text-slate-100 font-mono">headscale apikey create --expiration 90d</Text>
Generate a management API key (not a pre-auth key):{"\n"}
<Text className="text-slate-100 font-mono">headscale apikeys create --expiration 90d</Text>
{"\n"}
v0.28+ format: <Text className="text-slate-100 font-mono">hskey-api-…</Text>
</InfoText>
</View>

Expand Down
40 changes: 33 additions & 7 deletions app/api/acl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -46,4 +45,31 @@ export async function updateACLPolicy(policy: any) {
console.error("Update ACL policy error:", error);
throw error;
}
}
}

/**
* 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;
}
}
14 changes: 8 additions & 6 deletions app/api/apikeys.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getServerConfig } from "../utils/getServer";
import { buildExpireApiKeyBody, normalizeApiKey } from "../utils/apiKeyUtils";
import { fetchWithFallback } from "../utils/apiUtils";

export async function getAPIKeys() {
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -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();

Expand All @@ -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) {
Expand All @@ -90,4 +93,3 @@ export async function expireAPIKey(prefix: string) {
return null;
}
}

35 changes: 35 additions & 0 deletions app/api/auth.ts
Original file line number Diff line number Diff line change
@@ -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),
});
}
24 changes: 18 additions & 6 deletions app/api/devices.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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),
});
}

Expand Down
Loading
Loading