rotate api keys on demand

This commit is contained in:
Chris Bisset 2025-03-22 02:41:55 +00:00
parent 58cb788f22
commit 0636ea9419
4 changed files with 144 additions and 15 deletions

View file

@ -17,7 +17,9 @@ export class AppSettingsObject {
sidebarDrawerOpen = false // for determining if the sidebar is open when on a small screen
toastAlerts = new SvelteMap<string, toastAlert>(); // for adding or removing alerts
apiTested = true // used to hide the app if the api tests are failing
apiKeyList: APIKey[] = [] //list of apikeys retrieved from headscale API
apiKeyExpiration?: number = undefined // number of days left until the key in use expires
public constructor(init?: Partial<AppSettingsObject>) {
Object.assign(this, init);
}
@ -32,4 +34,17 @@ export class toastAlert {
public constructor(init?: Partial<toastAlert>) {
Object.assign(this, init);
}
}
// retrieved as an array from headscale
export class APIKey {
id = '' // unique identifier for headscale
prefix = '' // beginning of key to match full string
expiration = '' // when key expires, formatting as datetime
createdAt = '' // date of creation
lastSeen = '' // date last seen, seems to be always null?
public constructor(init?: Partial<APIKey>) {
Object.assign(this, init);
}
}

View file

@ -1,7 +1,7 @@
import { newToastAlert } from "../layout/toast.svelte.ts";
import { appSettings, persistentAppSettings } from "../common/state.svelte";
export async function testAPIConnectivity() {
export async function getAPIKeys() {
try {
const response = await fetch(`${persistentAppSettings.headscaleURL}/api/v1/apikey`, {
method: 'GET',
@ -14,16 +14,113 @@ export async function testAPIConnectivity() {
if (!response.ok) {
newToastAlert(`API test failed (check your server settings): ${response.status}`);
appSettings.apiTested = false;
} else {
appSettings.apiKeyList = (await response.json()).apiKeys;
appSettings.apiTested = true;
// determine the remaining time for the key we are currently using
appSettings.apiKeyList.forEach(key => {
if (persistentAppSettings.headscaleAPIKey.startsWith(key.prefix)) {
getKeyRemainingTime(new Date(key.expiration));
}
})
}
const data = await response.json();
appSettings.apiTested = true;
if (persistentAppSettings.debugLogging) {
newToastAlert(`API Response: ${JSON.stringify(data)}`);
}
} catch (error) {
let message
let message: string;
if (error instanceof Error) { message = error.message }
else { message = String(error) }
newToastAlert(`API test failed (check your server settings): ${message}`)
appSettings.apiTested = false;
}
}
function getKeyRemainingTime(expiration: Date) {
let currentTime = new Date();
// gets time difference in seconds
appSettings.apiKeyExpiration = Math.round((expiration.getTime() - currentTime.getTime()) / 1000 / 60 / 60 / 24);
if (appSettings.apiKeyExpiration < 30) {
newToastAlert(`${appSettings.apiKeyExpiration} days left before API Key expiry, consider rolling your key`);
}
}
export function rotateAPIKey() {
appSettings.apiKeyList.forEach(key => {
// select the current key being used
if (persistentAppSettings.headscaleAPIKey.startsWith(key.prefix)) {
let currentKey = key;
let newExpiration = new Date();
newExpiration.setDate(newExpiration.getDate() + 90);
// create a new API key with the new new expiration, set it as the current API key,
// and then expire the previous API key
createNewAPIKey(newExpiration).then((apiKey) => {
if (apiKey == undefined) {
throw new Error("expecting API key string, string was undefined");
}
persistentAppSettings.headscaleAPIKey = apiKey;
expireAPIKey(currentKey.prefix).then(() => {
getAPIKeys().then(() => {
// console.log(appSettings.apiKeyList);
});
});
});
}
})
}
export async function createNewAPIKey(expireDate: Date) {
try {
const response = await fetch(`${persistentAppSettings.headscaleURL}/api/v1/apikey`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${persistentAppSettings.headscaleAPIKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
"expiration": expireDate.toISOString()
})
});
if (!response.ok) {
newToastAlert(`Creating new API Key Failed (check your server settings): ${response.status}`);
appSettings.apiTested = false;
} else {
let apiKey = '';
apiKey = (await response.json()).apiKey;
return apiKey;
}
} catch (error) {
let message: string;
if (error instanceof Error) { message = error.message }
else { message = String(error) }
newToastAlert(`API Call Failed (check your server settings): ${message}`)
appSettings.apiTested = false;
}
}
export async function expireAPIKey(apiPrefix: string) {
try {
const response = await fetch(`${persistentAppSettings.headscaleURL}/api/v1/apikey/expire`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${persistentAppSettings.headscaleAPIKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
"prefix": apiPrefix
})
});
if (!response.ok) {
newToastAlert(`API test failed (check your server settings): ${response.status}`);
appSettings.apiTested = false;
}
} catch (error) {
let message: string;
if (error instanceof Error) { message = error.message }
else { message = String(error) }
newToastAlert(`API test failed (check your server settings): ${message}`)

View file

@ -1,25 +1,42 @@
<script lang="ts">
import { persistentAppSettings } from '$lib/components/common/state.svelte';
import { testAPIConnectivity } from './server-settings-functions.svelte.ts';
import { getAPIKeys, rotateAPIKey } from './server-settings-functions.svelte.ts';
import { appSettings } from '$lib/components/common/state.svelte';
import { fly } from 'svelte/transition';
let apiSecretHidden = $state(true); // for hiding or showing the API key
let rotateButtonDisabled = $state(false);
function rotateAPIKeyClick() {
rotateButtonDisabled = true;
rotateAPIKey();
rotateButtonDisabled = false;
}
</script>
<div class="form-control">
<h1 class="bold mb-4 text-xl text-primary">Server Settings</h1>
<form id="server-settings" onsubmit={testAPIConnectivity}>
<form id="server-settings" onsubmit={getAPIKeys}>
<label class="mb-2 block font-bold text-secondary" for="headscaleURL"> Headscale URL </label>
<input id="headscaleURL" bind:value={persistentAppSettings.headscaleURL} class="input input-sm input-bordered w-full" type="url" placeholder="https://hs.yourdomain.com.au" />
<label for="headscaleURL" class="label">
<span class="label-text-alt">URL for your headscale server instance (does not need populating if it's on the same subdomain)</span>
</label>
<label class="mb-2 block font-bold text-secondary" for="headscaleKey"> Headscale API Key </label>
<div class="relative flex">
<label class="mb-2 block font-bold text-secondary" for="headscaleKey"> Headscale API Key </label>
{#if appSettings.apiKeyExpiration != undefined}
<button type="button" disabled={rotateButtonDisabled} onclick={rotateAPIKeyClick} class="tooltip" data-tip="{appSettings.apiKeyExpiration} days left. Click to rotate key" aria-label="check time left"
><svg data-slot="icon" fill="none" class="-my-3 ml-2 h-5 w-5 stroke-success" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"></path>
</svg></button
>
{/if}
</div>
<div class="relative flex">
<input id="headscaleKey" bind:value={persistentAppSettings.headscaleAPIKey} class="input input-sm input-bordered w-full" minlength="40" maxlength="40" type={apiSecretHidden ? 'password' : 'text'} required placeholder="******************" />
<button type="button"
<button
type="button"
class="ml-2"
onclick={() => {
apiSecretHidden = !apiSecretHidden;

View file

@ -7,7 +7,7 @@
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import '../app.css';
import { testAPIConnectivity } from '$lib/components/settings/server-settings-functions.svelte';
import { getAPIKeys } from '$lib/components/settings/server-settings-functions.svelte';
let { children } = $props();
onMount(async () => {
@ -34,7 +34,7 @@
}
// perform an initial API test
testAPIConnectivity();
getAPIKeys();
// delay load until page is hydrated
appSettings.appLoaded = true;