diff --git a/app/components/TabLink.tsx b/app/components/TabLink.tsx new file mode 100644 index 0000000..5752841 --- /dev/null +++ b/app/components/TabLink.tsx @@ -0,0 +1,23 @@ +import { NavLink } from '@remix-run/react' +import clsx from 'clsx' +import type { ReactNode } from 'react' + +type Properties = { + readonly name: string; + readonly to: string; + readonly icon: ReactNode; +} + +export default function TabLink({ name, to, icon }: Properties) { + return ( + clsx( + 'flex items-center gap-x-2 p-2 border-b-2 text-sm', + isActive ? 'border-white' : 'border-transparent' + )} + > + {icon} {name} + + ) +} diff --git a/app/root.tsx b/app/root.tsx index e82f26f..e56a86b 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,29 +1,106 @@ +import { ExclamationTriangleIcon, QuestionMarkCircleIcon } from '@heroicons/react/24/outline' +import type { LinksFunction, MetaFunction } from '@remix-run/node' import { - Links, - Meta, - Outlet, - Scripts, - ScrollRestoration, -} from "@remix-run/react"; + isRouteErrorResponse, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useRouteError +} from '@remix-run/react' +import clsx from 'clsx' -export function Layout({ children }: { children: React.ReactNode }) { - return ( - - - - - - - - - {children} - - - - - ); +import stylesheet from '~/tailwind.css?url' + +export const meta: MetaFunction = () => [ + { title: 'Headplane' }, + { name: 'description', content: 'A frontend for the headscale coordination server' } +] + +export const links: LinksFunction = () => [ + { rel: 'stylesheet', href: stylesheet } +] + +export function loader() { + if (!process.env.HEADSCALE_URL) { + throw new Error('The HEADSCALE_URL environment variable is required') + } + + if (!process.env.COOKIE_SECRET) { + throw new Error('The COOKIE_SECRET environment variable is required') + } + + if (!process.env.API_KEY) { + throw new Error('The API_KEY environment variable is required') + } + + // eslint-disable-next-line unicorn/no-null + return null +} + +export function Layout({ children }: { readonly children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ) +} + +export function ErrorBoundary() { + const error = useRouteError() + const routing = isRouteErrorResponse(error) + const message = (error instanceof Error ? error.message : 'An unexpected error occurred') + return ( + + + Oh no! + + + + +
+
+ {routing ? ( + <> + +

{error.status}

+

{error.statusText}

+ + ) : ( + <> + +

Error

+ + {message} + +

+ If you are the administrator of this site, please check your logs for information. +

+ + )} +
+
+ + + + ) } export default function App() { - return ; + return } diff --git a/app/routes/_data.machines.tsx b/app/routes/_data.machines.tsx new file mode 100644 index 0000000..0360e7b --- /dev/null +++ b/app/routes/_data.machines.tsx @@ -0,0 +1,32 @@ +export default function Index() { + return ( + + ) +} diff --git a/app/routes/_data.tsx b/app/routes/_data.tsx new file mode 100644 index 0000000..41ecd70 --- /dev/null +++ b/app/routes/_data.tsx @@ -0,0 +1,40 @@ +import { CpuChipIcon, ServerStackIcon } from '@heroicons/react/24/outline' +import { type LoaderFunctionArgs, redirect } from '@remix-run/node' +import { Outlet } from '@remix-run/react' + +import TabLink from '~/components/TabLink' +import { getSession } from '~/utils/sessions' + +export async function loader({ request }: LoaderFunctionArgs) { + const session = await getSession(request.headers.get('Cookie')) + if (!session.has('hsApiKey')) { + return redirect('/login') + } + + // eslint-disable-next-line unicorn/no-null + return null +} + +export default function Layout() { + return ( + <> +
+ +
+ +
+ +
+ + + ) +} + diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 5347369..556470b 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -1,41 +1,5 @@ -import type { MetaFunction } from "@remix-run/node"; +import { redirect } from '@remix-run/node' -export const meta: MetaFunction = () => { - return [ - { title: "New Remix App" }, - { name: "description", content: "Welcome to Remix!" }, - ]; -}; - -export default function Index() { - return ( - - ); +export function loader() { + return redirect('/machines') } diff --git a/app/routes/login.tsx b/app/routes/login.tsx new file mode 100644 index 0000000..d1aabbc --- /dev/null +++ b/app/routes/login.tsx @@ -0,0 +1,82 @@ +import { type LoaderFunctionArgs } from '@remix-run/node' +import { Link, useLoaderData } from '@remix-run/react' +import { useMemo } from 'react' + +import { startOidc } from '~/utils/oidc' + +export async function loader({ request }: LoaderFunctionArgs) { + const issuer = process.env.OIDC_ISSUER + const id = process.env.OIDC_CLIENT_ID + const secret = process.env.OIDC_CLIENT_SECRET + const normal = process.env.DISABLE_API_KEY_LOGIN + + if (issuer && (!id || !secret)) { + throw new Error('An invalid OIDC configuration was provided') + } + + const data = { + oidc: issuer, + apiKey: normal === undefined + } + + console.log(data) + + if (!data.oidc && !data.apiKey) { + throw new Error('No authentication method is enabled') + } + + if (data.oidc && !data.apiKey) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return startOidc(data.oidc, id!, request) + } + + return data +} + +export default function Page() { + const data = useLoaderData() + const showOr = useMemo(() => data.oidc && data.apiKey, [data]) + + return ( +
+
+

Login

+ {data.apiKey ? ( + <> +

+ Enter an API key to authenticate with Headplane. You can generate + one by running + {' '} + + headscale apikeys create + + {' '} + in your terminal. +

+ + + + ) : undefined} + {showOr ? ( +
+
+ or +
+
+ ) : undefined} + {data.oidc ? ( + + + + ) : undefined} +
+
+ ) +} diff --git a/app/routes/oidc.callback.tsx b/app/routes/oidc.callback.tsx new file mode 100644 index 0000000..be3cd5b --- /dev/null +++ b/app/routes/oidc.callback.tsx @@ -0,0 +1,15 @@ +import { type LoaderFunctionArgs } from '@remix-run/node' + +import { finishOidc } from '~/utils/oidc' + +export async function loader({ request }: LoaderFunctionArgs) { + const issuer = process.env.OIDC_ISSUER + const id = process.env.OIDC_CLIENT_ID + const secret = process.env.OIDC_CLIENT_SECRET + + if (!issuer || !id || !secret) { + throw new Error('An invalid OIDC configuration was provided') + } + + return finishOidc(issuer, id, secret, request) +} diff --git a/app/utils/headscale.ts b/app/utils/headscale.ts new file mode 100644 index 0000000..1c6e615 --- /dev/null +++ b/app/utils/headscale.ts @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +export async function pull(url: string, key: string) { + const prefix = process.env.HEADSCALE_URL! + const response = await fetch(`${prefix}/api/${url}`, { + headers: { + Authorization: `Bearer ${key}` + } + }) + + if (!response.ok) { + throw new Error(response.statusText) + } + + return response.json() as Promise +} + +export async function post(url: string, key: string, body?: unknown) { + const prefix = process.env.HEADSCALE_URL! + const response = await fetch(`${prefix}/api/${url}`, { + method: 'POST', + body: body ? JSON.stringify(body) : undefined, + headers: { + Authorization: `Bearer ${key}` + } + }) + + if (!response.ok) { + throw new Error(await response.text()) + } + + return response.json() as Promise +} + diff --git a/app/utils/oidc.ts b/app/utils/oidc.ts new file mode 100644 index 0000000..e5f7aa2 --- /dev/null +++ b/app/utils/oidc.ts @@ -0,0 +1,135 @@ +import { redirect } from '@remix-run/node' +import { + authorizationCodeGrantRequest, + calculatePKCECodeChallenge, type Client, + discoveryRequest, + generateRandomCodeVerifier, + generateRandomNonce, + generateRandomState, + getValidatedIdTokenClaims, isOAuth2Error, + parseWwwAuthenticateChallenges, + processAuthorizationCodeOpenIDResponse, + processDiscoveryResponse, + validateAuthResponse } from 'oauth4webapi' + +import { post } from '~/utils/headscale' +import { commitSession, getSession } from '~/utils/sessions' + +export async function startOidc(issuer: string, client: string, request: Request) { + const session = await getSession(request.headers.get('Cookie')) + if (session.has('hsApiKey')) { + return redirect('/', { + status: 302, + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Set-Cookie': await commitSession(session) + } + }) + } + + const issuerUrl = new URL(issuer) + const oidcClient = { + client_id: client, + token_endpoint_auth_method: 'client_secret_basic' + } satisfies Client + + const response = await discoveryRequest(issuerUrl) + const processed = await processDiscoveryResponse(issuerUrl, response) + if (!processed.authorization_endpoint) { + throw new Error('No authorization endpoint found on the OIDC provider') + } + + const state = generateRandomState() + const nonce = generateRandomNonce() + const verifier = generateRandomCodeVerifier() + const challenge = await calculatePKCECodeChallenge(verifier) + const callback = new URL('/admin/oidc/callback', request.url) + const authUrl = new URL(processed.authorization_endpoint) + + authUrl.searchParams.set('client_id', oidcClient.client_id) + authUrl.searchParams.set('response_type', 'code') + authUrl.searchParams.set('redirect_uri', callback.href) + authUrl.searchParams.set('scope', 'openid profile email') + authUrl.searchParams.set('code_challenge', challenge) + authUrl.searchParams.set('code_challenge_method', 'S256') + authUrl.searchParams.set('state', state) + authUrl.searchParams.set('nonce', nonce) + + session.set('authState', state) + session.set('authNonce', nonce) + session.set('authVerifier', verifier) + + return redirect(authUrl.href, { + status: 302, + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Set-Cookie': await commitSession(session) + } + }) +} + +export async function finishOidc(issuer: string, client: string, secret: string, request: Request) { + const session = await getSession(request.headers.get('Cookie')) + if (session.has('hsApiKey')) { + return redirect('/', { + status: 302, + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Set-Cookie': await commitSession(session) + } + }) + } + + const issuerUrl = new URL(issuer) + const oidcClient = { + client_id: client, + client_secret: secret, + token_endpoint_auth_method: 'client_secret_basic' + } satisfies Client + + const response = await discoveryRequest(issuerUrl) + const processed = await processDiscoveryResponse(issuerUrl, response) + if (!processed.authorization_endpoint) { + throw new Error('No authorization endpoint found on the OIDC provider') + } + + const state = session.get('authState') + const nonce = session.get('authNonce') + const verifier = session.get('authVerifier') + if (!state || !nonce || !verifier) { + throw new Error('No OIDC state found in the session') + } + + const parameters = validateAuthResponse(processed, oidcClient, new URL(request.url), state) + if (isOAuth2Error(parameters)) { + throw new Error('Invalid response from the OIDC provider') + } + + const callback = new URL('/admin/oidc/callback', request.url) + const tokenResponse = await authorizationCodeGrantRequest(processed, oidcClient, parameters, callback.href, verifier) + const challenges = parseWwwAuthenticateChallenges(tokenResponse) + if (challenges) { + throw new Error('Recieved a challenge from the OIDC provider') + } + + const result = await processAuthorizationCodeOpenIDResponse(processed, oidcClient, tokenResponse, nonce) + if (isOAuth2Error(result)) { + throw new Error('Invalid response from the OIDC provider') + } + + const claims = getValidatedIdTokenClaims(result) + const expDate = new Date(claims.exp * 1000).toISOString() + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const keyResponse = await post<{ apiKey: string }>('v1/apikey', process.env.API_KEY!, { + expiration: expDate + }) + + session.set('hsApiKey', keyResponse.apiKey) + return redirect('/machines', { + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Set-Cookie': await commitSession(session) + } + }) +} diff --git a/app/utils/sessions.ts b/app/utils/sessions.ts new file mode 100644 index 0000000..a42b3b8 --- /dev/null +++ b/app/utils/sessions.ts @@ -0,0 +1,31 @@ +import { createCookieSessionStorage } from '@remix-run/node' // Or cloudflare/deno + +type SessionData = { + hsApiKey: string; + authState: string; + authNonce: string; + authVerifier: string; +} + +type SessionFlashData = { + error: string; +} + +export const { + getSession, + commitSession, + destroySession +} = createCookieSessionStorage( + { + cookie: { + name: 'hp_sess', + httpOnly: true, + maxAge: 60 * 60 * 24, // 24 hours + path: '/', + sameSite: 'lax', + secrets: [process.env.COOKIE_SECRET!], + secure: true + } + } +) +