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 (
+ <>
+
+
+
+
+
Headplane
+
+
+ }/>
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
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 ? (
+
+
+ Login with SSO
+
+
+ ) : 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
+ }
+ }
+)
+