mirror of
https://github.com/tale/headplane.git
synced 2026-08-12 22:56:53 +00:00
feat: fix acl api logic to work correctly
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { ApiClient } from '~/server/headscale/api-client';
|
||||
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
|
||||
|
||||
export abstract class Integration<T> {
|
||||
protected context: NonNullable<T>;
|
||||
@@ -11,6 +11,6 @@ export abstract class Integration<T> {
|
||||
}
|
||||
|
||||
abstract isAvailable(): Promise<boolean> | boolean;
|
||||
abstract onConfigChange(client: ApiClient): Promise<void> | void;
|
||||
abstract onConfigChange(client: RuntimeApiClient): Promise<void> | void;
|
||||
abstract get name(): string;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { constants, access } from 'node:fs/promises';
|
||||
import { access, constants } from 'node:fs/promises';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { Client } from 'undici';
|
||||
import { ApiClient } from '~/server/headscale/api-client';
|
||||
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
|
||||
import log from '~/utils/log';
|
||||
import type { HeadplaneConfig } from '../schema';
|
||||
import { Integration } from './abstract';
|
||||
@@ -193,7 +193,7 @@ export default class DockerIntegration extends Integration<T> {
|
||||
return this.client !== undefined && this.containerId !== undefined;
|
||||
}
|
||||
|
||||
async onConfigChange(client: ApiClient) {
|
||||
async onConfigChange(client: RuntimeApiClient) {
|
||||
if (!this.client) {
|
||||
return;
|
||||
}
|
||||
@@ -233,14 +233,14 @@ export default class DockerIntegration extends Integration<T> {
|
||||
while (attempts <= this.maxAttempts) {
|
||||
try {
|
||||
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
|
||||
const status = await client.healthcheck();
|
||||
const status = await client.isHealthy();
|
||||
if (status === false) {
|
||||
throw new Error('Headscale is not running');
|
||||
}
|
||||
|
||||
log.info('config', 'Headscale is up and running');
|
||||
return;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
if (attempts < this.maxAttempts) {
|
||||
attempts++;
|
||||
await setTimeout(1000);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { platform } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { kill } from 'node:process';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { CoreV1Api, KubeConfig } from '@kubernetes/client-node';
|
||||
import { ApiClient } from '~/server/headscale/api-client';
|
||||
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
|
||||
import log from '~/utils/log';
|
||||
import { HeadplaneConfig } from '../schema';
|
||||
import type { HeadplaneConfig } from '../schema';
|
||||
import { Integration } from './abstract';
|
||||
|
||||
// https://github.com/kubernetes-client/javascript/blob/055b83c6504dfd1b2a2d081efd974163c6cbb808/src/config.ts#L40
|
||||
@@ -202,7 +202,7 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
}
|
||||
}
|
||||
|
||||
async onConfigChange(client: ApiClient) {
|
||||
async onConfigChange(client: RuntimeApiClient) {
|
||||
if (!this.pid) {
|
||||
return;
|
||||
}
|
||||
@@ -220,14 +220,14 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
while (attempts <= this.maxAttempts) {
|
||||
try {
|
||||
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
|
||||
const status = await client.healthcheck();
|
||||
const status = await client.isHealthy();
|
||||
if (status === false) {
|
||||
throw new Error('Headscale is not running');
|
||||
}
|
||||
|
||||
log.info('config', 'Headscale is up and running');
|
||||
return;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
if (attempts < this.maxAttempts) {
|
||||
attempts++;
|
||||
await setTimeout(1000);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { platform } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { kill } from 'node:process';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { ApiClient } from '~/server/headscale/api-client';
|
||||
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
|
||||
import log from '~/utils/log';
|
||||
import { HeadplaneConfig } from '../schema';
|
||||
import type { HeadplaneConfig } from '../schema';
|
||||
import { Integration } from './abstract';
|
||||
|
||||
type T = NonNullable<HeadplaneConfig['integration']>['proc'];
|
||||
@@ -68,30 +68,37 @@ export default class ProcIntegration extends Integration<T> {
|
||||
pids.join(', '),
|
||||
);
|
||||
|
||||
log.debug('config', 'Checking if any of them have Parent PID = 1, assuming thats the correct PID');
|
||||
const ppidRegex = /(?:PPid:\s)(\d+)(?:\n?)/;
|
||||
for (const pid of pids) {
|
||||
const pidStatusPath = join('/proc', pid.toString(), 'status');
|
||||
try {
|
||||
log.debug('config', 'Reading %s', pidStatusPath);
|
||||
const pidData = await readFile(pidStatusPath, 'utf8');
|
||||
const ppidResult = pidData.match(ppidRegex);
|
||||
log.debug(
|
||||
'config',
|
||||
'Checking if any of them have Parent PID = 1, assuming thats the correct PID',
|
||||
);
|
||||
const ppidRegex = /(?:PPid:\s)(\d+)(?:\n?)/;
|
||||
for (const pid of pids) {
|
||||
const pidStatusPath = join('/proc', pid.toString(), 'status');
|
||||
try {
|
||||
log.debug('config', 'Reading %s', pidStatusPath);
|
||||
const pidData = await readFile(pidStatusPath, 'utf8');
|
||||
const ppidResult = pidData.match(ppidRegex);
|
||||
|
||||
if (ppidResult !== null) {
|
||||
const potentialPPid = Number.parseInt(ppidResult[1], 10);
|
||||
if (potentialPPid === 1) {
|
||||
this.pid = pid;
|
||||
log.info('config', 'Found potential Headscale process with PID: %d based on Parent PID = 1', this.pid);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to read %s: %s', pidStatusPath, error);
|
||||
}
|
||||
}
|
||||
if (ppidResult !== null) {
|
||||
const potentialPPid = Number.parseInt(ppidResult[1], 10);
|
||||
if (potentialPPid === 1) {
|
||||
this.pid = pid;
|
||||
log.info(
|
||||
'config',
|
||||
'Found potential Headscale process with PID: %d based on Parent PID = 1',
|
||||
this.pid,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to read %s: %s', pidStatusPath, error);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pids.length === 0) {
|
||||
log.error('config', 'Could not find Headscale process');
|
||||
@@ -107,7 +114,7 @@ export default class ProcIntegration extends Integration<T> {
|
||||
}
|
||||
}
|
||||
|
||||
async onConfigChange(client: ApiClient) {
|
||||
async onConfigChange(client: RuntimeApiClient) {
|
||||
if (!this.pid) {
|
||||
return;
|
||||
}
|
||||
@@ -125,7 +132,7 @@ export default class ProcIntegration extends Integration<T> {
|
||||
while (attempts <= this.maxAttempts) {
|
||||
try {
|
||||
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
|
||||
const status = await client.healthcheck();
|
||||
const status = await client.isHealthy();
|
||||
if (status === false) {
|
||||
log.error('config', 'Headscale is not running');
|
||||
return;
|
||||
@@ -133,7 +140,7 @@ export default class ProcIntegration extends Integration<T> {
|
||||
|
||||
log.info('config', 'Headscale is up and running');
|
||||
return;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
if (attempts < this.maxAttempts) {
|
||||
attempts++;
|
||||
await setTimeout(1000);
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { data } from 'react-router';
|
||||
import { Agent, Dispatcher, errors, request } from 'undici';
|
||||
import log from '~/utils/log';
|
||||
import ResponseError from './api/response-error';
|
||||
|
||||
function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException {
|
||||
const keys = Object.keys(error as Record<string, unknown>);
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
keys.includes('code') &&
|
||||
keys.includes('errno')
|
||||
);
|
||||
}
|
||||
|
||||
function friendlyError(givenError: unknown) {
|
||||
let error: unknown = givenError;
|
||||
if (error instanceof AggregateError) {
|
||||
error = error.errors[0];
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case error instanceof errors.BodyTimeoutError:
|
||||
case error instanceof errors.ConnectTimeoutError:
|
||||
case error instanceof errors.HeadersTimeoutError:
|
||||
return data('Timed out waiting for a response from the Headscale API', {
|
||||
statusText: 'Request Timeout',
|
||||
status: 408,
|
||||
});
|
||||
|
||||
case error instanceof errors.SocketError:
|
||||
case error instanceof errors.SecureProxyConnectionError:
|
||||
case error instanceof errors.ClientClosedError:
|
||||
case error instanceof errors.ClientDestroyedError:
|
||||
case error instanceof errors.RequestAbortedError:
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
|
||||
case error instanceof errors.InvalidArgumentError:
|
||||
case error instanceof errors.InvalidReturnValueError:
|
||||
case error instanceof errors.NotSupportedError:
|
||||
return data('Unable to make a request (this is most likely a bug)', {
|
||||
statusText: 'Internal Server Error',
|
||||
status: 500,
|
||||
});
|
||||
|
||||
case error instanceof errors.HeadersOverflowError:
|
||||
case error instanceof errors.RequestContentLengthMismatchError:
|
||||
case error instanceof errors.ResponseContentLengthMismatchError:
|
||||
case error instanceof errors.ResponseExceededMaxSizeError:
|
||||
return data('The Headscale API returned a malformed response', {
|
||||
statusText: 'Bad Gateway',
|
||||
status: 502,
|
||||
});
|
||||
|
||||
case isNodeNetworkError(error):
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'ENOTFOUND') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'EAI_AGAIN') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'ETIMEDOUT') {
|
||||
return data('Timed out waiting for a response from the Headscale API', {
|
||||
statusText: 'Request Timeout',
|
||||
status: 408,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'ECONNRESET') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'EPIPE') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'ENETUNREACH') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'ENETRESET') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
|
||||
default:
|
||||
return data((error as Error).message ?? 'An unknown error occurred', {
|
||||
statusText: 'Internal Server Error',
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function createApiClient(base: string, certPath?: string) {
|
||||
if (!certPath) {
|
||||
return new ApiClient(new Agent(), base);
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug('config', 'Loading certificate from %s', certPath);
|
||||
const data = await readFile(certPath, 'utf8');
|
||||
|
||||
log.info('config', 'Using certificate from %s', certPath);
|
||||
return new ApiClient(new Agent({ connect: { ca: data.trim() } }), base);
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to load Headscale TLS cert: %s', error);
|
||||
log.debug('config', 'Error Details: %o', error);
|
||||
return new ApiClient(new Agent(), base);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the new RuntimeApiClient instead.
|
||||
*/
|
||||
export class ApiClient {
|
||||
private agent: Agent;
|
||||
private base: string;
|
||||
|
||||
constructor(agent: Agent, base: string) {
|
||||
this.agent = agent;
|
||||
this.base = base;
|
||||
}
|
||||
|
||||
async defaultFetch(
|
||||
url: string,
|
||||
options?: Partial<Dispatcher.RequestOptions>,
|
||||
) {
|
||||
const method = options?.method ?? 'GET';
|
||||
log.debug('api', '%s %s', method, url);
|
||||
|
||||
try {
|
||||
const res = await request(new URL(url, this.base), {
|
||||
dispatcher: this.agent,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
Accept: 'application/json',
|
||||
'User-Agent': `Headplane/${__VERSION__}`,
|
||||
},
|
||||
body: options?.body,
|
||||
method,
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (error: unknown) {
|
||||
throw friendlyError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the new RuntimeApiClient instead.
|
||||
*/
|
||||
async healthcheck() {
|
||||
try {
|
||||
const res = await request(new URL('/health', this.base), {
|
||||
dispatcher: this.agent,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': `Headplane/${__VERSION__}`,
|
||||
},
|
||||
});
|
||||
|
||||
return res.statusCode === 200;
|
||||
} catch (error) {
|
||||
log.debug('api', 'Healthcheck failed %o', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the new RuntimeApiClient instead.
|
||||
*/
|
||||
async get<T = unknown>(url: string, key: string) {
|
||||
const res = await this.defaultFetch(`/api/${url}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.statusCode >= 400) {
|
||||
log.debug('api', 'GET %s failed with status %d', url, res.statusCode);
|
||||
throw new ResponseError(
|
||||
res.statusCode,
|
||||
await res.body.text(),
|
||||
`GET ${url}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.body.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the new RuntimeApiClient instead.
|
||||
*/
|
||||
async post<T = unknown>(url: string, key: string, body?: unknown) {
|
||||
const res = await this.defaultFetch(`/api/${url}`, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.statusCode >= 400) {
|
||||
log.debug('api', 'POST %s failed with status %d', url, res.statusCode);
|
||||
throw new ResponseError(
|
||||
res.statusCode,
|
||||
await res.body.text(),
|
||||
`POST ${url}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.body.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the new RuntimeApiClient instead.
|
||||
*/
|
||||
async put<T = unknown>(url: string, key: string, body?: unknown) {
|
||||
const res = await this.defaultFetch(`/api/${url}`, {
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.statusCode >= 400) {
|
||||
log.debug('api', 'PUT %s failed with status %d', url, res.statusCode);
|
||||
throw new ResponseError(
|
||||
res.statusCode,
|
||||
await res.body.text(),
|
||||
`PUT ${url}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.body.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the new RuntimeApiClient instead.
|
||||
*/
|
||||
async delete<T = unknown>(url: string, key: string) {
|
||||
const res = await this.defaultFetch(`/api/${url}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.statusCode >= 400) {
|
||||
log.debug('api', 'DELETE %s failed with status %d', url, res.statusCode);
|
||||
throw new ResponseError(
|
||||
res.statusCode,
|
||||
await res.body.text(),
|
||||
`DELETE ${url}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.body.json() as Promise<T>;
|
||||
}
|
||||
}
|
||||
@@ -4,38 +4,38 @@ export interface PolicyEndpoints {
|
||||
/**
|
||||
* Retrieves the current ACL policy from the Headscale instance.
|
||||
*
|
||||
* @returns The ACL policy as a string.
|
||||
* @returns The ACL policy as a string and the date it was last updated.
|
||||
*/
|
||||
getPolicy(): Promise<string>;
|
||||
getPolicy(): Promise<{ policy: string; updatedAt: Date | null }>;
|
||||
|
||||
/**
|
||||
* Sets the ACL policy for the Headscale instance.
|
||||
*
|
||||
* @param policy The ACL policy as a string.
|
||||
* @returns The expiration date of the new policy.
|
||||
* @returns The updated ACL policy as a string and the date it was last updated.
|
||||
*/
|
||||
setPolicy(policy: string): Promise<Date>;
|
||||
setPolicy(policy: string): Promise<{ policy: string; updatedAt: Date }>;
|
||||
}
|
||||
|
||||
export default defineApiEndpoints<PolicyEndpoints>((client, apiKey) => ({
|
||||
getPolicy: async () => {
|
||||
const { policy } = await client.apiFetch<{ policy: string }>(
|
||||
'GET',
|
||||
'v1/policy',
|
||||
apiKey,
|
||||
);
|
||||
const { policy, updatedAt } = await client.apiFetch<{
|
||||
policy: string;
|
||||
updatedAt: string;
|
||||
}>('GET', 'v1/policy', apiKey);
|
||||
|
||||
return policy;
|
||||
return {
|
||||
policy,
|
||||
updatedAt: updatedAt !== null ? new Date(updatedAt) : null,
|
||||
};
|
||||
},
|
||||
|
||||
setPolicy: async (policy) => {
|
||||
const { updatedAt } = await client.apiFetch<{ updatedAt: string }>(
|
||||
'PUT',
|
||||
'v1/policy',
|
||||
apiKey,
|
||||
{ policy },
|
||||
);
|
||||
const { policy: newPolicy, updatedAt } = await client.apiFetch<{
|
||||
policy: string;
|
||||
updatedAt: string;
|
||||
}>('PUT', 'v1/policy', apiKey, { policy });
|
||||
|
||||
return new Date(updatedAt);
|
||||
return { policy: newPolicy, updatedAt: new Date(updatedAt) };
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { constants, access, readFile, writeFile } from 'node:fs/promises';
|
||||
import { access, constants, readFile, writeFile } from 'node:fs/promises';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import log from '~/utils/log';
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { loadIntegration } from './config/integration';
|
||||
import { loadConfig } from './config/loader';
|
||||
import { createDbClient } from './db/client.server';
|
||||
import { createHeadscaleInterface } from './headscale/api';
|
||||
import { createApiClient } from './headscale/api-client';
|
||||
import { loadHeadscaleConfig } from './headscale/config-loader';
|
||||
import { createHeadplaneAgent } from './hp-agent';
|
||||
import { configureOidcAuth } from './web/oidc';
|
||||
@@ -73,11 +72,6 @@ const appLoadContext = {
|
||||
config.headscale.tls_cert_path,
|
||||
),
|
||||
|
||||
client: await createApiClient(
|
||||
config.headscale.url,
|
||||
config.headscale.tls_cert_path,
|
||||
),
|
||||
|
||||
agents,
|
||||
integration: await loadIntegration(config.integration),
|
||||
oidc: config.oidc ? await configureOidcAuth(config.oidc) : undefined,
|
||||
|
||||
Reference in New Issue
Block a user