mirror of
https://github.com/tale/headplane.git
synced 2026-08-22 18:56:38 +00:00
feat(auth): support reverse-proxy driven proxy auth
Closes HP-353.
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
- Fixed Browser SSH pre-auth key handling by increasing the temporary key expiry window and showing key creation errors in the UI (closes [#565](https://github.com/tale/headplane/issues/565)).
|
- Fixed Browser SSH pre-auth key handling by increasing the temporary key expiry window and showing key creation errors in the UI (closes [#565](https://github.com/tale/headplane/issues/565)).
|
||||||
- Fixed machine rename submission by validating names before sending the rename request (closes [#564](https://github.com/tale/headplane/issues/564)).
|
- Fixed machine rename submission by validating names before sending the rename request (closes [#564](https://github.com/tale/headplane/issues/564)).
|
||||||
- Fixed OIDC token exchange fallback when retrying with `client_secret_basic` (closes [#493](https://github.com/tale/headplane/issues/493)).
|
- Fixed OIDC token exchange fallback when retrying with `client_secret_basic` (closes [#493](https://github.com/tale/headplane/issues/493)).
|
||||||
|
- Added support for proxy authentication via `server.proxy_auth` (closes [#353](https://github.com/tale/headplane/issues/353)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+14
-13
@@ -4,6 +4,7 @@ import { ErrorBanner } from "~/components/error-banner";
|
|||||||
import StatusBanner from "~/components/status-banner";
|
import StatusBanner from "~/components/status-banner";
|
||||||
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
|
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
|
||||||
import { usersResource } from "~/server/headscale/live-store";
|
import { usersResource } from "~/server/headscale/live-store";
|
||||||
|
import { isUserPrincipal } from "~/server/web/auth";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import log from "~/utils/log";
|
import log from "~/utils/log";
|
||||||
|
|
||||||
@@ -33,16 +34,15 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
try {
|
try {
|
||||||
const { principal, api } = await context.apiForRequest(request);
|
const { principal, api } = await context.apiForRequest(request);
|
||||||
|
|
||||||
const user =
|
const user = isUserPrincipal(principal)
|
||||||
principal.kind === "oidc"
|
? {
|
||||||
? {
|
email: principal.profile.email,
|
||||||
email: principal.profile.email,
|
name: principal.profile.name,
|
||||||
name: principal.profile.name,
|
picture: principal.profile.picture,
|
||||||
picture: principal.profile.picture,
|
subject: principal.user.subject,
|
||||||
subject: principal.user.subject,
|
username: principal.profile.username,
|
||||||
username: principal.profile.username,
|
}
|
||||||
}
|
: { name: principal.displayName, subject: "api_key" };
|
||||||
: { name: principal.displayName, subject: "api_key" };
|
|
||||||
|
|
||||||
// MARK: The session should stay valid if Headscale isn't healthy
|
// MARK: The session should stay valid if Headscale isn't healthy
|
||||||
const isHealthy = await context.headscale.health();
|
const isHealthy = await context.headscale.health();
|
||||||
@@ -51,8 +51,9 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
await api.apiKeys.list();
|
await api.apiKeys.list();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isDataUnauthorizedError(error)) {
|
if (isDataUnauthorizedError(error)) {
|
||||||
const displayName =
|
const displayName = isUserPrincipal(principal)
|
||||||
principal.kind === "oidc" ? principal.profile.name : principal.displayName;
|
? principal.profile.name
|
||||||
|
: principal.displayName;
|
||||||
log.warn("auth", "Logging out %s due to expired API key", displayName);
|
log.warn("auth", "Logging out %s due to expired API key", displayName);
|
||||||
return redirect("/login", {
|
return redirect("/login", {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -64,7 +65,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
// Self-heal: if the linked Headscale user was deleted, clear the
|
// Self-heal: if the linked Headscale user was deleted, clear the
|
||||||
// stale link so the user gets prompted to re-link.
|
// stale link so the user gets prompted to re-link.
|
||||||
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
|
if (isUserPrincipal(principal) && principal.user.headscaleUserId) {
|
||||||
try {
|
try {
|
||||||
const usersSnap = await context.hsLive.get(usersResource, api);
|
const usersSnap = await context.hsLive.get(usersResource, api);
|
||||||
if (!usersSnap.data.some((u) => u.id === principal.user.headscaleUserId)) {
|
if (!usersSnap.data.some((u) => u.id === principal.user.headscaleUserId)) {
|
||||||
|
|||||||
+6
-9
@@ -11,6 +11,7 @@ import CodeBlock from "~/components/code-block";
|
|||||||
import Link from "~/components/link";
|
import Link from "~/components/link";
|
||||||
import LinkAccount from "~/layout/link-account";
|
import LinkAccount from "~/layout/link-account";
|
||||||
import { usersResource } from "~/server/headscale/live-store";
|
import { usersResource } from "~/server/headscale/live-store";
|
||||||
|
import { isUserPrincipal } from "~/server/web/auth";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import cn from "~/utils/cn";
|
import cn from "~/utils/cn";
|
||||||
import { getUserDisplayName } from "~/utils/user";
|
import { getUserDisplayName } from "~/utils/user";
|
||||||
@@ -20,14 +21,10 @@ import type { Route } from "./+types/home";
|
|||||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
const principal = await context.auth.require(request);
|
const principal = await context.auth.require(request);
|
||||||
|
|
||||||
// If the OIDC user has no linked Headscale user, check for
|
// If the signed-in Headplane user has no linked Headscale user,
|
||||||
// Unclaimed users they can pick from before anything else.
|
// check for unclaimed users they can pick from before anything else.
|
||||||
let unlinked = false;
|
let unlinked = false;
|
||||||
if (
|
if (isUserPrincipal(principal) && !principal.user.headscaleUserId) {
|
||||||
context.oidc.state === "enabled" &&
|
|
||||||
principal.kind === "oidc" &&
|
|
||||||
!principal.user.headscaleUserId
|
|
||||||
) {
|
|
||||||
const { api } = await context.apiForRequest(request);
|
const { api } = await context.apiForRequest(request);
|
||||||
|
|
||||||
let headscaleUsers: { id: string; name: string }[] = [];
|
let headscaleUsers: { id: string; name: string }[] = [];
|
||||||
@@ -66,7 +63,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
const { api } = await context.apiForRequest(request);
|
const { api } = await context.apiForRequest(request);
|
||||||
|
|
||||||
let linkedUserName: string | undefined;
|
let linkedUserName: string | undefined;
|
||||||
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
|
if (isUserPrincipal(principal) && principal.user.headscaleUserId) {
|
||||||
try {
|
try {
|
||||||
const usersSnap = await context.hsLive.get(usersResource, api);
|
const usersSnap = await context.hsLive.get(usersResource, api);
|
||||||
const hsUser = usersSnap.data.find((u) => u.id === principal.user.headscaleUserId);
|
const hsUser = usersSnap.data.find((u) => u.id === principal.user.headscaleUserId);
|
||||||
@@ -81,7 +78,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
export async function action({ request, context }: Route.ActionArgs) {
|
export async function action({ request, context }: Route.ActionArgs) {
|
||||||
const principal = await context.auth.require(request);
|
const principal = await context.auth.require(request);
|
||||||
if (principal.kind !== "oidc") {
|
if (!isUserPrincipal(principal)) {
|
||||||
return redirect("/");
|
return redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Link from "~/components/link";
|
|||||||
import PageError from "~/components/page-error";
|
import PageError from "~/components/page-error";
|
||||||
import Tooltip from "~/components/tooltip";
|
import Tooltip from "~/components/tooltip";
|
||||||
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
||||||
|
import { isUserPrincipal } from "~/server/web/auth";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import cn from "~/utils/cn";
|
import cn from "~/utils/cn";
|
||||||
import { mapNodes, sortAssignableTags, type PopulatedNode } from "~/utils/node-info";
|
import { mapNodes, sortAssignableTags, type PopulatedNode } from "~/utils/node-info";
|
||||||
@@ -64,7 +65,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
nodeKey: agents?.agentNodeKey(),
|
nodeKey: agents?.agentNodeKey(),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
|
headscaleUserId: isUserPrincipal(principal) ? principal.user.headscaleUserId : undefined,
|
||||||
existingTags: sortAssignableTags(nodes, policy),
|
existingTags: sortAssignableTags(nodes, policy),
|
||||||
magic,
|
magic,
|
||||||
nodes,
|
nodes,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { data } from "react-router";
|
import { data } from "react-router";
|
||||||
|
|
||||||
|
import { isUserPrincipal } from "~/server/web/auth";
|
||||||
import { getOidcSubject } from "~/server/web/headscale-identity";
|
import { getOidcSubject } from "~/server/web/headscale-identity";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import type { PreAuthKey } from "~/types";
|
import type { PreAuthKey } from "~/types";
|
||||||
@@ -25,7 +26,10 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
|
|||||||
throw data("User not found.", { status: 404 });
|
throw data("User not found.", { status: 404 });
|
||||||
}
|
}
|
||||||
const targetSubject = getOidcSubject(targetUser);
|
const targetSubject = getOidcSubject(targetUser);
|
||||||
if (principal.kind !== "oidc" || targetSubject !== principal.user.subject) {
|
const ownsTarget =
|
||||||
|
isUserPrincipal(principal) &&
|
||||||
|
(principal.user.headscaleUserId === userId || targetSubject === principal.user.subject);
|
||||||
|
if (!ownsTarget) {
|
||||||
throw data("You do not have permission to manage this user's pre-auth keys", {
|
throw data("You do not have permission to manage this user's pre-auth keys", {
|
||||||
status: 403,
|
status: 403,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,10 +18,22 @@ interface AddAuthKeyProps {
|
|||||||
users: User[];
|
users: User[];
|
||||||
url: string;
|
url: string;
|
||||||
selfServiceOnly: boolean;
|
selfServiceOnly: boolean;
|
||||||
|
currentHeadscaleUserId?: string;
|
||||||
currentSubject?: string;
|
currentSubject?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findCurrentUser(users: User[], subject: string | undefined): User | undefined {
|
function findCurrentUser(
|
||||||
|
users: User[],
|
||||||
|
headscaleUserId: string | undefined,
|
||||||
|
subject: string | undefined,
|
||||||
|
): User | undefined {
|
||||||
|
if (headscaleUserId) {
|
||||||
|
const linked = users.find((u) => u.id === headscaleUserId);
|
||||||
|
if (linked) {
|
||||||
|
return linked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!subject) {
|
if (!subject) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -38,6 +50,7 @@ export default function AddAuthKey({
|
|||||||
users,
|
users,
|
||||||
url,
|
url,
|
||||||
selfServiceOnly,
|
selfServiceOnly,
|
||||||
|
currentHeadscaleUserId,
|
||||||
currentSubject,
|
currentSubject,
|
||||||
}: AddAuthKeyProps) {
|
}: AddAuthKeyProps) {
|
||||||
const fetcher = useFetcher();
|
const fetcher = useFetcher();
|
||||||
@@ -46,7 +59,9 @@ export default function AddAuthKey({
|
|||||||
const [reusable, setReusable] = useState(false);
|
const [reusable, setReusable] = useState(false);
|
||||||
const [ephemeral, setEphemeral] = useState(false);
|
const [ephemeral, setEphemeral] = useState(false);
|
||||||
const [tagOnly, setTagOnly] = useState(false);
|
const [tagOnly, setTagOnly] = useState(false);
|
||||||
const currentUser = selfServiceOnly ? findCurrentUser(users, currentSubject) : null;
|
const currentUser = selfServiceOnly
|
||||||
|
? findCurrentUser(users, currentHeadscaleUserId, currentSubject)
|
||||||
|
: null;
|
||||||
const availableUsers = selfServiceOnly && currentUser ? [currentUser] : users;
|
const availableUsers = selfServiceOnly && currentUser ? [currentUser] : users;
|
||||||
const [userId, setUserId] = useState<string | null>(availableUsers[0]?.id);
|
const [userId, setUserId] = useState<string | null>(availableUsers[0]?.id);
|
||||||
const [tags, setTags] = useState("");
|
const [tags, setTags] = useState("");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Notice from "~/components/notice";
|
|||||||
import Select from "~/components/select";
|
import Select from "~/components/select";
|
||||||
import TableList from "~/components/table-list";
|
import TableList from "~/components/table-list";
|
||||||
import { usersResource } from "~/server/headscale/live-store";
|
import { usersResource } from "~/server/headscale/live-store";
|
||||||
|
import { isUserPrincipal } from "~/server/web/auth";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import type { PreAuthKey } from "~/types";
|
import type { PreAuthKey } from "~/types";
|
||||||
import type { User } from "~/types/User";
|
import type { User } from "~/types/User";
|
||||||
@@ -90,7 +91,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
access: canGenerateAny || canGenerateOwn,
|
access: canGenerateAny || canGenerateOwn,
|
||||||
currentSubject: principal.kind === "oidc" ? principal.user.subject : undefined,
|
currentHeadscaleUserId: isUserPrincipal(principal) ? principal.user.headscaleUserId : undefined,
|
||||||
|
currentSubject: isUserPrincipal(principal) ? principal.user.subject : undefined,
|
||||||
keys,
|
keys,
|
||||||
missing,
|
missing,
|
||||||
selfServiceOnly: !canGenerateAny && canGenerateOwn,
|
selfServiceOnly: !canGenerateAny && canGenerateOwn,
|
||||||
@@ -103,7 +105,16 @@ export const action = authKeysAction;
|
|||||||
|
|
||||||
type Status = "all" | "active" | "expired" | "reusable" | "ephemeral";
|
type Status = "all" | "active" | "expired" | "reusable" | "ephemeral";
|
||||||
export default function Page({
|
export default function Page({
|
||||||
loaderData: { keys, missing, users, url, access, selfServiceOnly, currentSubject },
|
loaderData: {
|
||||||
|
keys,
|
||||||
|
missing,
|
||||||
|
users,
|
||||||
|
url,
|
||||||
|
access,
|
||||||
|
selfServiceOnly,
|
||||||
|
currentHeadscaleUserId,
|
||||||
|
currentSubject,
|
||||||
|
},
|
||||||
}: Route.ComponentProps) {
|
}: Route.ComponentProps) {
|
||||||
const [selectedUser, setSelectedUser] = useState("__headplane_all");
|
const [selectedUser, setSelectedUser] = useState("__headplane_all");
|
||||||
const [status, setStatus] = useState<Status>("active");
|
const [status, setStatus] = useState<Status>("active");
|
||||||
@@ -199,6 +210,7 @@ export default function Page({
|
|||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
<AddAuthKey
|
<AddAuthKey
|
||||||
|
currentHeadscaleUserId={currentHeadscaleUserId}
|
||||||
currentSubject={currentSubject}
|
currentSubject={currentSubject}
|
||||||
selfServiceOnly={selfServiceOnly}
|
selfServiceOnly={selfServiceOnly}
|
||||||
url={url}
|
url={url}
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
// The user must exist within Headscale to generate a pre-auth key
|
// The user must exist within Headscale to generate a pre-auth key
|
||||||
const users = await api.users.list();
|
const users = await api.users.list();
|
||||||
const hsUser = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
|
const hsUser = principal.user.headscaleUserId
|
||||||
|
? users.find((u) => u.id === principal.user.headscaleUserId)
|
||||||
|
: findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
|
||||||
|
|
||||||
if (!hsUser) {
|
if (!hsUser) {
|
||||||
throw data(sshErrors.user_not_linked, 404);
|
throw data(sshErrors.user_not_linked, 404);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|||||||
|
|
||||||
import PageError from "~/components/page-error";
|
import PageError from "~/components/page-error";
|
||||||
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
||||||
|
import { isUserPrincipal } from "~/server/web/auth";
|
||||||
import { Capabilities, Roles } from "~/server/web/roles";
|
import { Capabilities, Roles } from "~/server/web/roles";
|
||||||
import type { Role } from "~/server/web/roles";
|
import type { Role } from "~/server/web/roles";
|
||||||
import type { Machine, User } from "~/types";
|
import type { Machine, User } from "~/types";
|
||||||
@@ -131,11 +132,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOwner = principal.kind === "oidc" && principal.user.role === "owner";
|
const isOwner = isUserPrincipal(principal) && principal.user.role === "owner";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
writable: writablePermission,
|
writable: writablePermission,
|
||||||
currentUserId: principal.kind === "oidc" ? principal.user.id : undefined,
|
currentUserId: isUserPrincipal(principal) ? principal.user.id : undefined,
|
||||||
isOwner,
|
isOwner,
|
||||||
oidc: context.config.oidc ? { issuer: context.config.oidc.issuer } : undefined,
|
oidc: context.config.oidc ? { issuer: context.config.oidc.issuer } : undefined,
|
||||||
magic,
|
magic,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { data } from "react-router";
|
import { data } from "react-router";
|
||||||
|
|
||||||
import { usersResource } from "~/server/headscale/live-store";
|
import { usersResource } from "~/server/headscale/live-store";
|
||||||
|
import { isUserPrincipal } from "~/server/web/auth";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import type { Role } from "~/server/web/roles";
|
import type { Role } from "~/server/web/roles";
|
||||||
|
|
||||||
@@ -93,7 +94,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
|||||||
return { message: "User reassigned successfully" };
|
return { message: "User reassigned successfully" };
|
||||||
}
|
}
|
||||||
case "transfer_ownership": {
|
case "transfer_ownership": {
|
||||||
if (principal.kind !== "oidc" || principal.user.role !== "owner") {
|
if (!isUserPrincipal(principal) || principal.user.role !== "owner") {
|
||||||
throw data("Only the owner can transfer ownership.", { status: 403 });
|
throw data("Only the owner can transfer ownership.", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -63,5 +63,8 @@ export async function dispose(): Promise<void> {
|
|||||||
export default createRequestListener({
|
export default createRequestListener({
|
||||||
build,
|
build,
|
||||||
mode: import.meta.env.MODE,
|
mode: import.meta.env.MODE,
|
||||||
getLoadContext: () => ctx,
|
getLoadContext: (request, client) => {
|
||||||
|
ctx.auth.registerRequestClientAddress(request, client.address);
|
||||||
|
return ctx;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,6 +48,17 @@ const serverConfig = type({
|
|||||||
// either is set, `cookie_secure` is forced to `true`.
|
// either is set, `cookie_secure` is forced to `true`.
|
||||||
tls_cert_path: "string?",
|
tls_cert_path: "string?",
|
||||||
tls_key_path: "string?",
|
tls_key_path: "string?",
|
||||||
|
|
||||||
|
"proxy_auth?": {
|
||||||
|
enabled: "boolean",
|
||||||
|
allowed_cidrs: "string[]?",
|
||||||
|
trusted_proxy_cidrs: "string[]?",
|
||||||
|
ip_header: "string?",
|
||||||
|
user_header: "string?",
|
||||||
|
email_header: "string?",
|
||||||
|
name_header: "string?",
|
||||||
|
picture_header: "string?",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const partialServerConfig = type({
|
const partialServerConfig = type({
|
||||||
@@ -64,6 +75,17 @@ const partialServerConfig = type({
|
|||||||
|
|
||||||
tls_cert_path: "string?",
|
tls_cert_path: "string?",
|
||||||
tls_key_path: "string?",
|
tls_key_path: "string?",
|
||||||
|
|
||||||
|
"proxy_auth?": {
|
||||||
|
enabled: "boolean?",
|
||||||
|
allowed_cidrs: "string[]?",
|
||||||
|
trusted_proxy_cidrs: "string[]?",
|
||||||
|
ip_header: "string?",
|
||||||
|
user_header: "string?",
|
||||||
|
email_header: "string?",
|
||||||
|
name_header: "string?",
|
||||||
|
picture_header: "string?",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const headscaleConfig = type({
|
const headscaleConfig = type({
|
||||||
|
|||||||
@@ -40,6 +40,18 @@ export async function createAppContext(config: HeadplaneConfig) {
|
|||||||
const auth = createAuthService({
|
const auth = createAuthService({
|
||||||
secret: config.server.cookie_secret,
|
secret: config.server.cookie_secret,
|
||||||
headscaleApiKey,
|
headscaleApiKey,
|
||||||
|
proxyAuth: config.server.proxy_auth
|
||||||
|
? {
|
||||||
|
enabled: config.server.proxy_auth.enabled,
|
||||||
|
allowedCidrs: config.server.proxy_auth.allowed_cidrs,
|
||||||
|
trustedProxyCidrs: config.server.proxy_auth.trusted_proxy_cidrs,
|
||||||
|
ipHeader: config.server.proxy_auth.ip_header,
|
||||||
|
userHeader: config.server.proxy_auth.user_header,
|
||||||
|
emailHeader: config.server.proxy_auth.email_header,
|
||||||
|
nameHeader: config.server.proxy_auth.name_header,
|
||||||
|
pictureHeader: config.server.proxy_auth.picture_header,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
db,
|
db,
|
||||||
cookie: {
|
cookie: {
|
||||||
name: "_hp_auth",
|
name: "_hp_auth",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export type HeadplaneUserInsert = typeof users.$inferInsert;
|
|||||||
|
|
||||||
export const authSessions = sqliteTable("auth_sessions", {
|
export const authSessions = sqliteTable("auth_sessions", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
kind: text("kind").notNull(), // 'oidc' | 'api_key'
|
kind: text("kind").notNull(), // 'oidc' | 'api_key' (proxy auth is request-scoped)
|
||||||
user_id: text("user_id"),
|
user_id: text("user_id"),
|
||||||
api_key_hash: text("api_key_hash"),
|
api_key_hash: text("api_key_hash"),
|
||||||
api_key_display: text("api_key_display"),
|
api_key_display: text("api_key_display"),
|
||||||
|
|||||||
+316
-36
@@ -1,4 +1,5 @@
|
|||||||
import { createHash, createHmac } from "node:crypto";
|
import { createHash, createHmac } from "node:crypto";
|
||||||
|
import { isIP } from "node:net";
|
||||||
|
|
||||||
import { eq, lt, sql } from "drizzle-orm";
|
import { eq, lt, sql } from "drizzle-orm";
|
||||||
import { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
|
import { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
|
||||||
@@ -17,23 +18,36 @@ export type Principal =
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
}
|
}
|
||||||
| {
|
| UserPrincipal;
|
||||||
kind: "oidc";
|
|
||||||
sessionId: string;
|
export type UserPrincipal = {
|
||||||
idToken?: string;
|
kind: "oidc" | "proxy";
|
||||||
user: {
|
sessionId: string;
|
||||||
id: string;
|
idToken?: string;
|
||||||
subject: string;
|
user: {
|
||||||
role: Role;
|
id: string;
|
||||||
headscaleUserId: string | undefined;
|
subject: string;
|
||||||
};
|
role: Role;
|
||||||
profile: {
|
headscaleUserId: string | undefined;
|
||||||
name: string;
|
};
|
||||||
email?: string;
|
profile: {
|
||||||
username?: string;
|
name: string;
|
||||||
picture?: string;
|
email?: string;
|
||||||
};
|
username?: string;
|
||||||
};
|
picture?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ProxyAuthOptions {
|
||||||
|
enabled: boolean;
|
||||||
|
allowedCidrs?: string[];
|
||||||
|
trustedProxyCidrs?: string[];
|
||||||
|
ipHeader?: string;
|
||||||
|
userHeader?: string;
|
||||||
|
emailHeader?: string;
|
||||||
|
nameHeader?: string;
|
||||||
|
pictureHeader?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface CookiePayload {
|
interface CookiePayload {
|
||||||
sid: string;
|
sid: string;
|
||||||
@@ -48,6 +62,7 @@ interface CookiePayload {
|
|||||||
export interface AuthServiceOptions {
|
export interface AuthServiceOptions {
|
||||||
secret: string;
|
secret: string;
|
||||||
headscaleApiKey?: string;
|
headscaleApiKey?: string;
|
||||||
|
proxyAuth?: ProxyAuthOptions;
|
||||||
db: NodeSQLiteDatabase;
|
db: NodeSQLiteDatabase;
|
||||||
cookie: {
|
cookie: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -58,6 +73,7 @@ export interface AuthServiceOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthService {
|
export interface AuthService {
|
||||||
|
registerRequestClientAddress(request: Request, address: string | undefined): void;
|
||||||
require(request: Request): Promise<Principal>;
|
require(request: Request): Promise<Principal>;
|
||||||
can(principal: Principal, capabilities: Capabilities): boolean;
|
can(principal: Principal, capabilities: Capabilities): boolean;
|
||||||
canManageNode(principal: Principal, node: Machine): boolean;
|
canManageNode(principal: Principal, node: Machine): boolean;
|
||||||
@@ -88,8 +104,152 @@ export interface AuthService {
|
|||||||
stop(): void;
|
stop(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isUserPrincipal(principal: Principal): principal is UserPrincipal {
|
||||||
|
return principal.kind === "oidc" || principal.kind === "proxy";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CidrRange {
|
||||||
|
family: 4 | 6;
|
||||||
|
base: bigint;
|
||||||
|
mask: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROXY_AUTH_CIDRS = ["127.0.0.1/32", "::1/128"];
|
||||||
|
const DEFAULT_PROXY_AUTH_USER_HEADER = "Remote-User";
|
||||||
|
|
||||||
|
function normalizeIpAddress(address: string): string {
|
||||||
|
if (address.startsWith("::ffff:")) {
|
||||||
|
const mapped = address.slice("::ffff:".length);
|
||||||
|
if (isIP(mapped) === 4) {
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIpv4(address: string): bigint | undefined {
|
||||||
|
const parts = address.split(".");
|
||||||
|
if (parts.length !== 4) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = 0n;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!/^\d+$/.test(part)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byte = Number(part);
|
||||||
|
if (byte < 0 || byte > 255) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = (value << 8n) + BigInt(byte);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIpv6(address: string): bigint | undefined {
|
||||||
|
const sections = address.split("::");
|
||||||
|
if (sections.length > 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const head = sections[0] ? sections[0].split(":") : [];
|
||||||
|
const tail = sections.length === 2 && sections[1] ? sections[1].split(":") : [];
|
||||||
|
const missing = 8 - head.length - tail.length;
|
||||||
|
if (missing < 0 || (sections.length === 1 && missing !== 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = [...head, ...Array<string>(missing).fill("0"), ...tail];
|
||||||
|
if (groups.length !== 8) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = 0n;
|
||||||
|
for (const group of groups) {
|
||||||
|
if (!/^[0-9a-fA-F]{1,4}$/.test(group)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = (value << 16n) + BigInt(parseInt(group, 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIpAddress(address: string): { family: 4 | 6; value: bigint } | undefined {
|
||||||
|
const normalized = normalizeIpAddress(address);
|
||||||
|
const family = isIP(normalized);
|
||||||
|
if (family === 4) {
|
||||||
|
const value = parseIpv4(normalized);
|
||||||
|
return value === undefined ? undefined : { family, value };
|
||||||
|
}
|
||||||
|
if (family === 6) {
|
||||||
|
const value = parseIpv6(normalized);
|
||||||
|
return value === undefined ? undefined : { family, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCidr(cidr: string): CidrRange {
|
||||||
|
const parts = cidr.trim().split("/");
|
||||||
|
if (parts.length > 2) {
|
||||||
|
throw new Error(`Invalid proxy auth CIDR: ${cidr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rawAddress, rawPrefix] = parts;
|
||||||
|
const address = parseIpAddress(rawAddress);
|
||||||
|
if (!address) {
|
||||||
|
throw new Error(`Invalid proxy auth CIDR address: ${cidr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBits = address.family === 4 ? 32 : 128;
|
||||||
|
const prefix = rawPrefix === undefined ? maxBits : Number(rawPrefix);
|
||||||
|
if (!Number.isInteger(prefix) || prefix < 0 || prefix > maxBits) {
|
||||||
|
throw new Error(`Invalid proxy auth CIDR prefix: ${cidr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bits = BigInt(maxBits);
|
||||||
|
const hostBits = BigInt(maxBits - prefix);
|
||||||
|
const allOnes = (1n << bits) - 1n;
|
||||||
|
const mask = prefix === 0 ? 0n : (allOnes << hostBits) & allOnes;
|
||||||
|
|
||||||
|
return {
|
||||||
|
family: address.family,
|
||||||
|
base: address.value & mask,
|
||||||
|
mask,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cidrContains(range: CidrRange, address: string): boolean {
|
||||||
|
const parsed = parseIpAddress(address);
|
||||||
|
if (!parsed || parsed.family !== range.family) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (parsed.value & range.mask) === range.base;
|
||||||
|
}
|
||||||
|
|
||||||
export function createAuthService(opts: AuthServiceOptions): AuthService {
|
export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||||
const requestCache = new WeakMap<Request, Promise<Principal>>();
|
const requestCache = new WeakMap<Request, Promise<Principal>>();
|
||||||
|
const clientAddresses = new WeakMap<Request, string>();
|
||||||
|
const proxyAuthCidrs = opts.proxyAuth?.enabled
|
||||||
|
? (opts.proxyAuth.allowedCidrs?.length
|
||||||
|
? opts.proxyAuth.allowedCidrs
|
||||||
|
: DEFAULT_PROXY_AUTH_CIDRS
|
||||||
|
).map(parseCidr)
|
||||||
|
: [];
|
||||||
|
const trustedProxyCidrs = opts.proxyAuth?.enabled
|
||||||
|
? (opts.proxyAuth.trustedProxyCidrs?.length
|
||||||
|
? opts.proxyAuth.trustedProxyCidrs
|
||||||
|
: DEFAULT_PROXY_AUTH_CIDRS
|
||||||
|
).map(parseCidr)
|
||||||
|
: [];
|
||||||
let pruneTimer: ReturnType<typeof setInterval> | undefined;
|
let pruneTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
async function encodeCookie(payload: CookiePayload, maxAge: number): Promise<string> {
|
async function encodeCookie(payload: CookiePayload, maxAge: number): Promise<string> {
|
||||||
@@ -140,7 +300,139 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
|||||||
return createHash("sha256").update(key).digest("hex");
|
return createHash("sha256").update(key).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function registerRequestClientAddress(request: Request, address: string | undefined): void {
|
||||||
|
if (address) {
|
||||||
|
clientAddresses.set(request, address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getForwardedClientAddress(request: Request): string | undefined {
|
||||||
|
const headerName = opts.proxyAuth?.ipHeader;
|
||||||
|
if (!headerName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = request.headers.get(headerName)?.trim();
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = value.split(",")[0]?.trim();
|
||||||
|
if (!first) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseIpAddress(first) ? first : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProxyAuthClientAddress(request: Request): string | undefined {
|
||||||
|
const directAddress = clientAddresses.get(request);
|
||||||
|
if (!directAddress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opts.proxyAuth?.ipHeader) {
|
||||||
|
return directAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directPeerTrusted = trustedProxyCidrs.some((cidr) => cidrContains(cidr, directAddress));
|
||||||
|
if (!directPeerTrusted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getForwardedClientAddress(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveUserPrincipal(options: {
|
||||||
|
kind: UserPrincipal["kind"];
|
||||||
|
sessionId: string;
|
||||||
|
userId: string;
|
||||||
|
idToken?: string;
|
||||||
|
profile?: {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
}): Promise<UserPrincipal> {
|
||||||
|
const [user] = await opts.db.select().from(users).where(eq(users.id, options.userId)).limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User record not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = (user.role in Roles ? user.role : "member") as Role;
|
||||||
|
return {
|
||||||
|
kind: options.kind,
|
||||||
|
sessionId: options.sessionId,
|
||||||
|
idToken: options.idToken,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
subject: user.sub,
|
||||||
|
role,
|
||||||
|
headscaleUserId: user.headscale_user_id ?? undefined,
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
name: options.profile?.name ?? user.name ?? user.sub,
|
||||||
|
email: options.profile?.email ?? user.email ?? undefined,
|
||||||
|
username: options.profile?.username,
|
||||||
|
picture: user.picture ?? undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveProxyAuthPrincipal(request: Request): Promise<Principal | undefined> {
|
||||||
|
if (!opts.proxyAuth?.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!opts.headscaleApiKey) {
|
||||||
|
throw new Error("Proxy authentication requires headscale.api_key to be configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientAddress = getProxyAuthClientAddress(request);
|
||||||
|
if (!clientAddress || !proxyAuthCidrs.some((cidr) => cidrContains(cidr, clientAddress))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userHeader = opts.proxyAuth.userHeader ?? DEFAULT_PROXY_AUTH_USER_HEADER;
|
||||||
|
const proxyUser = request.headers.get(userHeader)?.trim();
|
||||||
|
if (!proxyUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = opts.proxyAuth.emailHeader
|
||||||
|
? request.headers.get(opts.proxyAuth.emailHeader)?.trim()
|
||||||
|
: undefined;
|
||||||
|
const name = opts.proxyAuth.nameHeader
|
||||||
|
? request.headers.get(opts.proxyAuth.nameHeader)?.trim()
|
||||||
|
: undefined;
|
||||||
|
const picture = opts.proxyAuth.pictureHeader
|
||||||
|
? request.headers.get(opts.proxyAuth.pictureHeader)?.trim()
|
||||||
|
: undefined;
|
||||||
|
const subject = `proxy:${proxyUser}`;
|
||||||
|
const userId = await findOrCreateUser(subject, {
|
||||||
|
name: name || proxyUser,
|
||||||
|
email: email || undefined,
|
||||||
|
picture: picture || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return resolveUserPrincipal({
|
||||||
|
kind: "proxy",
|
||||||
|
sessionId: "proxy-auth",
|
||||||
|
userId,
|
||||||
|
profile: {
|
||||||
|
name: name || proxyUser,
|
||||||
|
email: email || undefined,
|
||||||
|
username: proxyUser,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function resolve(request: Request): Promise<Principal> {
|
async function resolve(request: Request): Promise<Principal> {
|
||||||
|
const proxyPrincipal = await resolveProxyAuthPrincipal(request);
|
||||||
|
if (proxyPrincipal) {
|
||||||
|
return proxyPrincipal;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = await decodeCookie(request);
|
const payload = await decodeCookie(request);
|
||||||
|
|
||||||
const [session] = await opts.db
|
const [session] = await opts.db
|
||||||
@@ -175,30 +467,17 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
|||||||
throw new Error("OIDC session missing user_id");
|
throw new Error("OIDC session missing user_id");
|
||||||
}
|
}
|
||||||
|
|
||||||
const [user] = await opts.db.select().from(users).where(eq(users.id, session.user_id)).limit(1);
|
return resolveUserPrincipal({
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new Error("User record not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const role = (user.role in Roles ? user.role : "member") as Role;
|
|
||||||
return {
|
|
||||||
kind: "oidc",
|
kind: "oidc",
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
idToken: session.oidc_id_token ?? undefined,
|
idToken: session.oidc_id_token ?? undefined,
|
||||||
user: {
|
userId: session.user_id,
|
||||||
id: user.id,
|
|
||||||
subject: user.sub,
|
|
||||||
role,
|
|
||||||
headscaleUserId: user.headscale_user_id ?? undefined,
|
|
||||||
},
|
|
||||||
profile: {
|
profile: {
|
||||||
name: payload.profile?.name ?? user.name ?? user.sub,
|
name: payload.profile?.name,
|
||||||
email: payload.profile?.email ?? user.email ?? undefined,
|
email: payload.profile?.email,
|
||||||
username: payload.profile?.username,
|
username: payload.profile?.username,
|
||||||
picture: user.picture ?? undefined,
|
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function require(request: Request): Promise<Principal> {
|
function require(request: Request): Promise<Principal> {
|
||||||
@@ -241,7 +520,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!opts.headscaleApiKey) {
|
if (!opts.headscaleApiKey) {
|
||||||
throw new Error("OIDC sessions require headscale.api_key to be configured");
|
throw new Error("User sessions require headscale.api_key to be configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
return opts.headscaleApiKey;
|
return opts.headscaleApiKey;
|
||||||
@@ -480,6 +759,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
registerRequestClientAddress,
|
||||||
require: require,
|
require: require,
|
||||||
can,
|
can,
|
||||||
canManageNode,
|
canManageNode,
|
||||||
|
|||||||
+32
-1
@@ -38,6 +38,36 @@ server:
|
|||||||
# This may not work as expected if not using a reverse proxy.
|
# This may not work as expected if not using a reverse proxy.
|
||||||
# cookie_domain: ""
|
# cookie_domain: ""
|
||||||
|
|
||||||
|
# Optional proxy authentication mode. When enabled, Headplane trusts identity
|
||||||
|
# headers from requests that come directly from one of `allowed_cidrs` and
|
||||||
|
# uses `headscale.api_key` for Headscale API access.
|
||||||
|
#
|
||||||
|
# Only enable this when a trusted reverse proxy in front of Headplane already
|
||||||
|
# performs authentication (for example, nginx basic auth, Authelia, Authentik,
|
||||||
|
# etc.). The source IP check uses the direct client address connected to
|
||||||
|
# Headplane by default, not X-Forwarded-For headers.
|
||||||
|
#
|
||||||
|
# If `allowed_cidrs` is omitted, only localhost is trusted.
|
||||||
|
# proxy_auth:
|
||||||
|
# enabled: false
|
||||||
|
#
|
||||||
|
# # Optional. If set, Headplane checks allowed_cidrs against the first IP in
|
||||||
|
# # this header (for example, "X-Forwarded-For" or "X-Real-IP") instead of
|
||||||
|
# # the direct client address. The direct client must still match
|
||||||
|
# # trusted_proxy_cidrs before this header is used.
|
||||||
|
# ip_header: "X-Forwarded-For"
|
||||||
|
# trusted_proxy_cidrs:
|
||||||
|
# - "127.0.0.1/32"
|
||||||
|
# - "::1/128"
|
||||||
|
#
|
||||||
|
# user_header: "Remote-User"
|
||||||
|
# email_header: "Remote-Email"
|
||||||
|
# name_header: "Remote-Name"
|
||||||
|
# picture_header: "Remote-Picture"
|
||||||
|
# allowed_cidrs:
|
||||||
|
# - "127.0.0.1/32"
|
||||||
|
# - "::1/128"
|
||||||
|
|
||||||
# The path to persist Headplane specific data. All data going forward
|
# The path to persist Headplane specific data. All data going forward
|
||||||
# is stored in this directory, including the internal database and
|
# is stored in this directory, including the internal database and
|
||||||
# any cache related files.
|
# any cache related files.
|
||||||
@@ -87,7 +117,8 @@ headscale:
|
|||||||
config_path: "/etc/headscale/config.yaml"
|
config_path: "/etc/headscale/config.yaml"
|
||||||
|
|
||||||
# The API key used by Headplane for server-side operations.
|
# The API key used by Headplane for server-side operations.
|
||||||
# This is required for OIDC authentication and the Headplane agent.
|
# This is required for OIDC authentication, proxy authentication, and the
|
||||||
|
# Headplane agent.
|
||||||
# Generate one with: headscale apikeys create
|
# Generate one with: headscale apikeys create
|
||||||
# api_key: "<your-api-key>"
|
# api_key: "<your-api-key>"
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,11 @@ export default defineConfig({
|
|||||||
{
|
{
|
||||||
text: "Features",
|
text: "Features",
|
||||||
items: [
|
items: [
|
||||||
{ text: "Single Sign-On (SSO)", link: "/features/sso" },
|
{
|
||||||
|
text: "Single Sign-On (SSO)",
|
||||||
|
link: "/features/sso",
|
||||||
|
items: [{ text: "Proxy Authentication", link: "/features/proxy-auth" }],
|
||||||
|
},
|
||||||
{ text: "Headplane Agent", link: "/features/agent" },
|
{ text: "Headplane Agent", link: "/features/agent" },
|
||||||
{ text: "Browser SSH", link: "/features/ssh" },
|
{ text: "Browser SSH", link: "/features/ssh" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -350,3 +350,82 @@ _Description:_ The port to listen on.
|
|||||||
_Type:_ 16 bit unsigned integer; between 0 and 65535 (both inclusive)
|
_Type:_ 16 bit unsigned integer; between 0 and 65535 (both inclusive)
|
||||||
|
|
||||||
_Default:_ `3000`
|
_Default:_ `3000`
|
||||||
|
|
||||||
|
## settings.server.proxy_auth
|
||||||
|
|
||||||
|
_Description:_ Proxy authentication configuration.
|
||||||
|
|
||||||
|
_Type:_ submodule
|
||||||
|
|
||||||
|
_Default:_ `{ }`
|
||||||
|
|
||||||
|
## settings.server.proxy_auth.allowed_cidrs
|
||||||
|
|
||||||
|
_Description:_ Direct client CIDR ranges allowed to bypass Headplane's login flow.
|
||||||
|
These should be the addresses your trusted reverse proxy uses to connect
|
||||||
|
to Headplane. Requires headscale.api_key_path.
|
||||||
|
|
||||||
|
_Type:_ list of string
|
||||||
|
|
||||||
|
_Default:_ `[ "127.0.0.1/32" "::1/128" ]`
|
||||||
|
|
||||||
|
_Example:_ `[ "10.0.0.0/24" ]`
|
||||||
|
|
||||||
|
## settings.server.proxy_auth.email_header
|
||||||
|
|
||||||
|
_Description:_ Optional header containing the authenticated user's email address.
|
||||||
|
|
||||||
|
_Type:_ null or string
|
||||||
|
|
||||||
|
_Default:_ `null`
|
||||||
|
|
||||||
|
## settings.server.proxy_auth.enabled
|
||||||
|
|
||||||
|
_Description:_ Whether to trust reverse proxy authentication for allowed client CIDRs.
|
||||||
|
|
||||||
|
_Type:_ boolean
|
||||||
|
|
||||||
|
_Default:_ `false`
|
||||||
|
|
||||||
|
## settings.server.proxy_auth.ip_header
|
||||||
|
|
||||||
|
_Description:_ Optional header containing the original client IP, such as X-Forwarded-For or X-Real-IP.
|
||||||
|
|
||||||
|
_Type:_ null or string
|
||||||
|
|
||||||
|
_Default:_ `null`
|
||||||
|
|
||||||
|
## settings.server.proxy_auth.name_header
|
||||||
|
|
||||||
|
_Description:_ Optional header containing the authenticated user's display name.
|
||||||
|
|
||||||
|
_Type:_ null or string
|
||||||
|
|
||||||
|
_Default:_ `null`
|
||||||
|
|
||||||
|
## settings.server.proxy_auth.picture_header
|
||||||
|
|
||||||
|
_Description:_ Optional header containing the authenticated user's profile picture URL.
|
||||||
|
|
||||||
|
_Type:_ null or string
|
||||||
|
|
||||||
|
_Default:_ `null`
|
||||||
|
|
||||||
|
## settings.server.proxy_auth.user_header
|
||||||
|
|
||||||
|
_Description:_ Header containing the stable authenticated proxy user identity.
|
||||||
|
|
||||||
|
_Type:_ string
|
||||||
|
|
||||||
|
_Default:_ `"Remote-User"`
|
||||||
|
|
||||||
|
## settings.server.proxy_auth.trusted_proxy_cidrs
|
||||||
|
|
||||||
|
_Description:_ Direct proxy CIDR ranges trusted to supply ip_header.
|
||||||
|
Only used when ip_header is set.
|
||||||
|
|
||||||
|
_Type:_ list of string
|
||||||
|
|
||||||
|
_Default:_ `[ "127.0.0.1/32" "::1/128" ]`
|
||||||
|
|
||||||
|
_Example:_ `[ "127.0.0.1/32" ]`
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ The following configuration options in Headplane are treated as secret paths:
|
|||||||
- _Note:_ Either `cookie_secret` or `cookie_secret_path` must be provided for web session security.
|
- _Note:_ Either `cookie_secret` or `cookie_secret_path` must be provided for web session security.
|
||||||
|
|
||||||
- **Headscale Connection Settings (`headscale.*`):**
|
- **Headscale Connection Settings (`headscale.*`):**
|
||||||
- `api_key_path` (Headscale API key for server-side operations like OIDC and agent sync)
|
- `api_key_path` (Headscale API key for server-side operations like OIDC, proxy authentication, and agent sync)
|
||||||
- _Note:_ Either `api_key` or `api_key_path` must be provided when using OIDC or the agent.
|
- _Note:_ Either `api_key` or `api_key_path` must be provided when using OIDC, proxy authentication, or the agent.
|
||||||
- `tls_cert_path` (custom TLS certificate for connecting to Headscale)
|
- `tls_cert_path` (custom TLS certificate for connecting to Headscale)
|
||||||
- _Note:_ This is treated as a regular path, not a secret path, so it will not have its content loaded.
|
- _Note:_ This is treated as a regular path, not a secret path, so it will not have its content loaded.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
title: Proxy Authentication
|
||||||
|
description: Delegate Headplane authentication to a trusted reverse proxy.
|
||||||
|
outline: [2, 3]
|
||||||
|
---
|
||||||
|
|
||||||
|
:::warning
|
||||||
|
Proxy authentication is **dangerously powerful**. If misconfigured, it can allow
|
||||||
|
anyone to impersonate users and gain access to Headplane.
|
||||||
|
|
||||||
|
It is recommended to use Headplane's built-in SSO integrations over proxy
|
||||||
|
authentication if possible. No guarantees are made about the security of proxy
|
||||||
|
authentication.
|
||||||
|
:::
|
||||||
|
|
||||||
|
# Proxy Authentication
|
||||||
|
|
||||||
|
Proxy authentication lets Headplane delegate user authentication to a trusted
|
||||||
|
reverse proxy. This is useful when Headplane is already protected by middleware
|
||||||
|
such as nginx basic auth, Authelia, Authentik, or another SSO-aware proxy and
|
||||||
|
you do not want users to log in to Headplane separately.
|
||||||
|
|
||||||
|
Proxy authentication is intentionally opt-in and requires `headscale.api_key`.
|
||||||
|
When enabled, Headplane trusts identity headers only on requests whose client IP
|
||||||
|
matches `server.proxy_auth.allowed_cidrs`; all Headscale API calls then use the
|
||||||
|
configured `headscale.api_key`.
|
||||||
|
|
||||||
|
## Basic Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
headscale:
|
||||||
|
api_key: "<your-headscale-api-key>"
|
||||||
|
|
||||||
|
server:
|
||||||
|
proxy_auth:
|
||||||
|
enabled: true
|
||||||
|
user_header: "Remote-User"
|
||||||
|
email_header: "Remote-Email"
|
||||||
|
name_header: "Remote-Name"
|
||||||
|
allowed_cidrs:
|
||||||
|
- "127.0.0.1/32"
|
||||||
|
- "::1/128"
|
||||||
|
```
|
||||||
|
|
||||||
|
`user_header` is required for a request to authenticate and defaults to
|
||||||
|
`Remote-User`. The value becomes the stable proxy identity in Headplane as
|
||||||
|
`proxy:<value>`. `email_header`, `name_header`, and `picture_header` are
|
||||||
|
optional profile metadata headers.
|
||||||
|
|
||||||
|
The first proxy-authenticated user is created as the Headplane owner, matching
|
||||||
|
the normal SSO first-user behavior. Subsequent users are created as members and
|
||||||
|
can be reassigned from the Users page.
|
||||||
|
|
||||||
|
## Client IP Checks
|
||||||
|
|
||||||
|
If `allowed_cidrs` is omitted, Headplane trusts only localhost. By default, this
|
||||||
|
CIDR check uses the socket address connected to Headplane, not
|
||||||
|
`X-Forwarded-For`, `X-Real-IP`, or other forwarded headers. Configure
|
||||||
|
`allowed_cidrs` for the direct address range your proxy uses to connect to
|
||||||
|
Headplane.
|
||||||
|
|
||||||
|
## Forwarded Client IP Headers
|
||||||
|
|
||||||
|
If you need to check the original client IP from a proxy header, set `ip_header`
|
||||||
|
to `X-Forwarded-For`, `X-Real-IP`, or another header your proxy controls. When
|
||||||
|
`ip_header` is set, Headplane only reads that header if the direct socket peer
|
||||||
|
matches `trusted_proxy_cidrs` (default localhost). The first IP in the header is
|
||||||
|
then checked against `allowed_cidrs`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
proxy_auth:
|
||||||
|
enabled: true
|
||||||
|
ip_header: "X-Forwarded-For"
|
||||||
|
trusted_proxy_cidrs:
|
||||||
|
- "127.0.0.1/32"
|
||||||
|
allowed_cidrs:
|
||||||
|
- "10.0.0.0/8"
|
||||||
|
```
|
||||||
|
|
||||||
|
::: warning
|
||||||
|
Only enable proxy authentication when Headplane is not directly reachable by
|
||||||
|
untrusted clients. Anyone who can connect to Headplane from an allowed CIDR will
|
||||||
|
be able to spoof the configured identity headers. Only configure `ip_header`
|
||||||
|
for headers set or overwritten by your trusted reverse proxy.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Header Reference
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
| --------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||||
|
| `server.proxy_auth.enabled` | Enables proxy authentication. |
|
||||||
|
| `server.proxy_auth.user_header` | Header containing the stable authenticated user identity. Defaults to `Remote-User`. |
|
||||||
|
| `server.proxy_auth.email_header` | Optional header containing the authenticated user's email address. |
|
||||||
|
| `server.proxy_auth.name_header` | Optional header containing the authenticated user's display name. |
|
||||||
|
| `server.proxy_auth.picture_header` | Optional header containing the authenticated user's profile picture URL. |
|
||||||
|
| `server.proxy_auth.allowed_cidrs` | Client CIDRs allowed to authenticate. Defaults to localhost. |
|
||||||
|
| `server.proxy_auth.ip_header` | Optional original-client-IP header such as `X-Forwarded-For` or `X-Real-IP`. |
|
||||||
|
| `server.proxy_auth.trusted_proxy_cidrs` | Direct proxy CIDRs trusted to supply `ip_header`. Defaults to localhost. |
|
||||||
@@ -17,6 +17,9 @@ Identity Provider (IdP) using the OpenID Connect (OIDC) protocol. When enabled,
|
|||||||
users sign in through your IdP and Headplane automatically links them to their
|
users sign in through your IdP and Headplane automatically links them to their
|
||||||
Headscale identity, assigns a role, and manages their session.
|
Headscale identity, assigns a role, and manages their session.
|
||||||
|
|
||||||
|
If your reverse proxy already performs authentication and can pass trusted user
|
||||||
|
headers to Headplane, see [Proxy Authentication](./proxy-auth.md) instead.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|||||||
@@ -90,6 +90,77 @@ in {
|
|||||||
example = "headscale.example.com";
|
example = "headscale.example.com";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
proxy_auth = mkOption {
|
||||||
|
type = types.submodule {
|
||||||
|
options = {
|
||||||
|
enabled = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Whether to trust reverse proxy authentication for allowed client CIDRs.";
|
||||||
|
};
|
||||||
|
|
||||||
|
user_header = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "Remote-User";
|
||||||
|
description = "Header containing the stable authenticated proxy user identity.";
|
||||||
|
};
|
||||||
|
|
||||||
|
ip_header = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Optional header containing the original client IP, such as X-Forwarded-For or X-Real-IP.";
|
||||||
|
};
|
||||||
|
|
||||||
|
trusted_proxy_cidrs = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
|
default = [
|
||||||
|
"127.0.0.1/32"
|
||||||
|
"::1/128"
|
||||||
|
];
|
||||||
|
description = ''
|
||||||
|
Direct proxy CIDR ranges trusted to supply ip_header.
|
||||||
|
Only used when ip_header is set.
|
||||||
|
'';
|
||||||
|
example = ["127.0.0.1/32"];
|
||||||
|
};
|
||||||
|
|
||||||
|
email_header = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Optional header containing the authenticated user's email address.";
|
||||||
|
};
|
||||||
|
|
||||||
|
name_header = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Optional header containing the authenticated user's display name.";
|
||||||
|
};
|
||||||
|
|
||||||
|
picture_header = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Optional header containing the authenticated user's profile picture URL.";
|
||||||
|
};
|
||||||
|
|
||||||
|
allowed_cidrs = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
|
default = [
|
||||||
|
"127.0.0.1/32"
|
||||||
|
"::1/128"
|
||||||
|
];
|
||||||
|
description = ''
|
||||||
|
Direct client CIDR ranges allowed to bypass Headplane's login flow.
|
||||||
|
These should be the addresses your trusted reverse proxy uses to connect
|
||||||
|
to Headplane. Requires headscale.api_key_path.
|
||||||
|
'';
|
||||||
|
example = ["10.0.0.0/24"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default = {};
|
||||||
|
description = "Proxy authentication configuration.";
|
||||||
|
};
|
||||||
|
|
||||||
data_path = mkOption {
|
data_path = mkOption {
|
||||||
type = types.path;
|
type = types.path;
|
||||||
default = "/var/lib/headplane";
|
default = "/var/lib/headplane";
|
||||||
|
|||||||
@@ -196,6 +196,113 @@ describe("session round-trip", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("proxy authentication", () => {
|
||||||
|
test("creates a user-backed principal from trusted proxy headers", async () => {
|
||||||
|
const { auth } = createTestAuth({
|
||||||
|
headscaleApiKey: "configured-headscale-key",
|
||||||
|
proxyAuth: {
|
||||||
|
enabled: true,
|
||||||
|
allowedCidrs: ["10.10.0.0/16"],
|
||||||
|
emailHeader: "X-Forwarded-Email",
|
||||||
|
nameHeader: "X-Forwarded-Name",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const request = new Request("http://localhost/test", {
|
||||||
|
headers: {
|
||||||
|
"Remote-User": "alice",
|
||||||
|
"X-Forwarded-Email": "alice@example.com",
|
||||||
|
"X-Forwarded-Name": "Alice Example",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
auth.registerRequestClientAddress(request, "10.10.42.9");
|
||||||
|
|
||||||
|
const principal = await auth.require(request);
|
||||||
|
expect(principal.kind).toBe("proxy");
|
||||||
|
if (principal.kind === "proxy") {
|
||||||
|
expect(principal.user.subject).toBe("proxy:alice");
|
||||||
|
expect(principal.user.role).toBe("owner");
|
||||||
|
expect(principal.profile.name).toBe("Alice Example");
|
||||||
|
expect(principal.profile.email).toBe("alice@example.com");
|
||||||
|
expect(principal.profile.username).toBe("alice");
|
||||||
|
}
|
||||||
|
expect(auth.getHeadscaleApiKey(principal)).toBe("configured-headscale-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses localhost CIDRs by default when no allowed CIDRs are configured", async () => {
|
||||||
|
const { auth } = createTestAuth({
|
||||||
|
headscaleApiKey: "configured-headscale-key",
|
||||||
|
proxyAuth: { enabled: true },
|
||||||
|
});
|
||||||
|
const request = new Request("http://localhost/test", {
|
||||||
|
headers: { "Remote-User": "alice" },
|
||||||
|
});
|
||||||
|
|
||||||
|
auth.registerRequestClientAddress(request, "::ffff:127.0.0.1");
|
||||||
|
|
||||||
|
const principal = await auth.require(request);
|
||||||
|
expect(principal.kind).toBe("proxy");
|
||||||
|
expect(auth.getHeadscaleApiKey(principal)).toBe("configured-headscale-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to normal session auth outside allowed CIDRs", async () => {
|
||||||
|
const { auth } = createTestAuth({
|
||||||
|
headscaleApiKey: "configured-headscale-key",
|
||||||
|
proxyAuth: { enabled: true, allowedCidrs: ["10.10.0.0/16"] },
|
||||||
|
});
|
||||||
|
const request = new Request("http://localhost/test");
|
||||||
|
|
||||||
|
auth.registerRequestClientAddress(request, "10.11.42.9");
|
||||||
|
|
||||||
|
await expect(auth.require(request)).rejects.toThrow("No session cookie found");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can check allowed CIDRs against a forwarded IP from a trusted proxy", async () => {
|
||||||
|
const { auth } = createTestAuth({
|
||||||
|
headscaleApiKey: "configured-headscale-key",
|
||||||
|
proxyAuth: {
|
||||||
|
enabled: true,
|
||||||
|
allowedCidrs: ["203.0.113.0/24"],
|
||||||
|
trustedProxyCidrs: ["10.0.0.0/24"],
|
||||||
|
ipHeader: "X-Forwarded-For",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const request = new Request("http://localhost/test", {
|
||||||
|
headers: {
|
||||||
|
"Remote-User": "alice",
|
||||||
|
"X-Forwarded-For": "203.0.113.42, 10.0.0.10",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
auth.registerRequestClientAddress(request, "10.0.0.10");
|
||||||
|
|
||||||
|
const principal = await auth.require(request);
|
||||||
|
expect(principal.kind).toBe("proxy");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not trust forwarded IP headers from untrusted direct peers", async () => {
|
||||||
|
const { auth } = createTestAuth({
|
||||||
|
headscaleApiKey: "configured-headscale-key",
|
||||||
|
proxyAuth: {
|
||||||
|
enabled: true,
|
||||||
|
allowedCidrs: ["203.0.113.0/24"],
|
||||||
|
trustedProxyCidrs: ["10.0.0.0/24"],
|
||||||
|
ipHeader: "X-Real-IP",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const request = new Request("http://localhost/test", {
|
||||||
|
headers: {
|
||||||
|
"Remote-User": "alice",
|
||||||
|
"X-Real-IP": "203.0.113.42",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
auth.registerRequestClientAddress(request, "198.51.100.10");
|
||||||
|
|
||||||
|
await expect(auth.require(request)).rejects.toThrow("No session cookie found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("authorization", () => {
|
describe("authorization", () => {
|
||||||
let auth: AuthService;
|
let auth: AuthService;
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,28 @@ import { migrate } from "drizzle-orm/node-sqlite/migrator";
|
|||||||
|
|
||||||
import { createAuthService } from "~/server/web/auth";
|
import { createAuthService } from "~/server/web/auth";
|
||||||
|
|
||||||
export function createTestAuth() {
|
export function createTestAuth(
|
||||||
|
options: {
|
||||||
|
headscaleApiKey?: string;
|
||||||
|
proxyAuth?: {
|
||||||
|
enabled: boolean;
|
||||||
|
allowedCidrs?: string[];
|
||||||
|
trustedProxyCidrs?: string[];
|
||||||
|
ipHeader?: string;
|
||||||
|
userHeader?: string;
|
||||||
|
emailHeader?: string;
|
||||||
|
nameHeader?: string;
|
||||||
|
pictureHeader?: string;
|
||||||
|
};
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
const db = drizzle(":memory:");
|
const db = drizzle(":memory:");
|
||||||
migrate(db, { migrationsFolder: "./drizzle" });
|
migrate(db, { migrationsFolder: "./drizzle" });
|
||||||
|
|
||||||
const auth = createAuthService({
|
const auth = createAuthService({
|
||||||
secret: "test-secret-key-for-unit-tests",
|
secret: "test-secret-key-for-unit-tests",
|
||||||
|
headscaleApiKey: options.headscaleApiKey,
|
||||||
|
proxyAuth: options.proxyAuth,
|
||||||
db,
|
db,
|
||||||
cookie: {
|
cookie: {
|
||||||
name: "_hp_test",
|
name: "_hp_test",
|
||||||
|
|||||||
Reference in New Issue
Block a user