feat(auth): support reverse-proxy driven proxy auth

Closes HP-353.
This commit is contained in:
Aarnav Tale
2026-06-20 11:40:46 -04:00
parent c7822e3ec2
commit 96f2721272
24 changed files with 837 additions and 75 deletions
+14 -13
View File
@@ -4,6 +4,7 @@ import { ErrorBanner } from "~/components/error-banner";
import StatusBanner from "~/components/status-banner";
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import log from "~/utils/log";
@@ -33,16 +34,15 @@ export async function loader({ request, context }: Route.LoaderArgs) {
try {
const { principal, api } = await context.apiForRequest(request);
const user =
principal.kind === "oidc"
? {
email: principal.profile.email,
name: principal.profile.name,
picture: principal.profile.picture,
subject: principal.user.subject,
username: principal.profile.username,
}
: { name: principal.displayName, subject: "api_key" };
const user = isUserPrincipal(principal)
? {
email: principal.profile.email,
name: principal.profile.name,
picture: principal.profile.picture,
subject: principal.user.subject,
username: principal.profile.username,
}
: { name: principal.displayName, subject: "api_key" };
// MARK: The session should stay valid if Headscale isn't healthy
const isHealthy = await context.headscale.health();
@@ -51,8 +51,9 @@ export async function loader({ request, context }: Route.LoaderArgs) {
await api.apiKeys.list();
} catch (error) {
if (isDataUnauthorizedError(error)) {
const displayName =
principal.kind === "oidc" ? principal.profile.name : principal.displayName;
const displayName = isUserPrincipal(principal)
? principal.profile.name
: principal.displayName;
log.warn("auth", "Logging out %s due to expired API key", displayName);
return redirect("/login", {
headers: {
@@ -64,7 +65,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// Self-heal: if the linked Headscale user was deleted, clear the
// stale link so the user gets prompted to re-link.
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
if (isUserPrincipal(principal) && principal.user.headscaleUserId) {
try {
const usersSnap = await context.hsLive.get(usersResource, api);
if (!usersSnap.data.some((u) => u.id === principal.user.headscaleUserId)) {
+6 -9
View File
@@ -11,6 +11,7 @@ import CodeBlock from "~/components/code-block";
import Link from "~/components/link";
import LinkAccount from "~/layout/link-account";
import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import { getUserDisplayName } from "~/utils/user";
@@ -20,14 +21,10 @@ import type { Route } from "./+types/home";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
// If the OIDC user has no linked Headscale user, check for
// Unclaimed users they can pick from before anything else.
// If the signed-in Headplane user has no linked Headscale user,
// check for unclaimed users they can pick from before anything else.
let unlinked = false;
if (
context.oidc.state === "enabled" &&
principal.kind === "oidc" &&
!principal.user.headscaleUserId
) {
if (isUserPrincipal(principal) && !principal.user.headscaleUserId) {
const { api } = await context.apiForRequest(request);
let headscaleUsers: { id: string; name: string }[] = [];
@@ -66,7 +63,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const { api } = await context.apiForRequest(request);
let linkedUserName: string | undefined;
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
if (isUserPrincipal(principal) && principal.user.headscaleUserId) {
try {
const usersSnap = await context.hsLive.get(usersResource, api);
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) {
const principal = await context.auth.require(request);
if (principal.kind !== "oidc") {
if (!isUserPrincipal(principal)) {
return redirect("/");
}
+2 -1
View File
@@ -8,6 +8,7 @@ import Link from "~/components/link";
import PageError from "~/components/page-error";
import Tooltip from "~/components/tooltip";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import { mapNodes, sortAssignableTags, type PopulatedNode } from "~/utils/node-info";
@@ -64,7 +65,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
nodeKey: agents?.agentNodeKey(),
}
: undefined,
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
headscaleUserId: isUserPrincipal(principal) ? principal.user.headscaleUserId : undefined,
existingTags: sortAssignableTags(nodes, policy),
magic,
nodes,
+5 -1
View File
@@ -1,5 +1,6 @@
import { data } from "react-router";
import { isUserPrincipal } from "~/server/web/auth";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
@@ -25,7 +26,10 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
throw data("User not found.", { status: 404 });
}
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", {
status: 403,
});
@@ -18,10 +18,22 @@ interface AddAuthKeyProps {
users: User[];
url: string;
selfServiceOnly: boolean;
currentHeadscaleUserId?: 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) {
return undefined;
}
@@ -38,6 +50,7 @@ export default function AddAuthKey({
users,
url,
selfServiceOnly,
currentHeadscaleUserId,
currentSubject,
}: AddAuthKeyProps) {
const fetcher = useFetcher();
@@ -46,7 +59,9 @@ export default function AddAuthKey({
const [reusable, setReusable] = useState(false);
const [ephemeral, setEphemeral] = 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 [userId, setUserId] = useState<string | null>(availableUsers[0]?.id);
const [tags, setTags] = useState("");
+14 -2
View File
@@ -7,6 +7,7 @@ import Notice from "~/components/notice";
import Select from "~/components/select";
import TableList from "~/components/table-list";
import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
import type { User } from "~/types/User";
@@ -90,7 +91,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return {
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,
missing,
selfServiceOnly: !canGenerateAny && canGenerateOwn,
@@ -103,7 +105,16 @@ export const action = authKeysAction;
type Status = "all" | "active" | "expired" | "reusable" | "ephemeral";
export default function Page({
loaderData: { keys, missing, users, url, access, selfServiceOnly, currentSubject },
loaderData: {
keys,
missing,
users,
url,
access,
selfServiceOnly,
currentHeadscaleUserId,
currentSubject,
},
}: Route.ComponentProps) {
const [selectedUser, setSelectedUser] = useState("__headplane_all");
const [status, setStatus] = useState<Status>("active");
@@ -199,6 +210,7 @@ export default function Page({
</Link>
</p>
<AddAuthKey
currentHeadscaleUserId={currentHeadscaleUserId}
currentSubject={currentSubject}
selfServiceOnly={selfServiceOnly}
url={url}
+3 -1
View File
@@ -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
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) {
throw data(sshErrors.user_not_linked, 404);
+3 -2
View File
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import PageError from "~/components/page-error";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities, Roles } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
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 {
writable: writablePermission,
currentUserId: principal.kind === "oidc" ? principal.user.id : undefined,
currentUserId: isUserPrincipal(principal) ? principal.user.id : undefined,
isOwner,
oidc: context.config.oidc ? { issuer: context.config.oidc.issuer } : undefined,
magic,
+2 -1
View File
@@ -1,6 +1,7 @@
import { data } from "react-router";
import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } 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" };
}
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 });
}
+4 -1
View File
@@ -63,5 +63,8 @@ export async function dispose(): Promise<void> {
export default createRequestListener({
build,
mode: import.meta.env.MODE,
getLoadContext: () => ctx,
getLoadContext: (request, client) => {
ctx.auth.registerRequestClientAddress(request, client.address);
return ctx;
},
});
+22
View File
@@ -48,6 +48,17 @@ const serverConfig = type({
// either is set, `cookie_secure` is forced to `true`.
tls_cert_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({
@@ -64,6 +75,17 @@ const partialServerConfig = type({
tls_cert_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({
+12
View File
@@ -40,6 +40,18 @@ export async function createAppContext(config: HeadplaneConfig) {
const auth = createAuthService({
secret: config.server.cookie_secret,
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,
cookie: {
name: "_hp_auth",
+1 -1
View File
@@ -32,7 +32,7 @@ export type HeadplaneUserInsert = typeof users.$inferInsert;
export const authSessions = sqliteTable("auth_sessions", {
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"),
api_key_hash: text("api_key_hash"),
api_key_display: text("api_key_display"),
+316 -36
View File
@@ -1,4 +1,5 @@
import { createHash, createHmac } from "node:crypto";
import { isIP } from "node:net";
import { eq, lt, sql } from "drizzle-orm";
import { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
@@ -17,23 +18,36 @@ export type Principal =
displayName: string;
apiKey: string;
}
| {
kind: "oidc";
sessionId: string;
idToken?: string;
user: {
id: string;
subject: string;
role: Role;
headscaleUserId: string | undefined;
};
profile: {
name: string;
email?: string;
username?: string;
picture?: string;
};
};
| UserPrincipal;
export type UserPrincipal = {
kind: "oidc" | "proxy";
sessionId: string;
idToken?: string;
user: {
id: string;
subject: string;
role: Role;
headscaleUserId: string | undefined;
};
profile: {
name: 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 {
sid: string;
@@ -48,6 +62,7 @@ interface CookiePayload {
export interface AuthServiceOptions {
secret: string;
headscaleApiKey?: string;
proxyAuth?: ProxyAuthOptions;
db: NodeSQLiteDatabase;
cookie: {
name: string;
@@ -58,6 +73,7 @@ export interface AuthServiceOptions {
}
export interface AuthService {
registerRequestClientAddress(request: Request, address: string | undefined): void;
require(request: Request): Promise<Principal>;
can(principal: Principal, capabilities: Capabilities): boolean;
canManageNode(principal: Principal, node: Machine): boolean;
@@ -88,8 +104,152 @@ export interface AuthService {
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 {
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;
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");
}
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> {
const proxyPrincipal = await resolveProxyAuthPrincipal(request);
if (proxyPrincipal) {
return proxyPrincipal;
}
const payload = await decodeCookie(request);
const [session] = await opts.db
@@ -175,30 +467,17 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
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);
if (!user) {
throw new Error("User record not found");
}
const role = (user.role in Roles ? user.role : "member") as Role;
return {
return resolveUserPrincipal({
kind: "oidc",
sessionId: session.id,
idToken: session.oidc_id_token ?? undefined,
user: {
id: user.id,
subject: user.sub,
role,
headscaleUserId: user.headscale_user_id ?? undefined,
},
userId: session.user_id,
profile: {
name: payload.profile?.name ?? user.name ?? user.sub,
email: payload.profile?.email ?? user.email ?? undefined,
name: payload.profile?.name,
email: payload.profile?.email,
username: payload.profile?.username,
picture: user.picture ?? undefined,
},
};
});
}
function require(request: Request): Promise<Principal> {
@@ -241,7 +520,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
}
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;
@@ -480,6 +759,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
}
return {
registerRequestClientAddress,
require: require,
can,
canManageNode,