diff --git a/README.md b/README.md index 0aeeb95a..6705caaa 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ View Video: https://youtu.be/AWAlOQeNpgU?t=48 - ⌨️ Interactive Editor for `compose.yaml` - 🦦 Interactive Web Terminal - 🕷️ (1.4.0 🆕) Multiple agents support - You can manage multiple stacks from different Docker hosts in one single interface +- 🔐 **Proxy Authentication** - Integrate with identity providers like Authentik, Authelia, OAuth2 Proxy, and more - 🏪 Convert `docker run ...` commands into `compose.yaml` - 📙 File based structure - Dockge won't kidnap your compose files, they are stored on your drive as usual. You can interact with them using normal `docker compose` commands @@ -126,6 +127,109 @@ If you want to translate Dockge into your language, please read [Translation Gui Be sure to read the [guide](https://github.com/louislam/dockge/blob/master/CONTRIBUTING.md), as we don't accept all types of pull requests and don't want to waste your time. +## 🔐 Proxy Authentication + +Dockge supports authentication via HTTP headers set by reverse proxies. This allows you to integrate with identity providers like **Authentik**, **Authelia**, **OAuth2 Proxy**, **Keycloak**, and others. + +### Configuration + +| Environment Variable | Description | Default | +|---------------------|-------------|---------| +| `DOCKGE_AUTH_PROXY_HEADER` | The HTTP header containing the authenticated username | *(disabled)* | +| `DOCKGE_AUTH_PROXY_AUTO_CREATE` | Automatically create users on first login | `false` | +| `DOCKGE_AUTH_PROXY_LOGOUT_URL` | URL to redirect to when user logs out | *(none)* | + +### Supported Headers + +The header name is configurable. Common headers used by identity providers: + +| Provider | Header Name | +|----------|-------------| +| Authelia | `Remote-User` | +| Authentik | `X-authentik-username` | +| OAuth2 Proxy | `X-Auth-Request-User` | +| Traefik Forward Auth | `X-Forwarded-User` | + +### Example: Authentik + +```yaml +services: + dockge: + image: louislam/dockge:1 + restart: unless-stopped + ports: + - 5001:5001 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./data:/app/data + - /opt/stacks:/opt/stacks + environment: + - DOCKGE_STACKS_DIR=/opt/stacks + - DOCKGE_AUTH_PROXY_HEADER=X-authentik-username + - DOCKGE_AUTH_PROXY_AUTO_CREATE=true + - DOCKGE_AUTH_PROXY_LOGOUT_URL=https://auth.example.com/outpost.goauthentik.io/sign_out +``` + +### Example: Authelia + +```yaml +services: + dockge: + image: louislam/dockge:1 + restart: unless-stopped + ports: + - 5001:5001 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./data:/app/data + - /opt/stacks:/opt/stacks + environment: + - DOCKGE_STACKS_DIR=/opt/stacks + - DOCKGE_AUTH_PROXY_HEADER=Remote-User + - DOCKGE_AUTH_PROXY_AUTO_CREATE=true + - DOCKGE_AUTH_PROXY_LOGOUT_URL=https://auth.example.com/logout +``` + +### Example: Traefik with Forward Auth + +```yaml +services: + dockge: + image: louislam/dockge:1 + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./data:/app/data + - /opt/stacks:/opt/stacks + environment: + - DOCKGE_STACKS_DIR=/opt/stacks + - DOCKGE_AUTH_PROXY_HEADER=X-Forwarded-User + - DOCKGE_AUTH_PROXY_AUTO_CREATE=true + labels: + - "traefik.enable=true" + - "traefik.http.routers.dockge.rule=Host(`dockge.example.com`)" + - "traefik.http.routers.dockge.middlewares=authentik@docker" +``` + +### Security Considerations + +⚠️ **Important**: When using proxy authentication, ensure that: + +1. **Direct access is blocked** - Users should only access Dockge through your reverse proxy. The proxy header can be spoofed if users can connect directly. +2. **Your reverse proxy strips incoming auth headers** - Prevent users from injecting fake authentication headers. +3. **Use HTTPS** - Always use TLS between clients and your reverse proxy. + +### First-Time Setup with Proxy Auth + +When `DOCKGE_AUTH_PROXY_AUTO_CREATE=true`: +- The first user to authenticate via the proxy becomes an admin +- No manual setup is required +- Users are automatically created when they first log in + +When `DOCKGE_AUTH_PROXY_AUTO_CREATE=false`: +- Users must be manually created in the database before they can log in +- Useful for restricting access to pre-approved users only + ## FAQ #### "Dockge"? diff --git a/backend/dockge-server.ts b/backend/dockge-server.ts index 730b69f1..23534db2 100644 --- a/backend/dockge-server.ts +++ b/backend/dockge-server.ts @@ -37,6 +37,7 @@ import { AgentSocketHandler } from "./agent-socket-handler"; import { AgentSocket } from "../common/agent-socket"; import { ManageAgentSocketHandler } from "./socket-handlers/manage-agent-socket-handler"; import { Terminal } from "./terminal"; +import { ProxyAuth } from "./proxy-auth"; export class DockgeServer { app : Express; @@ -141,7 +142,20 @@ export class DockgeServer { type: Boolean, optional: true, defaultValue: false, - } + }, + authProxyHeader: { + type: String, + optional: true, + }, + authProxyAutoCreate: { + type: Boolean, + optional: true, + defaultValue: false, + }, + authProxyLogoutUrl: { + type: String, + optional: true, + }, }); this.config = args as Config; @@ -155,6 +169,22 @@ export class DockgeServer { this.config.dataDir = args.dataDir || process.env.DOCKGE_DATA_DIR || "./data/"; this.config.stacksDir = args.stacksDir || process.env.DOCKGE_STACKS_DIR || defaultStacksDir; this.config.enableConsole = args.enableConsole || process.env.DOCKGE_ENABLE_CONSOLE === "true" || false; + + // Proxy authentication configuration + this.config.authProxyHeader = args.authProxyHeader || process.env.DOCKGE_AUTH_PROXY_HEADER || undefined; + this.config.authProxyAutoCreate = args.authProxyAutoCreate || process.env.DOCKGE_AUTH_PROXY_AUTO_CREATE === "true" || false; + this.config.authProxyLogoutUrl = args.authProxyLogoutUrl || process.env.DOCKGE_AUTH_PROXY_LOGOUT_URL || undefined; + + if (this.config.authProxyHeader) { + log.info("server", `Proxy authentication enabled using header: ${this.config.authProxyHeader}`); + if (this.config.authProxyAutoCreate) { + log.info("server", "Proxy authentication auto-create users: enabled"); + } + if (this.config.authProxyLogoutUrl) { + log.info("server", `Proxy authentication logout URL: ${this.config.authProxyLogoutUrl}`); + } + } + this.stacksDir = this.config.stacksDir; log.debug("server", this.config); @@ -274,11 +304,6 @@ export class DockgeServer { this.sendInfo(dockgeSocket, true); - if (this.needSetup) { - log.info("server", "Redirect to setup page"); - dockgeSocket.emit("setup"); - } - // Create socket handlers (original, no agent support) for (const socketHandler of this.socketHandlerList) { socketHandler.create(dockgeSocket, this); @@ -300,7 +325,56 @@ export class DockgeServer { // *************************** log.debug("auth", "check auto login"); - if (await Settings.get("disableAuth")) { + + // Check for proxy authentication first (before setup page redirect) + if (this.config.authProxyHeader) { + const proxyUser = await ProxyAuth.authenticate( + socket.request.headers, + this.config.authProxyHeader, + this.config.authProxyAutoCreate + ); + + if (proxyUser) { + log.info("auth", `Proxy auth successful for user: ${proxyUser.username}`); + + // If this was the first user created via proxy auth, clear the needSetup flag + if (this.needSetup) { + log.info("auth", "First user created via proxy auth, clearing setup flag"); + this.needSetup = false; + } + + await this.afterLogin(dockgeSocket, proxyUser); + dockgeSocket.emit("proxyAuthLogin", { + ok: true, + username: proxyUser.username, + logoutUrl: this.config.authProxyLogoutUrl, + }); + } else { + // Proxy auth is enabled but no valid user found + // This could be: no header present, or user doesn't exist and auto-create is off + const headerValue = socket.request.headers[this.config.authProxyHeader.toLowerCase()]; + + if (headerValue) { + // Header is present but user doesn't exist and auto-create is disabled + log.warn("auth", `Proxy auth header present but user not found and auto-create is disabled. Header: ${this.config.authProxyHeader}, Value: ${headerValue}`); + dockgeSocket.emit("proxyAuthError", { + ok: false, + msg: "User not found. Please contact your administrator to create your account, or enable DOCKGE_AUTH_PROXY_AUTO_CREATE.", + }); + } else { + // No proxy auth header present - user is accessing directly without going through proxy + log.warn("auth", `Proxy auth enabled but no header found. Expected header: ${this.config.authProxyHeader}`); + dockgeSocket.emit("proxyAuthError", { + ok: false, + msg: "Proxy authentication is required. Please access Dockge through your identity provider.", + }); + } + } + } else if (this.needSetup) { + // Only redirect to setup if proxy auth is not enabled + log.info("server", "Redirect to setup page"); + dockgeSocket.emit("setup"); + } else if (await Settings.get("disableAuth")) { log.info("auth", "Disabled Auth: auto login to admin"); this.afterLogin(dockgeSocket, await R.findOne("user") as User); dockgeSocket.emit("autoLogin"); @@ -439,6 +513,9 @@ export class DockgeServer { latestVersion: latestVersionProperty, isContainer, primaryHostname: await Settings.get("primaryHostname"), + // Proxy auth info for frontend + proxyAuthEnabled: !!this.config.authProxyHeader, + proxyAuthLogoutUrl: this.config.authProxyLogoutUrl, //serverTimezone: await this.getTimezone(), //serverTimezoneOffset: this.getTimezoneOffset(), }); diff --git a/backend/proxy-auth.ts b/backend/proxy-auth.ts new file mode 100644 index 00000000..38406b95 --- /dev/null +++ b/backend/proxy-auth.ts @@ -0,0 +1,186 @@ +import { R } from "redbean-node"; +import { log } from "./log"; +import { generatePasswordHash } from "./password-hash"; +import { genSecret } from "../common/util-common"; +import { User } from "./models/user"; +import { IncomingHttpHeaders } from "http"; + +/** + * Proxy Authentication Handler + * + * Supports authentication via HTTP headers set by reverse proxies like: + * - Authentik (X-authentik-username) + * - Authelia (Remote-User) + * - OAuth2 Proxy (X-Auth-Request-User) + * - Traefik Forward Auth (X-Forwarded-User) + * + * Common header names: + * - Remote-User + * - X-Forwarded-User + * - X-Auth-Request-User + * - X-authentik-username + */ + +export class ProxyAuth { + /** + * Extract username from request headers based on configured header name + * @param headers HTTP headers from the request + * @param headerName The header name to look for (case-insensitive) + * @returns The username if found, null otherwise + */ + static extractUsername(headers: IncomingHttpHeaders, headerName: string): string | null { + if (!headerName) { + return null; + } + + // Headers are lowercased by Node.js + const normalizedHeader = headerName.toLowerCase(); + const value = headers[normalizedHeader]; + + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + + // Handle array case (multiple headers with same name) + if (Array.isArray(value) && value.length > 0 && value[0].trim()) { + return value[0].trim(); + } + + return null; + } + + /** + * Find an existing user by username + * @param username Username to search for + * @returns User if found, null otherwise + */ + static async findUser(username: string): Promise { + const user = await R.findOne("user", " username = ? AND active = 1 ", [ + username, + ]) as User | null; + + return user; + } + + /** + * Create a new user for proxy authentication + * The user will have a random password since they authenticate via proxy + * @param username Username to create + * @returns The newly created user + */ + static async createUser(username: string): Promise { + log.info("proxy-auth", `Auto-creating user: ${username}`); + + const user = R.dispense("user") as User; + user.username = username; + // Generate a random secure password - user won't need it since they auth via proxy + user.password = generatePasswordHash(genSecret(32)); + user.active = true; + // Mark as proxy-authenticated user (optional field for future use) + await R.store(user); + + log.info("proxy-auth", `User created successfully: ${username} (ID: ${user.id})`); + return user; + } + + /** + * Authenticate a user via proxy headers + * Will find existing user or create new one if autoCreate is enabled + * @param headers HTTP headers from the request + * @param headerName The header name to look for + * @param autoCreate Whether to auto-create users that don't exist + * @returns User if authenticated, null otherwise + */ + static async authenticate( + headers: IncomingHttpHeaders, + headerName: string, + autoCreate: boolean + ): Promise { + const username = this.extractUsername(headers, headerName); + + if (!username) { + log.debug("proxy-auth", `No username found in header: ${headerName}`); + return null; + } + + log.info("proxy-auth", `Proxy auth attempt for user: ${username}`); + + // Try to find existing user + let user = await this.findUser(username); + + if (user) { + log.info("proxy-auth", `Found existing user: ${username}`); + return user; + } + + // User doesn't exist + if (autoCreate) { + user = await this.createUser(username); + return user; + } + + log.warn("proxy-auth", `User not found and auto-create disabled: ${username}`); + return null; + } + + /** + * Get additional user info from common proxy headers + * @param headers HTTP headers from the request + * @returns Object containing email, name, groups if available + */ + static extractUserInfo(headers: IncomingHttpHeaders): { + email?: string; + name?: string; + groups?: string[]; + } { + const info: { email?: string; name?: string; groups?: string[] } = {}; + + // Common email headers + const emailHeaders = [ + "x-forwarded-email", + "x-auth-request-email", + "x-authentik-email", + "remote-email", + ]; + for (const header of emailHeaders) { + const value = headers[header]; + if (typeof value === "string" && value.trim()) { + info.email = value.trim(); + break; + } + } + + // Common name headers + const nameHeaders = [ + "x-forwarded-preferred-username", + "x-auth-request-preferred-username", + "x-authentik-name", + "remote-name", + ]; + for (const header of nameHeaders) { + const value = headers[header]; + if (typeof value === "string" && value.trim()) { + info.name = value.trim(); + break; + } + } + + // Common groups headers + const groupsHeaders = [ + "x-forwarded-groups", + "x-auth-request-groups", + "x-authentik-groups", + "remote-groups", + ]; + for (const header of groupsHeaders) { + const value = headers[header]; + if (typeof value === "string" && value.trim()) { + info.groups = value.split(",").map(g => g.trim()).filter(g => g); + break; + } + } + + return info; + } +} + diff --git a/backend/util-server.ts b/backend/util-server.ts index de4aecd6..8fd490a0 100644 --- a/backend/util-server.ts +++ b/backend/util-server.ts @@ -31,12 +31,20 @@ export interface Arguments { dataDir? : string; stacksDir? : string; enableConsole? : boolean; + // Proxy authentication options + authProxyHeader? : string; + authProxyAutoCreate? : boolean; + authProxyLogoutUrl? : string; } // Some config values are required export interface Config extends Arguments { dataDir : string; stacksDir : string; + // Proxy authentication - undefined means disabled + authProxyHeader? : string; + authProxyAutoCreate : boolean; + authProxyLogoutUrl? : string; } export function checkLogin(socket : DockgeSocket) { diff --git a/compose.yaml b/compose.yaml index b2b7bdb0..211f5d95 100644 --- a/compose.yaml +++ b/compose.yaml @@ -20,3 +20,9 @@ services: environment: # Tell Dockge where is your stacks directory - DOCKGE_STACKS_DIR=/opt/stacks + + # Proxy Authentication (for use with Authentik, Authelia, etc.) + # Uncomment and configure these to enable header-based authentication + # - DOCKGE_AUTH_PROXY_HEADER=Remote-User + # - DOCKGE_AUTH_PROXY_AUTO_CREATE=true + # - DOCKGE_AUTH_PROXY_LOGOUT_URL=https://auth.example.com/logout \ No newline at end of file diff --git a/frontend/src/components/Login.vue b/frontend/src/components/Login.vue index 9667d7a7..f02c1bdb 100644 --- a/frontend/src/components/Login.vue +++ b/frontend/src/components/Login.vue @@ -1,7 +1,19 @@