mirror of
https://github.com/tale/headplane.git
synced 2026-08-06 04:07:41 +00:00
feat: update more primitives to use the new api
This commit is contained in:
@@ -1,24 +1,21 @@
|
||||
import { type LoaderFunctionArgs, Outlet, redirect } from 'react-router';
|
||||
import { Outlet, redirect } from 'react-router';
|
||||
import { ErrorPopup } from '~/components/Error';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { pruneEphemeralNodes } from '~/server/db/pruner';
|
||||
import ResponseError from '~/server/headscale/api/response-error';
|
||||
import log from '~/utils/log';
|
||||
import type { Route } from './+types/dashboard';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
...rest
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const healthy = await context.client.healthcheck();
|
||||
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
await pruneEphemeralNodes({ context, request, ...rest });
|
||||
const healthy = await api.isHealthy();
|
||||
|
||||
// We shouldn't session invalidate if Headscale is down
|
||||
// TODO: Notify in the logs or the UI that OIDC auth key is wrong if enabled
|
||||
// TODO: Notify in the logs or the UI whether or not the OIDC auth key is wrong if enabled
|
||||
if (healthy) {
|
||||
try {
|
||||
await context.client.get('v1/apikey', session.api_key);
|
||||
await api.getApiKeys();
|
||||
} catch (error) {
|
||||
if (error instanceof ResponseError) {
|
||||
log.debug('api', 'API Key validation failed %o', error);
|
||||
|
||||
+11
-20
@@ -1,26 +1,18 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { CircleCheckIcon } from 'lucide-react';
|
||||
import {
|
||||
LoaderFunctionArgs,
|
||||
Outlet,
|
||||
redirect,
|
||||
useLoaderData,
|
||||
} from 'react-router';
|
||||
import { Outlet, redirect } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Footer from '~/components/Footer';
|
||||
import Header from '~/components/Header';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { users } from '~/server/db/schema';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import toast from '~/utils/toast';
|
||||
import { Route } from './+types/shell';
|
||||
|
||||
// This loads the bare minimum for the application to function
|
||||
// So we know that if context fails to load then well, oops?
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
try {
|
||||
const session = await context.sessions.auth(request);
|
||||
if (
|
||||
@@ -39,6 +31,7 @@ export async function loader({
|
||||
}
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const check = await context.sessions.check(request, Capabilities.ui_access);
|
||||
return {
|
||||
config: context.hs.c,
|
||||
@@ -62,7 +55,7 @@ export async function loader({
|
||||
),
|
||||
},
|
||||
onboarding: request.url.endsWith('/onboarding'),
|
||||
healthy: await context.client.healthcheck(),
|
||||
healthy: await api.isHealthy(),
|
||||
};
|
||||
} catch {
|
||||
return redirect('/login', {
|
||||
@@ -73,14 +66,12 @@ export async function loader({
|
||||
}
|
||||
}
|
||||
|
||||
export default function Shell() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
export default function Shell({ loaderData }: Route.ComponentProps) {
|
||||
return (
|
||||
<>
|
||||
<Header {...data} />
|
||||
<Header {...loaderData} />
|
||||
{/* Always show the outlet if we are onboarding */}
|
||||
{(data.onboarding ? true : data.uiAccess) ? (
|
||||
{(loaderData.onboarding ? true : loaderData.uiAccess) ? (
|
||||
<Outlet />
|
||||
) : (
|
||||
<Card className="mx-auto w-fit mt-24">
|
||||
@@ -96,13 +87,13 @@ export default function Shell() {
|
||||
className="flex text-md font-mono"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
`tailscale up --login-server=${data.url}`,
|
||||
`tailscale up --login-server=${loaderData.url}`,
|
||||
);
|
||||
|
||||
toast('Copied to clipboard');
|
||||
}}
|
||||
>
|
||||
tailscale up --login-server={data.url}
|
||||
tailscale up --login-server={loaderData.url}
|
||||
</Button>
|
||||
<p className="text-xs mt-1 opacity-50 text-center">
|
||||
Click this button to copy the command.
|
||||
@@ -113,7 +104,7 @@ export default function Shell() {
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
<Footer {...data} />
|
||||
<Footer {...loaderData} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { data, Link as RemixLink, useLoaderData } from 'react-router';
|
||||
import { data, Link as RemixLink } from 'react-router';
|
||||
import Attribute from '~/components/Attribute';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
@@ -58,9 +58,9 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
|
||||
export const action = machineAction;
|
||||
|
||||
export default function Page() {
|
||||
const { node, tags, magic, users, agent, stats } =
|
||||
useLoaderData<typeof loader>();
|
||||
export default function Page({
|
||||
loaderData: { node, tags, users, magic, agent, stats },
|
||||
}: Route.ComponentProps) {
|
||||
const [showRouting, setShowRouting] = useState(false);
|
||||
|
||||
const uiTags = useMemo(() => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Info } from 'lucide-react';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import Code from '~/components/Code';
|
||||
import Link from '~/components/Link';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
@@ -67,9 +66,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
export const action = machineAction;
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
@@ -86,10 +83,10 @@ export default function Page() {
|
||||
</p>
|
||||
</div>
|
||||
<NewMachine
|
||||
disabledKeys={data.preAuth ? [] : ['pre-auth']}
|
||||
isDisabled={!data.writable}
|
||||
server={data.publicServer ?? data.server}
|
||||
users={data.users}
|
||||
disabledKeys={loaderData.preAuth ? [] : ['pre-auth']}
|
||||
isDisabled={!loaderData.writable}
|
||||
server={loaderData.publicServer ?? loaderData.server}
|
||||
users={loaderData.users}
|
||||
/>
|
||||
</div>
|
||||
<table className="table-auto w-full rounded-lg">
|
||||
@@ -99,7 +96,7 @@ export default function Page() {
|
||||
<th className="pb-2 w-1/4">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<p className="uppercase text-xs font-bold">Addresses</p>
|
||||
{data.magic ? (
|
||||
{loaderData.magic ? (
|
||||
<Tooltip>
|
||||
<Info className="w-4 h-4" />
|
||||
<Tooltip.Body className="font-normal">
|
||||
@@ -107,7 +104,7 @@ export default function Page() {
|
||||
their name and also at{' '}
|
||||
<Code>
|
||||
[name].
|
||||
{data.magic}
|
||||
{loaderData.magic}
|
||||
</Code>
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
@@ -115,7 +112,7 @@ export default function Page() {
|
||||
</div>
|
||||
</th>
|
||||
{/* We only want to show the version column if there are agents */}
|
||||
{data.agent !== undefined ? (
|
||||
{loaderData.agent !== undefined ? (
|
||||
<th className="uppercase text-xs font-bold pb-2">Version</th>
|
||||
) : undefined}
|
||||
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
|
||||
@@ -127,18 +124,21 @@ export default function Page() {
|
||||
'border-t border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
{data.populatedNodes.map((machine) => (
|
||||
{loaderData.populatedNodes.map((node) => (
|
||||
<MachineRow
|
||||
isAgent={data.agent ? data.agent === machine.nodeKey : undefined}
|
||||
isDisabled={
|
||||
data.writable
|
||||
? false // If the user has write permissions, they can edit all machines
|
||||
: machine.user.providerId?.split('/').pop() !== data.subject
|
||||
isAgent={
|
||||
loaderData.agent ? loaderData.agent === node.nodeKey : undefined
|
||||
}
|
||||
key={machine.id}
|
||||
magic={data.magic}
|
||||
node={machine}
|
||||
users={data.users}
|
||||
isDisabled={
|
||||
loaderData.writable
|
||||
? false // If the user has write permissions, they can edit all machines
|
||||
: node.user.providerId?.split('/').pop() !==
|
||||
loaderData.subject
|
||||
}
|
||||
key={node.id}
|
||||
magic={loaderData.magic}
|
||||
node={node}
|
||||
users={loaderData.users}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
+20
-43
@@ -4,20 +4,11 @@ import { faker } from '@faker-js/faker';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
data,
|
||||
LinksFunction,
|
||||
LoaderFunctionArgs,
|
||||
ShouldRevalidateFunction,
|
||||
useLoaderData,
|
||||
useSubmit,
|
||||
} from 'react-router';
|
||||
import { data, type ShouldRevalidateFunction, useSubmit } from 'react-router';
|
||||
import { ExternalScriptsHandle } from 'remix-utils/external-scripts';
|
||||
import { LoadContext } from '~/server';
|
||||
import { EphemeralNodeInsert, ephemeralNodes } from '~/server/db/schema';
|
||||
import { Machine, PreAuthKey, User } from '~/types';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
import type { Route } from './+types/console';
|
||||
import UserPrompt from './user-prompt';
|
||||
import XTerm from './xterm.client';
|
||||
|
||||
@@ -25,10 +16,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const origin = new URL(request.url).origin;
|
||||
const assets = ['/wasm_exec.js', '/hp_ssh.wasm'];
|
||||
const missing: string[] = [];
|
||||
@@ -53,10 +41,9 @@ export async function loader({
|
||||
if (session.user.subject === 'unknown-non-oauth') {
|
||||
throw data('Only OAuth users are allowed to use WebSSH', 403);
|
||||
}
|
||||
const { users } = await context.client.get<{ users: User[] }>(
|
||||
'v1/user',
|
||||
session.api_key,
|
||||
);
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const users = await api.getUsers();
|
||||
|
||||
// MARK: This assumes that a user has authenticated with Headscale first
|
||||
// Since the only way to enforce permissions via ACLs is to generate a
|
||||
@@ -77,15 +64,12 @@ export async function loader({
|
||||
);
|
||||
}
|
||||
|
||||
const { preAuthKey } = await context.client.post<{ preAuthKey: PreAuthKey }>(
|
||||
'v1/preauthkey',
|
||||
session.api_key,
|
||||
{
|
||||
user: lookup.id,
|
||||
reusable: false,
|
||||
ephemeral: true,
|
||||
expiration: new Date(Date.now() + 60 * 1000).toISOString(), // 1 minute
|
||||
},
|
||||
const preAuthKey = await api.createPreAuthKey(
|
||||
lookup.id,
|
||||
true, // ephemeral
|
||||
false, // reusable
|
||||
new Date(Date.now() + 60 * 1000), // expiration: 1 minute
|
||||
null, // aclTags
|
||||
);
|
||||
|
||||
// TODO: Enable config to enforce generate_authkeys capability
|
||||
@@ -130,13 +114,8 @@ export async function loader({
|
||||
// );
|
||||
// }
|
||||
|
||||
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
session.api_key,
|
||||
);
|
||||
|
||||
// node.name is the hostname, given_name is the set name
|
||||
const lookupNode = nodes.find((n) => n.name === hostname);
|
||||
const nodes = await api.getNodes();
|
||||
const lookupNode = nodes.find((n) => n.givenName === hostname);
|
||||
if (!lookupNode) {
|
||||
throw data(`Node with hostname ${hostname} not found`, 404);
|
||||
}
|
||||
@@ -182,11 +161,8 @@ function generateHostname(username: string) {
|
||||
return `ssh-${adjective}-${noun}-${username}`;
|
||||
}
|
||||
|
||||
export async function action({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const _session = await context.sessions.auth(request);
|
||||
export async function action({ request, context }: Route.ActionArgs) {
|
||||
await context.sessions.auth(request);
|
||||
if (!context.agents?.agentID()) {
|
||||
throw data(
|
||||
'WebSSH is only available with the Headplane agent integration',
|
||||
@@ -214,7 +190,7 @@ export async function action({
|
||||
.where(eq(ephemeralNodes.auth_key, authKey));
|
||||
}
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{
|
||||
rel: 'preload',
|
||||
href: '/hp_ssh.wasm',
|
||||
@@ -234,13 +210,14 @@ export const handle: ExternalScriptsHandle = {
|
||||
],
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
export default function Page({
|
||||
loaderData: { ipnDetails, sshDetails },
|
||||
}: Route.ComponentProps) {
|
||||
const submit = useSubmit();
|
||||
const { pause } = useLiveData();
|
||||
|
||||
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
|
||||
const [nodeKey, setNodeKey] = useState<string | null>(null);
|
||||
const { ipnDetails, sshDetails } = useLoaderData<typeof loader>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!ipnDetails) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { LoaderFunctionArgs } from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import type { Route } from './+types/healthz';
|
||||
|
||||
export async function loader({ context }: Route.LoaderArgs) {
|
||||
// Use a fake API key for healthcheck
|
||||
const api = context.hsApi.getRuntimeClient('fake-api-key');
|
||||
const healthy = await api.isHealthy();
|
||||
|
||||
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
|
||||
const healthy = await context.client.healthcheck();
|
||||
return new Response(JSON.stringify({ status: healthy ? 'OK' : 'ERROR' }), {
|
||||
status: healthy ? 200 : 500,
|
||||
headers: {
|
||||
|
||||
+5
-10
@@ -1,14 +1,12 @@
|
||||
import { eq, isNotNull } from 'drizzle-orm';
|
||||
import { LoaderFunctionArgs } from 'react-router';
|
||||
import { Machine } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
import { LoadContext } from '..';
|
||||
import type { Route } from '../../layouts/+types/dashboard';
|
||||
import { ephemeralNodes } from './schema';
|
||||
|
||||
export async function pruneEphemeralNodes({
|
||||
context,
|
||||
request,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
}: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const ephemerals = await context.db
|
||||
.select()
|
||||
@@ -20,11 +18,8 @@ export async function pruneEphemeralNodes({
|
||||
return;
|
||||
}
|
||||
|
||||
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
session.api_key,
|
||||
);
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const nodes = await api.getNodes();
|
||||
const toPrune = nodes.filter((node) => {
|
||||
if (node.online) {
|
||||
return false;
|
||||
@@ -42,7 +37,7 @@ export async function pruneEphemeralNodes({
|
||||
const promises = toPrune.map((node) => {
|
||||
return async () => {
|
||||
log.debug('api', `Pruning node ${node.name}`);
|
||||
await context.client.delete(`v1/node/${node.id}`, session.api_key);
|
||||
await api.deleteNode(node.id);
|
||||
|
||||
await context.db
|
||||
.delete(ephemeralNodes)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
composeEndpoints,
|
||||
defineApiEndpoints,
|
||||
type ExtractApiEndpoints,
|
||||
type UnionToIntersection,
|
||||
} from '../factory';
|
||||
@@ -10,12 +11,42 @@ import policyEndpoints from './policy';
|
||||
import preAuthKeyEndpoints from './pre-auth-keys';
|
||||
import userEndpoints from './users';
|
||||
|
||||
interface HealthcheckEndpoint {
|
||||
/**
|
||||
* Checks if the Headscale instance is healthy.
|
||||
*
|
||||
* @returns A boolean indicating if the instance is healthy.
|
||||
*/
|
||||
isHealthy(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const healthcheckEndpoint = defineApiEndpoints<HealthcheckEndpoint>(
|
||||
(client, apiKey) => ({
|
||||
isHealthy: async () => {
|
||||
try {
|
||||
const res = await client.rawFetch('/health', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
// This doesn't really matter
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
return res.statusCode === 200;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* A constant list of all endpoint groups.
|
||||
* Add new endpoint groups here.
|
||||
*/
|
||||
export const endpointSets = [
|
||||
apiKeyEndpoints,
|
||||
healthcheckEndpoint,
|
||||
nodeEndpoints,
|
||||
policyEndpoints,
|
||||
preAuthKeyEndpoints,
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface PreAuthKeyEndpoints {
|
||||
*
|
||||
* @param user The user to create the pre-authentication key for.
|
||||
* @param ephemeral Whether the key is ephemeral.
|
||||
* @param uses The number of uses for the key.
|
||||
* @param reusable Whether the key is reusable.
|
||||
* @param expiration The expiration date of the key, or `null` for no expiration.
|
||||
* @param aclTags An array of ACL tags to associate with the key, or `null` for none.
|
||||
* @returns A `PreAuthKey` object representing the newly created pre-authentication key.
|
||||
@@ -23,7 +23,7 @@ export interface PreAuthKeyEndpoints {
|
||||
createPreAuthKey(
|
||||
user: string,
|
||||
ephemeral: boolean,
|
||||
uses: number,
|
||||
reusable: boolean,
|
||||
expiration: Date | null,
|
||||
aclTags: string[] | null,
|
||||
): Promise<PreAuthKey>;
|
||||
@@ -46,13 +46,13 @@ export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({
|
||||
return preAuthKeys;
|
||||
},
|
||||
|
||||
createPreAuthKey: async (user, ephemeral, uses, expiration, aclTags) => {
|
||||
createPreAuthKey: async (user, ephemeral, reusable, expiration, aclTags) => {
|
||||
const { preAuthKey } = await client.apiFetch<{
|
||||
preAuthKey: PreAuthKey;
|
||||
}>('POST', 'v1/preauthkey', apiKey, {
|
||||
user,
|
||||
ephemeral,
|
||||
uses,
|
||||
reusable,
|
||||
expiration: expiration ? expiration.toISOString() : null,
|
||||
aclTags,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user