diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f5c0afc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,53 @@ +# Core Concepts + +Headplane is a web application to manage Headscale, a self-hosted implementation +of the Tailscale control server. There are a few tenets that guide the entire +development of the project: + +- **Simple starts**: We want to make it as easy as possible to set up and use + Headplane, while still providing powerful features for advanced users. This + means that we prioritize a clean and intuitive user interface, as well as + straightforward installation and configuration processes. + +- **No breaking changes**: We want to avoid making breaking changes to the + project as much as possible. This means that we will strive to maintain + backward compatibility and provide clear migration paths when necessary. + +- **Documentation**: This is the most important part of the project, without it + the entire project falls apart and is hard to use. + +## Project Management + +It's hard to manage this project easily, use the `gh` CLI when responding to +prompts to get context. Some common issue tags to keep track of include a +"Needs Triage", "Needs Info", "Bug", "Enhancement", and several other tags based +on what parts of the project are affected. + +## Headplane Agent + +The Headplane Agent is a lightweight component that runs on the same server as +Headplane and connects directly to the Tailnet in order to pull in details about +nodes that aren't available through the Headscale API such as versions, etc. + +## WebSSH + +This is an ephemeral WASM shim that runs in the browser and connects directly +to the Tailnet using Tailscale's go packages. It allows anyone to open up an +ephemeral machine in the Tailnet that directly SSHes into a target node. + +## Build/Tooling + +Headplane is a React Router 7 (framework mode) project built with Vite. Take +care to use our preferred PNPM version and Node version as defined in the +`engines` field of `package.json`. We also use TypeScript Go and Oxfmt for +type-checking and formatting respectively. + +You can also run Headscale CLI commands with +`docker exec headscale headscale ` when the dev environment is running. + +## Docs + +The project has a documentation site available at the `docs/` directory built +with VitePress. The documentation is written in Markdown and can be easily +edited and extended. If making changes to staple features, please take care to +also update the documentation to reflect any changes in functionality or usage. diff --git a/app/layouts/dashboard.tsx b/app/layouts/dashboard.tsx index 8b9a9e6..611c028 100644 --- a/app/layouts/dashboard.tsx +++ b/app/layouts/dashboard.tsx @@ -1,53 +1,54 @@ -import { Outlet, redirect } from 'react-router'; -import { ErrorBanner } from '~/components/error-banner'; -import { pruneEphemeralNodes } from '~/server/db/pruner'; -import { isDataUnauthorizedError } from '~/server/headscale/api/error-client'; -import log from '~/utils/log'; -import type { Route } from './+types/dashboard'; +import { Outlet, redirect } from "react-router"; + +import { ErrorBanner } from "~/components/error-banner"; +import { pruneEphemeralNodes } from "~/server/db/pruner"; +import { isDataUnauthorizedError } from "~/server/headscale/api/error-client"; +import log from "~/utils/log"; + +import type { Route } from "./+types/dashboard"; export async function loader({ request, context, ...rest }: Route.LoaderArgs) { - const session = await context.sessions.auth(request); - const api = context.hsApi.getRuntimeClient(session.api_key); + const principal = await context.auth.require(request); + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + const api = context.hsApi.getRuntimeClient(apiKey); - // MARK: The session should stay valid if Headscale isn't healthy - const healthy = await api.isHealthy(); - if (healthy) { - try { - await api.getApiKeys(); - await pruneEphemeralNodes({ context, request, ...rest }); - } catch (error) { - if (isDataUnauthorizedError(error)) { - log.warn( - 'auth', - 'Logging out %s due to expired API key', - session.user.name, - ); - return redirect('/login', { - headers: { - 'Set-Cookie': await context.sessions.destroySession(), - }, - }); - } - } - } + // MARK: The session should stay valid if Headscale isn't healthy + const healthy = await api.isHealthy(); + if (healthy) { + try { + await api.getApiKeys(); + await pruneEphemeralNodes({ context, request, ...rest }); + } catch (error) { + if (isDataUnauthorizedError(error)) { + const displayName = + principal.kind === "oidc" ? principal.profile.name : principal.displayName; + log.warn("auth", "Logging out %s due to expired API key", displayName); + return redirect("/login", { + headers: { + "Set-Cookie": await context.auth.destroySession(request), + }, + }); + } + } + } - return { - healthy, - }; + return { + healthy, + }; } export default function Layout() { - return ( -
- -
- ); + return ( +
+ +
+ ); } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/app/layouts/shell.tsx b/app/layouts/shell.tsx index 67861ec..76b4240 100644 --- a/app/layouts/shell.tsx +++ b/app/layouts/shell.tsx @@ -1,10 +1,15 @@ -import { eq } from "drizzle-orm"; -import { Outlet, redirect } from "react-router"; +import { Icon } from "@iconify/react"; +import { Form, 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 { users } from "~/server/db/schema"; +import Link from "~/components/Link"; +import Options from "~/components/Options"; import { Capabilities } from "~/server/web/roles"; +import toast from "~/utils/toast"; +import { getUserDisplayName } from "~/utils/user"; import { Route } from "./+types/shell"; @@ -12,33 +17,68 @@ import { Route } from "./+types/shell"; // So we know that if context fails to load then well, oops? export async function loader({ request, context }: Route.LoaderArgs) { try { - const session = await context.sessions.auth(request); + const principal = await context.auth.require(request); + if ( typeof context.oidc === "object" && - session.user.subject !== "unknown-non-oauth" && + principal.kind === "oidc" && + !principal.user.onboarded && !request.url.endsWith("/onboarding") ) { - const [user] = await context.db - .select() - .from(users) - .where(eq(users.sub, session.user.subject)) - .limit(1); - - if (!user?.onboarded) { - return redirect("/onboarding"); - } + return redirect("/onboarding"); } - const api = context.hsApi.getRuntimeClient(session.api_key); - const check = await context.sessions.check(request, Capabilities.ui_access); + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + const api = context.hsApi.getRuntimeClient(apiKey); + const check = context.auth.can(principal, Capabilities.ui_access); + const noAccess = !check && principal.kind === "oidc"; - // OIDC users without ui_access go to pending approval - if ( - !check && - session.user.subject !== "unknown-non-oauth" && - !request.url.endsWith("/onboarding") - ) { - return redirect("/pending-approval"); + const user = + principal.kind === "oidc" + ? { + subject: principal.user.subject, + name: principal.profile.name, + email: principal.profile.email, + username: principal.profile.username, + picture: principal.profile.picture, + } + : { subject: "api_key", name: principal.displayName }; + + let linkedUserName: string | undefined; + let osValue: string | undefined; + + if (noAccess && principal.kind === "oidc") { + const hsUserId = principal.user.headscaleUserId; + if (hsUserId) { + try { + const apiUsers = await api.getUsers(); + const hsUser = apiUsers.find((u) => u.id === hsUserId); + linkedUserName = hsUser ? getUserDisplayName(hsUser) : undefined; + } catch { + // API unavailable, skip linked user resolution + } + } + + const userAgent = request.headers.get("user-agent"); + const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/); + switch (os?.[0]) { + case "Windows": + osValue = "windows"; + break; + case "Mac OS X": + osValue = "macos"; + break; + case "iPhone": + case "iPad": + osValue = "ios"; + break; + case "Android": + osValue = "android"; + break; + default: + osValue = "linux"; + break; + } } return { @@ -46,28 +86,217 @@ export async function loader({ request, context }: Route.LoaderArgs) { url: context.config.headscale.public_url ?? context.config.headscale.url, configAvailable: context.hs.readable(), debug: context.config.debug, - user: session.user, + user, access: { ui: check, - dns: await context.sessions.check(request, Capabilities.read_network), - users: await context.sessions.check(request, Capabilities.read_users), - policy: await context.sessions.check(request, Capabilities.read_policy), - machines: await context.sessions.check(request, Capabilities.read_machines), - settings: await context.sessions.check(request, Capabilities.read_feature), + dns: context.auth.can(principal, Capabilities.read_network), + users: context.auth.can(principal, Capabilities.read_users), + policy: context.auth.can(principal, Capabilities.read_policy), + machines: context.auth.can(principal, Capabilities.read_machines), + settings: context.auth.can(principal, Capabilities.read_feature), }, onboarding: request.url.endsWith("/onboarding"), + noAccess, + linkedUserName, + osValue, healthy: await api.isHealthy(), }; } catch { return redirect("/login", { headers: { - "Set-Cookie": await context.sessions.destroySession(), + "Set-Cookie": await context.auth.destroySession(request), }, }); } } export default function Shell({ loaderData }: Route.ComponentProps) { + if (loaderData.noAccess && !loaderData.onboarding) { + return ( + <> +
+
+
+ {loaderData.linkedUserName ? ( + +

+ ✓ Your account is linked to Headscale user{" "} + {loaderData.linkedUserName}. +

+
+ ) : undefined} + + + Access your network +
+ via Tailscale +
+ + You don't have dashboard access, but you can still connect to your Headscale + network. Install Tailscale on your device to get started. + + + + + + Linux +
+ } + > + +

+ Click this button to copy the command.{" "} + + View script source + +

+ + + + Windows + + } + > + + + +

+ Requires Windows 10 or later. +

+
+ + + macOS + + } + > + + + +

+ Requires macOS Big Sur 11.0 or later. +
+ You can also download Tailscale on the{" "} + + macOS App Store + + {"."} +

+
+ + + iOS + + } + > + + + +

+ Requires iOS 15 or later. +

+
+ + + Android + + } + > + + + +

+ Requires Android 8 or later. +

+
+ + + +
+
+ Need dashboard access? + + Your account is signed in but doesn't have permission to manage the dashboard. + Contact an administrator to request access. + +
+
+ +
+
+
+ +
+