diff --git a/create-a-container/migrations/20260408000000-add-http-service-auth-required.js b/create-a-container/migrations/20260408000000-add-http-service-auth-required.js new file mode 100644 index 00000000..bc126806 --- /dev/null +++ b/create-a-container/migrations/20260408000000-add-http-service-auth-required.js @@ -0,0 +1,16 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('HTTPServices', 'authRequired', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('HTTPServices', 'authRequired'); + } +}; diff --git a/create-a-container/migrations/20260408000001-add-external-domain-manager-url.js b/create-a-container/migrations/20260408000001-add-external-domain-manager-url.js new file mode 100644 index 00000000..c0bead47 --- /dev/null +++ b/create-a-container/migrations/20260408000001-add-external-domain-manager-url.js @@ -0,0 +1,16 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('ExternalDomains', 'authServer', { + type: Sequelize.STRING, + allowNull: true, + defaultValue: null + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('ExternalDomains', 'authServer'); + } +}; diff --git a/create-a-container/models/container.js b/create-a-container/models/container.js index baacf884..e0ae3f18 100644 --- a/create-a-container/models/container.js +++ b/create-a-container/models/container.js @@ -70,7 +70,13 @@ module.exports = (sequelize, DataTypes) => { Container.init({ hostname: { type: DataTypes.STRING(255), - allowNull: false + allowNull: false, + validate: { + is: { + args: /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/, + msg: 'Hostname must be 1–63 characters, only lowercase letters, digits, and hyphens, and must start and end with a letter or digit' + } + } }, username: { type: DataTypes.STRING(255), diff --git a/create-a-container/models/external-domain.js b/create-a-container/models/external-domain.js index 08005ead..ea24c340 100644 --- a/create-a-container/models/external-domain.js +++ b/create-a-container/models/external-domain.js @@ -52,6 +52,14 @@ module.exports = (sequelize) => { key: 'id' }, comment: 'Optional default site — when null, domain has no default site' + }, + authServer: { + type: DataTypes.STRING, + allowNull: true, + validate: { + isUrl: true + }, + comment: 'Auth server URL for auth_request (e.g., https://manager.example.com). Must implement GET /verify and GET /login?redirect=' } }, { tableName: 'ExternalDomains', diff --git a/create-a-container/models/http-service.js b/create-a-container/models/http-service.js index e83e506a..7d12e1c4 100644 --- a/create-a-container/models/http-service.js +++ b/create-a-container/models/http-service.js @@ -23,7 +23,10 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.STRING(255), allowNull: false, validate: { - notEmpty: true + is: { + args: /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/, + msg: 'Hostname must be 1–63 characters, only lowercase letters, digits, and hyphens, and must start and end with a letter or digit' + } } }, externalDomainId: { @@ -38,6 +41,11 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.ENUM('http', 'https'), allowNull: false, defaultValue: 'http' + }, + authRequired: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false } }, { sequelize, diff --git a/create-a-container/openapi.yaml b/create-a-container/openapi.yaml index 9268415d..8aeff31c 100644 --- a/create-a-container/openapi.yaml +++ b/create-a-container/openapi.yaml @@ -795,35 +795,60 @@ components: dnsName: type: string description: DNS name (required for `srv`) + authRequired: + type: boolean + default: false + description: | + When true, NGINX uses `auth_request` to verify authentication before + proxying requests. Requires the external domain to have an `authServer` + configured. Only applies to `http`/`https` services. ImageMetadata: type: object properties: - Ports: - type: object - additionalProperties: - type: object - description: 'Exposed ports (e.g. `{"80/tcp": {}}`)' - Env: - type: array - items: - type: string - description: Environment variables (e.g. `["PATH=/usr/bin"]`) - Entrypoint: + ports: type: array items: - type: string - nullable: true - Cmd: + type: object + properties: + port: + type: integer + protocol: + type: string + enum: [tcp, udp] + description: Non-HTTP exposed ports from EXPOSE directives + httpServices: type: array items: - type: string - nullable: true - ExposedPorts: + type: object + required: [port] + properties: + port: + type: integer + description: Internal port number + hostnameSuffix: + type: string + description: | + When set, the external hostname is built as `-`. + From label `org.mieweb.opensource-server.services.http..hostnameSuffix`. + requireAuth: + type: boolean + description: | + Enable authentication via `auth_request` for this service. + From label `org.mieweb.opensource-server.services.http..requireAuth`. + description: | + HTTP services derived from OCI labels. Supports the legacy + `org.mieweb.opensource-server.services.http.default-port` label and named + labels: `...http..port`, `...http..hostnameSuffix`, + `...http..requireAuth`. + env: type: object additionalProperties: - type: object - nullable: true + type: string + description: Environment variables (excluding common ones like PATH, HOME) + entrypoint: + type: string + description: Container entrypoint command ExternalDomain: type: object @@ -833,6 +858,15 @@ components: name: type: string description: Domain name (e.g. `example.com`) + authServer: + type: string + nullable: true + description: | + URL of an auth server for this domain (e.g. `https://manager.example.com`). + Must support the NGINX `auth_request` protocol: `GET /verify` (return 2xx/401) + and `GET /login?redirect=` for unauthenticated users. Used for HTTP services + that have `authRequired: true`. Must be on a subdomain of this domain for + session cookie sharing. Job: type: object diff --git a/create-a-container/package-lock.json b/create-a-container/package-lock.json index 60958288..a4b5093d 100644 --- a/create-a-container/package-lock.json +++ b/create-a-container/package-lock.json @@ -12,7 +12,7 @@ "ejs": "^3.1.10", "express": "^5.2.1", "express-rate-limit": "^8.1.1", - "express-session": "^1.18.2", + "express-session": "^1.19.0", "express-session-sequelize": "^2.3.0", "method-override": "^3.0.0", "morgan": "^1.10.1", @@ -904,21 +904,26 @@ } }, "node_modules/express-session": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", - "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "license": "MIT", "dependencies": { - "cookie": "0.7.2", - "cookie-signature": "1.0.7", - "debug": "2.6.9", + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", "depd": "~2.0.0", "on-headers": "~1.1.0", "parseurl": "~1.3.3", - "safe-buffer": "5.2.1", + "safe-buffer": "~5.2.1", "uid-safe": "~2.1.5" }, "engines": { "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express-session-sequelize": { diff --git a/create-a-container/package.json b/create-a-container/package.json index 418af141..3174730f 100644 --- a/create-a-container/package.json +++ b/create-a-container/package.json @@ -17,7 +17,7 @@ "ejs": "^3.1.10", "express": "^5.2.1", "express-rate-limit": "^8.1.1", - "express-session": "^1.18.2", + "express-session": "^1.19.0", "express-session-sequelize": "^2.3.0", "method-override": "^3.0.0", "morgan": "^1.10.1", diff --git a/create-a-container/routers/containers.js b/create-a-container/routers/containers.js index bcfa9db1..e0b59d7c 100644 --- a/create-a-container/routers/containers.js +++ b/create-a-container/routers/containers.js @@ -8,7 +8,6 @@ const serviceMap = require('../data/services.json'); const { isApiRequest } = require('../utils/http'); const { parseDockerRef, getImageConfig, extractImageMetadata } = require('../utils/docker-registry'); const { manageDnsRecords } = require('../utils/cloudflare-dns'); -const { isValidHostname } = require('../utils'); /** * Normalize a Docker image reference to full format: host/org/image:tag @@ -181,12 +180,17 @@ router.get('/', requireAuth, async (req, res) => { const services = c.services || []; const ssh = services.find(s => s.type === 'transport' && s.transportService?.protocol === 'tcp' && Number(s.internalPort) === 22); const sshPort = ssh?.transportService?.externalPort || null; - const http = services.find(s => s.type === 'http'); - const httpPort = http ? http.internalPort : null; - const httpExternalHost = http?.httpService?.externalHostname && http?.httpService?.externalDomain?.name - ? `${http.httpService.externalHostname}.${http.httpService.externalDomain.name}` - : null; - const httpExternalUrl = httpExternalHost ? `https://${httpExternalHost}` : null; + const httpList = services.filter(s => s.type === 'http'); + const httpEntries = httpList.map(s => { + const host = s.httpService?.externalHostname && s.httpService?.externalDomain?.name + ? `${s.httpService.externalHostname}.${s.httpService.externalDomain.name}` + : null; + return { + port: s.internalPort, + externalUrl: host ? `https://${host}` : null + }; + }); + const primaryHttp = httpEntries[0] || null; // Common object structure for both API and View return { @@ -200,9 +204,10 @@ router.get('/', requireAuth, async (req, res) => { template: c.template, creationJobId: c.creationJobId, sshPort, - sshHost: httpExternalHost || site.externalIp, - httpPort, - httpExternalUrl, + sshHost: primaryHttp?.externalUrl ? new URL(primaryHttp.externalUrl).hostname : site.externalIp, + httpEntries, + httpPort: primaryHttp?.port || null, + httpExternalUrl: primaryHttp?.externalUrl || null, nodeName: c.node ? c.node.name : '-', nodeApiUrl: c.node ? c.node.apiUrl : null, createdAt: c.createdAt @@ -301,12 +306,6 @@ router.post('/', async (req, res) => { } // --------------------------- - // Hostname must be lowercase before validation - if (hostname) hostname = hostname.trim().toLowerCase(); - if (!isValidHostname(hostname)) { - throw new Error('Invalid hostname: must be 1–63 characters, only lowercase letters, digits, and hyphens, and must start and end with a letter or digit'); - } - const currentUser = req.session?.user || req.user?.username || 'api-user'; let envVarsJson = null; @@ -380,7 +379,7 @@ router.post('/', async (req, res) => { if (services && typeof services === 'object') { for (const key in services) { const service = services[key]; - const { type, internalPort, externalHostname, externalDomainId, dnsName } = service; + const { type, internalPort, externalHostname, externalDomainId, dnsName, authRequired } = service; if (!type || !internalPort) continue; @@ -412,7 +411,8 @@ router.post('/', async (req, res) => { serviceId: createdService.id, externalHostname, externalDomainId: parseInt(externalDomainId, 10), - backendProtocol: type === 'https' ? 'https' : 'http' + backendProtocol: type === 'https' ? 'https' : 'http', + authRequired: authRequired === 'true' || authRequired === true }, { transaction: t }); } else if (serviceType === 'dns') { if (!dnsName) throw new Error('DNS services must have a DNS name'); @@ -576,10 +576,27 @@ router.put('/:id', requireAuth, async (req, res) => { }); } } + + // Update authRequired on existing HTTP services + for (const key in services) { + const { id, deleted, authRequired } = services[key]; + if (deleted === 'true' || !id) continue; + const svc = await Service.findByPk(parseInt(id, 10), { + include: [{ model: HTTPService, as: 'httpService' }], + transaction: t + }); + if (svc?.httpService) { + const newAuthRequired = authRequired === 'true' || authRequired === true; + if (svc.httpService.authRequired !== newAuthRequired) { + await svc.httpService.update({ authRequired: newAuthRequired }, { transaction: t }); + } + } + } + // Create new services — collect cross-site HTTP services for DNS creation const newHttpServices = []; for (const key in services) { - const { id, deleted, type, internalPort, externalHostname, externalDomainId, dnsName } = services[key]; + const { id, deleted, type, internalPort, externalHostname, externalDomainId, dnsName, authRequired } = services[key]; if (deleted === 'true' || id || !type || !internalPort) continue; let serviceType = type === 'srv' ? 'dns' : ((type === 'http' || type === 'https') ? 'http' : 'transport'); @@ -592,7 +609,7 @@ router.put('/:id', requireAuth, async (req, res) => { }, { transaction: t }); if (serviceType === 'http') { - await HTTPService.create({ serviceId: createdService.id, externalHostname, externalDomainId, backendProtocol: type === 'https' ? 'https' : 'http' }, { transaction: t }); + await HTTPService.create({ serviceId: createdService.id, externalHostname, externalDomainId, backendProtocol: type === 'https' ? 'https' : 'http', authRequired: authRequired === 'true' || authRequired === true }, { transaction: t }); const domain = await ExternalDomain.findByPk(parseInt(externalDomainId, 10), { transaction: t }); if (domain) newHttpServices.push({ externalHostname, ExternalDomain: domain }); } else if (serviceType === 'dns') { diff --git a/create-a-container/routers/external-domains.js b/create-a-container/routers/external-domains.js index b6a81e6f..31f3b735 100644 --- a/create-a-container/routers/external-domains.js +++ b/create-a-container/routers/external-domains.js @@ -59,7 +59,7 @@ router.get('/:id/edit', async (req, res) => { // POST /external-domains router.post('/', async (req, res) => { try { - const { name, acmeEmail, acmeDirectoryUrl, cloudflareApiEmail, cloudflareApiKey, siteId } = req.body; + const { name, acmeEmail, acmeDirectoryUrl, cloudflareApiEmail, cloudflareApiKey, siteId, authServer } = req.body; await ExternalDomain.create({ name, @@ -67,7 +67,8 @@ router.post('/', async (req, res) => { acmeDirectoryUrl: acmeDirectoryUrl || null, cloudflareApiEmail: cloudflareApiEmail || null, cloudflareApiKey: cloudflareApiKey || null, - siteId: siteId || null + siteId: siteId || null, + authServer: authServer || null }); await req.flash('success', `External domain ${name} created successfully`); @@ -92,14 +93,15 @@ router.put('/:id', async (req, res) => { return res.redirect('/external-domains'); } - const { name, acmeEmail, acmeDirectoryUrl, cloudflareApiEmail, cloudflareApiKey, siteId } = req.body; + const { name, acmeEmail, acmeDirectoryUrl, cloudflareApiEmail, cloudflareApiKey, siteId, authServer } = req.body; const updateData = { name, acmeEmail: acmeEmail || null, acmeDirectoryUrl: acmeDirectoryUrl || null, cloudflareApiEmail: cloudflareApiEmail || null, - siteId: siteId || null + siteId: siteId || null, + authServer: authServer || null }; // Only update cloudflareApiKey if a new value was provided diff --git a/create-a-container/routers/login.js b/create-a-container/routers/login.js index e67f683b..7cf9319f 100644 --- a/create-a-container/routers/login.js +++ b/create-a-container/routers/login.js @@ -1,7 +1,7 @@ const express = require('express'); const router = express.Router(); -const { User, Setting } = require('../models'); -const { isSafeRelativeUrl } = require('../utils'); +const { User, Setting, ExternalDomain } = require('../models'); +const { isSafeRedirectUrl } = require('../utils'); // GET / - Display login form router.get('/', (req, res) => { @@ -101,8 +101,9 @@ router.post('/', async (req, res) => { // Return redirect to original page or default to home let redirectUrl = req.body.redirect || '/'; - if (!isSafeRelativeUrl(redirectUrl)) { - // ensure redirect is a relative path + const domains = await ExternalDomain.findAll({ attributes: ['name'] }); + const allowedDomains = domains.map(d => d.name); + if (!isSafeRedirectUrl(redirectUrl, allowedDomains)) { redirectUrl = '/'; } diff --git a/create-a-container/routers/verify.js b/create-a-container/routers/verify.js new file mode 100644 index 00000000..83356051 --- /dev/null +++ b/create-a-container/routers/verify.js @@ -0,0 +1,66 @@ +const express = require('express'); +const router = express.Router(); + +function setUserHeaders(res, user, groups) { + res.set('X-User-ID', String(user.uidNumber)); + res.set('X-Username', user.uid); + res.set('X-User-First-Name', user.givenName); + res.set('X-User-Last-Name', user.sn); + res.set('X-Email', user.mail); + res.set('X-Groups', groups.map(g => g.cn).join(',')); +} + +// GET /verify — lightweight auth check for nginx auth_request subrequests. +// Returns 200 with user identity headers if authenticated, 401 otherwise. +router.get('/', async (req, res) => { + const { ApiKey, User, Group } = require('../models'); + + // Check session authentication + if (req.session && req.session.user) { + const user = await User.findOne({ + where: { uid: req.session.user }, + include: [{ model: Group, as: 'groups' }] + }); + if (user) { + setUserHeaders(res, user, user.groups || []); + return res.status(200).send(); + } + } + + // Check Bearer token authentication + const authHeader = req.get('Authorization'); + if (authHeader && authHeader.startsWith('Bearer ')) { + const apiKey = authHeader.substring(7); + + if (apiKey) { + const { extractKeyPrefix } = require('../utils/apikey'); + + const keyPrefix = extractKeyPrefix(apiKey); + const apiKeys = await ApiKey.findAll({ + where: { keyPrefix }, + include: [{ + model: User, + as: 'user', + include: [{ model: Group, as: 'groups' }] + }] + }); + + for (const storedKey of apiKeys) { + const isValid = await storedKey.validateKey(apiKey); + if (isValid) { + storedKey.recordUsage().catch(err => { + console.error('Failed to update API key last used timestamp:', err); + }); + if (storedKey.user) { + setUserHeaders(res, storedKey.user, storedKey.user.groups || []); + } + return res.status(200).send(); + } + } + } + } + + return res.status(401).send(); +}); + +module.exports = router; diff --git a/create-a-container/server.js b/create-a-container/server.js index 1973c0e7..212ab62c 100644 --- a/create-a-container/server.js +++ b/create-a-container/server.js @@ -59,14 +59,26 @@ async function main() { db: sequelize, }); + const isProduction = process.env.NODE_ENV === 'production'; + app.use(session({ secret: await getSessionSecrets(), store: sessionStore, resave: false, saveUninitialized: false, - cookie: { - secure: process.env.NODE_ENV === 'production', // Only secure in production - maxAge: 24 * 60 * 60 * 1000 // 24 hours + // Dynamic cookie: drop the host part and set domain to the parent domain + // (e.g., manager.example.com → .example.com) so the session cookie is + // shared across sibling subdomains for nginx auth_request. + cookie: function(req) { + const hostname = req.hostname || ''; + const parts = hostname.split('.'); + const domain = parts.length >= 2 ? '.' + parts.slice(1).join('.') : undefined; + return { + secure: isProduction, + maxAge: 24 * 60 * 60 * 1000, // 24 hours + sameSite: 'lax', + domain + }; } })); @@ -131,6 +143,7 @@ async function main() { // --- Mount Routers --- const loginRouter = require('./routers/login'); const registerRouter = require('./routers/register'); + const verifyRouter = require('./routers/verify'); const usersRouter = require('./routers/users'); const groupsRouter = require('./routers/groups'); const sitesRouter = require('./routers/sites'); // Includes nested nodes and containers routers @@ -143,6 +156,7 @@ async function main() { app.use('/jobs', jobsRouter); app.use('/login', loginRouter); app.use('/register', registerRouter); + app.use('/verify', verifyRouter); app.use('/users', usersRouter); app.use('/groups', groupsRouter); app.use('/sites', sitesRouter); // /sites/:siteId/nodes and /sites/:siteId/containers routes nested here diff --git a/create-a-container/utils/docker-registry.js b/create-a-container/utils/docker-registry.js index 7afdc7b0..ef61bfe3 100644 --- a/create-a-container/utils/docker-registry.js +++ b/create-a-container/utils/docker-registry.js @@ -299,32 +299,55 @@ function extractImageMetadata(config) { entrypoint: '' }; - // Extract HTTP service from OCI labels first - // Label: org.mieweb.opensource-server.services.http.default-port - let httpServicePort = null; + const httpServicePorts = new Set(); + const LABEL_PREFIX = 'org.mieweb.opensource-server.services.http.'; + if (config.config?.Labels) { - const httpPortLabel = config.config.Labels['org.mieweb.opensource-server.services.http.default-port']; - if (httpPortLabel) { - const port = parseInt(httpPortLabel, 10); + const labels = config.config.Labels; + + // Legacy unnamed label: org.mieweb.opensource-server.services.http.default-port + const defaultPortLabel = labels[LABEL_PREFIX + 'default-port']; + if (defaultPortLabel) { + const port = parseInt(defaultPortLabel, 10); if (!isNaN(port) && port > 0 && port <= 65535) { - httpServicePort = port; - metadata.httpServices.push({ - port: port - }); + httpServicePorts.add(port); + metadata.httpServices.push({ port }); } } + + // Named labels: org.mieweb.opensource-server.services.http..port + const namedServices = {}; + for (const [key, value] of Object.entries(labels)) { + if (!key.startsWith(LABEL_PREFIX)) continue; + const rest = key.slice(LABEL_PREFIX.length); + const dotIdx = rest.indexOf('.'); + if (dotIdx === -1) continue; // not a named label (e.g. "default-port") + const name = rest.slice(0, dotIdx); + const field = rest.slice(dotIdx + 1); + if (!namedServices[name]) namedServices[name] = {}; + namedServices[name][field] = value; + } + + for (const [, fields] of Object.entries(namedServices)) { + if (!fields.port) continue; + const port = parseInt(fields.port, 10); + if (isNaN(port) || port <= 0 || port > 65535) continue; + httpServicePorts.add(port); + const svc = { port }; + if (fields.hostnameSuffix) svc.hostnameSuffix = fields.hostnameSuffix; + if (fields.requireAuth && /^(true|1|yes)$/i.test(fields.requireAuth)) svc.requireAuth = true; + metadata.httpServices.push(svc); + } } - // Extract exposed ports (excluding HTTP service port on TCP to avoid duplicates) + // Extract exposed ports (excluding HTTP service ports on TCP to avoid duplicates) // Format: { "80/tcp": {}, "443/tcp": {}, "8080/udp": {} } if (config.config?.ExposedPorts) { for (const portSpec of Object.keys(config.config.ExposedPorts)) { const [port, protocol = 'tcp'] = portSpec.split('/'); const portNum = parseInt(port, 10); - // Skip if this port is designated as an HTTP service AND it's TCP - // (HTTP runs over TCP, but keep UDP ports even if same number) - if (portNum === httpServicePort && protocol.toLowerCase() === 'tcp') { + if (httpServicePorts.has(portNum) && protocol.toLowerCase() === 'tcp') { continue; } diff --git a/create-a-container/utils/index.js b/create-a-container/utils/index.js index 65f53eaa..40ce6456 100644 --- a/create-a-container/utils/index.js +++ b/create-a-container/utils/index.js @@ -55,16 +55,6 @@ function getVersionInfo() { } } -/** - * Validate that a hostname is a legal DNS subdomain label (RFC 1123). - * @param {string} hostname - * @returns {boolean} - */ -function isValidHostname(hostname) { - if (typeof hostname !== 'string') return false; - return /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(hostname); -} - /** * Helper to validate that a redirect URL is a safe relative path. * @param {string} url - the URL to validate @@ -81,10 +71,36 @@ function isSafeRelativeUrl(url) { !url.includes('%2F%2E%2E%2F'); // basic check against encoded path traversal } +/** + * Validate that a redirect URL is safe — either a relative path or an absolute + * URL whose hostname is a subdomain of (or equal to) one of the allowed domains. + * Used by the login flow to support cross-domain redirects for auth_request. + * @param {string} url - the URL to validate + * @param {string[]} allowedDomains - list of allowed root domains (e.g., ['example.com']) + * @returns {boolean} + */ +function isSafeRedirectUrl(url, allowedDomains = []) { + if (typeof url !== 'string') return false; + if (isSafeRelativeUrl(url)) return true; + + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return false; + + const hostname = parsed.hostname.toLowerCase(); + return allowedDomains.some(domain => { + const d = domain.toLowerCase(); + return hostname === d || hostname.endsWith('.' + d); + }); + } catch { + return false; + } +} + module.exports = { ProxmoxApi, run, - isValidHostname, isSafeRelativeUrl, + isSafeRedirectUrl, getVersionInfo }; diff --git a/create-a-container/views/containers/form.ejs b/create-a-container/views/containers/form.ejs index 2df8358b..0d5cb730 100644 --- a/create-a-container/views/containers/form.ejs +++ b/create-a-container/views/containers/form.ejs @@ -43,6 +43,7 @@ const breadcrumbLabel = isEdit ? 'Edit' : 'New'; + +
+ + +
+ URL of an authentication server that supports the NGINX + auth_request + protocol. Must implement GET /verify (return 2xx if authenticated, 401 otherwise) + and GET /login?redirect=<url> for unauthenticated users. + The server must be on a subdomain of this external domain for session cookie sharing. +
+ +
+
Cancel
+ + <%- include('../layouts/footer') %> diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs index c96b907b..2dcb26b6 100644 --- a/create-a-container/views/nginx-conf.ejs +++ b/create-a-container/views/nginx-conf.ejs @@ -30,6 +30,10 @@ http { server_names_hash_bucket_size 128; + # Cache zone for auth_request subrequests, keyed on credentials. + proxy_cache_path /var/cache/nginx/auth_cache levels=1:2 keys_zone=auth_cache:1m + max_size=10m inactive=5m; + modsecurity on; modsecurity_rules_file /etc/nginx/modsecurity_includes.conf; modsecurity_transaction_id "$request_id"; @@ -52,6 +56,7 @@ http { location /403.html { } location /404.html { } location /502.html { } + location /auth-unavailable.html { } } server { @@ -175,6 +180,8 @@ http { } <%_ httpServices.forEach((service, index) => { _%> + <%_ const authRequired = service.httpService.authRequired; _%> + <%_ const authServer = service.httpService.externalDomain.authServer; _%> server { listen 443 ssl; listen [::]:443 ssl; @@ -226,8 +233,68 @@ http { proxy_pass http://error_pages; } + <%_ if (authRequired && authServer) { _%> + # Auth subrequest — proxied to the auth server's /verify endpoint. + # Responses are cached per Cookie+Authorization pair so NGINX only + # contacts the auth server when credentials change. + location = /.oss-auth-verify { + internal; + proxy_pass <%= authServer %>/verify; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; + proxy_set_header X-Original-Host $host; + proxy_set_header Cookie $http_cookie; + proxy_set_header Authorization $http_authorization; + + proxy_cache auth_cache; + proxy_cache_key "$http_cookie$http_authorization"; + proxy_cache_valid 200 5m; + proxy_cache_valid 401 30s; + } + + # Capture user identity headers from the auth subrequest response. + auth_request_set $auth_user_id $upstream_http_x_user_id; + auth_request_set $auth_username $upstream_http_x_username; + auth_request_set $auth_first_name $upstream_http_x_user_first_name; + auth_request_set $auth_last_name $upstream_http_x_user_last_name; + auth_request_set $auth_email $upstream_http_x_email; + auth_request_set $auth_groups $upstream_http_x_groups; + + location @login_redirect { + return 302 <%= authServer %>/login?redirect=https://$host$request_uri; + } + + <%_ } _%> + <%_ if (authRequired && !authServer) { _%> + # Authentication required but no auth server URL configured for this domain + location @auth_unavailable { + rewrite ^ /auth-unavailable.html break; + proxy_method GET; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_pass http://error_pages; + } + + location / { + error_page 503 @auth_unavailable; + return 503; + } + <%_ } else { _%> # Proxy settings location / { + <%_ if (authRequired && authServer) { _%> + auth_request /.oss-auth-verify; + error_page 401 = @login_redirect; + + # Forward user identity from auth subrequest to backend + proxy_set_header X-User-ID $auth_user_id; + proxy_set_header X-Username $auth_username; + proxy_set_header X-User-First-Name $auth_first_name; + proxy_set_header X-User-Last-Name $auth_last_name; + proxy_set_header X-Email $auth_email; + proxy_set_header X-Groups $auth_groups; + <%_ } _%> proxy_pass <%= service.httpService.backendProtocol %>://<%= service.Container.ipv4Address %>:<%= service.internalPort %>; proxy_http_version 1.1; @@ -255,6 +322,7 @@ http { # Allow large uploads client_max_body_size 2G; } + <%_ } _%> } <%_ }) _%> diff --git a/error-pages/auth-unavailable.html b/error-pages/auth-unavailable.html new file mode 100644 index 00000000..d9592442 --- /dev/null +++ b/error-pages/auth-unavailable.html @@ -0,0 +1,141 @@ + + + + + + 503 — Authentication Unavailable + + + +
+
503
+

Authentication Unavailable

+

+ This service requires authentication, but no authentication server has been + configured for this domain. +

+
+
+

What happened?

+

+ This service has authentication enabled, but no authentication server + has been configured for the domain. To resolve this: +

+
    +
  • If you're a user of this service, let the person in charge of it know.
  • +
  • If you're in charge of this service and authentication was enabled + by mistake, disable it. Otherwise, contact your environment administrator.
  • +
  • If you're the environment administrator, configure an auth server + for this domain.
  • +
+
+
+ + + diff --git a/mie-opensource-landing/docs/admins/core-concepts/containers.md b/mie-opensource-landing/docs/admins/core-concepts/containers.md index 1688190d..b117d49c 100644 --- a/mie-opensource-landing/docs/admins/core-concepts/containers.md +++ b/mie-opensource-landing/docs/admins/core-concepts/containers.md @@ -23,3 +23,5 @@ Users in the **ldapusers** group can SSH into any container using their cluster Users can expose HTTP services from containers using [external domains](external-domains). Services are automatically configured with SSL/TLS certificates, reverse proxy routing, and DNS records. +HTTP services can optionally require authentication via the **Require auth** checkbox. When enabled, NGINX authenticates requests against the domain's [auth server](external-domains#authentication) before proxying. Authenticated requests include identity headers (`X-User-ID`, `X-Username`, etc.) forwarded to the backend. See [External Domains — Authentication](external-domains#authentication) for configuration details. + diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md index 2051b4eb..6a757a55 100644 --- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md +++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md @@ -20,6 +20,7 @@ External domains expose container HTTP services to the internet. Domains are glo | **ACME Email** | Certificate expiration notifications | | **ACME Directory** | CA endpoint (Let's Encrypt Production/Staging) | | **Cloudflare API Token** | For DNS-01 challenge authentication and cross-site DNS record management | +| **Auth Server URL** | Optional — URL of an authentication server for NGINX `auth_request`. See [Authentication](#authentication) | :::tip Use Let's Encrypt **Staging** for testing — it has higher rate limits. Switch to **Production** once verified. @@ -111,3 +112,57 @@ When creating a container service, users select an external domain and specify a - Rotate tokens periodically; revoke immediately if compromised - Private keys never leave the cluster +## Authentication + +HTTP services can require authentication via NGINX's [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module. When a service has **Require auth** enabled, NGINX sends a subrequest to the domain's auth server before proxying each request. Unauthenticated users are redirected to the auth server's login page. + +### Auth Server Requirements + +The auth server URL (e.g., `https://manager.example.com`) must implement two endpoints: + +| Endpoint | Behavior | +|----------|----------| +| `GET /verify` | Return `2xx` if the user is authenticated, `401` otherwise. May return identity headers (see below). | +| `GET /login?redirect=` | Login page that redirects to `` after successful authentication. | + +The manager application implements both endpoints and can be used as the auth server. + +### Identity Headers + +On successful authentication, the auth server can return identity headers that NGINX forwards to the backend: + +| Header | Description | +|--------|-------------| +| `X-User-ID` | Numeric user ID | +| `X-Username` | Username | +| `X-User-First-Name` | First name | +| `X-User-Last-Name` | Last name | +| `X-Email` | Email address | +| `X-Groups` | Comma-separated group names | + +### Cookie Sharing + +The auth server must be on a subdomain of the external domain (e.g., `manager.example.com` for domain `example.com`). The manager sets its session cookie on the parent domain (`.example.com`) so sibling subdomains share the cookie for `auth_request` subrequests. + +### Flow + +```mermaid +sequenceDiagram + participant Client + participant NGINX + participant AuthServer as Auth Server + participant Backend + + Client->>NGINX: GET app.example.com/page + NGINX->>AuthServer: GET /verify (subrequest) + alt Authenticated + AuthServer-->>NGINX: 200 + identity headers + NGINX->>Backend: Proxied request + X-User-* headers + Backend-->>NGINX: Response + NGINX-->>Client: Response + else Not authenticated + AuthServer-->>NGINX: 401 + NGINX-->>Client: 302 → auth server /login?redirect=... + end +``` + diff --git a/mie-opensource-landing/docs/developers/database-schema.md b/mie-opensource-landing/docs/developers/database-schema.md index 61ea83d6..60233c55 100644 --- a/mie-opensource-landing/docs/developers/database-schema.md +++ b/mie-opensource-landing/docs/developers/database-schema.md @@ -80,6 +80,7 @@ erDiagram string externalHostname int externalDomainId FK enum backendProtocol "http | https (default: http)" + boolean authRequired "default: false" } TransportServices { @@ -105,6 +106,7 @@ erDiagram string cloudflareApiEmail string cloudflareApiKey int siteId FK "nullable, default site" + string authServer "nullable, auth server URL" } Jobs { @@ -184,12 +186,12 @@ LXC container on a Proxmox node. Unique composite index on `(nodeId, containerId ### Service (STI) Base model with `type` discriminator (`http`, `transport`, `dns`). Belongs to Container. -- **HTTPService**: `(externalHostname, externalDomainId)` unique. Belongs to ExternalDomain. `backendProtocol` controls `proxy_pass` scheme (`http` or `https`). +- **HTTPService**: `(externalHostname, externalDomainId)` unique. Belongs to ExternalDomain. `backendProtocol` controls `proxy_pass` scheme (`http` or `https`). `authRequired` enables NGINX `auth_request` — requires the domain's `authServer` to be configured. - **TransportService**: `(protocol, externalPort)` unique. `findNextAvailablePort()` static method. - **DnsService**: SRV records with `serviceName`. ### ExternalDomain -Manages public domains for HTTP service exposure. `siteId` is nullable — when set, indicates the "default site" whose DNS is assumed pre-configured (e.g., wildcard A record). Global resource available to all sites. Has many HTTPServices. Cloudflare credentials used for both ACME DNS-01 challenges and cross-site A record management. +Manages public domains for HTTP service exposure. `siteId` is nullable — when set, indicates the "default site" whose DNS is assumed pre-configured (e.g., wildcard A record). Global resource available to all sites. Has many HTTPServices. Cloudflare credentials used for both ACME DNS-01 challenges and cross-site A record management. `authServer` is an optional URL pointing to an authentication server that implements the NGINX `auth_request` protocol (see [External Domains](/docs/admins/core-concepts/external-domains#authentication)). ## User Management Models diff --git a/mie-opensource-landing/docs/developers/docker-images.md b/mie-opensource-landing/docs/developers/docker-images.md index cd1d800e..d0779bbb 100644 --- a/mie-opensource-landing/docs/developers/docker-images.md +++ b/mie-opensource-landing/docs/developers/docker-images.md @@ -145,7 +145,7 @@ RUN apt-get update && \ **Rules:** - `FROM base` — Docker Bake resolves this to the freshly built base image via `contexts` - Use `EXPOSE` to declare ports that should become container Services automatically -- Use `LABEL org.mieweb.opensource-server.services.http.default-port=` to set a default HTTP port (associated with `.`) +- Use `LABEL` directives to define HTTP services (see [Service Labels](/docs/users/creating-containers/using-environment-variables#service-labels)) - **Never** set `CMD`, `ENTRYPOINT`, `WORKDIR`, or `USER` — base images run systemd as PID 1 ### 2. Update docker-bake.hcl diff --git a/mie-opensource-landing/docs/developers/system-architecture.md b/mie-opensource-landing/docs/developers/system-architecture.md index afea0dd0..2bdb440c 100644 --- a/mie-opensource-landing/docs/developers/system-architecture.md +++ b/mie-opensource-landing/docs/developers/system-architecture.md @@ -145,3 +145,31 @@ sequenceDiagram NGINX-->>Client: HTTPS response ``` +### Authenticated HTTP Services + +When `authRequired` is enabled on an HTTP service, NGINX uses the [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module to authenticate requests before proxying. The domain's `authServer` must be configured (see [External Domains](/docs/admins/core-concepts/external-domains#authentication)). + +```mermaid +sequenceDiagram + participant Client + participant NGINX + participant AuthServer as Auth Server + participant Container + + Client->>NGINX: GET app.example.com/page + NGINX->>AuthServer: Subrequest: GET /verify + alt 2xx (authenticated) + AuthServer-->>NGINX: 200 + X-User-* headers + NGINX->>Container: Proxied request + identity headers + Container-->>NGINX: Response + NGINX-->>Client: Response + else 401 (unauthenticated) + AuthServer-->>NGINX: 401 + NGINX-->>Client: 302 → /login?redirect=original_url + end +``` + +NGINX captures identity headers from the auth server subrequest (`X-User-ID`, `X-Username`, `X-User-First-Name`, `X-User-Last-Name`, `X-Email`, `X-Groups`) and forwards them to the backend container via `proxy_set_header`. + +If `authRequired` is enabled but no `authServer` is configured on the domain, NGINX serves a 503 error page. + diff --git a/mie-opensource-landing/docs/users/creating-containers/using-environment-variables.mdx b/mie-opensource-landing/docs/users/creating-containers/using-environment-variables.mdx index 04fecbde..ac6aad47 100644 --- a/mie-opensource-landing/docs/users/creating-containers/using-environment-variables.mdx +++ b/mie-opensource-landing/docs/users/creating-containers/using-environment-variables.mdx @@ -82,10 +82,48 @@ The same pattern applies to **Node.js** (use `ghcr.io/mieweb/opensource-server/n 1. `EnvironmentFile=/etc/environment` in your `.service` file 2. `systemctl enable` in your Dockerfile -:::note -Adding the label `org.mieweb.opensource-server.services.http.default-port` with a number between 1 and 65535 will automattically create an HTTP service on new containers with the value as the internal port number and an external hostname matching the container name. See ghcr.io/mieweb/opensource-server/nodejs:latest for an example. +## Service Labels + +Docker images can declare HTTP services via OCI labels under the `org.mieweb.opensource-server.services.http` namespace. When a user selects the image as a template, the container creation form auto-populates services from these labels. + +### Default Port + +```dockerfile +LABEL org.mieweb.opensource-server.services.http.default-port=3000 +``` + +Creates one HTTP service on port 3000 with the container hostname as the external hostname. + +### Named Services + +Named labels use the pattern `...http..` where `` groups fields for a single service: + +| Label | Required | Description | +|-------|----------|-------------| +| `...http..port` | Yes | Internal port number | +| `...http..hostnameSuffix` | No | External hostname becomes `-` | +| `...http..requireAuth` | No | `true`, `1`, or `yes` to enable [authentication](/docs/admins/core-concepts/external-domains#authentication) | + +:::warning +The `` groups fields together — a typo in the name (e.g., `ozwell-studio` vs `ozwell-sutdio`) causes fields to be treated as separate services. ::: +```dockerfile +# Main app on port 3000 (hostname: myapp.example.com) +LABEL org.mieweb.opensource-server.services.http.default-port=3000 + +# API on port 8080 (hostname: myapp-api.example.com, auth required) +LABEL org.mieweb.opensource-server.services.http.api.port=8080 +LABEL org.mieweb.opensource-server.services.http.api.hostnameSuffix=api +LABEL org.mieweb.opensource-server.services.http.api.requireAuth=true + +# Docs on port 9090 (hostname: myapp-docs.example.com) +LABEL org.mieweb.opensource-server.services.http.docs.port=9090 +LABEL org.mieweb.opensource-server.services.http.docs.hostnameSuffix=docs +``` + +TCP ports declared by named services are excluded from the auto-populated transport services list (same dedup as `default-port`). + ## Testing ```bash diff --git a/mie-opensource-landing/package-lock.json b/mie-opensource-landing/package-lock.json index fb8a5162..97b0e59a 100644 --- a/mie-opensource-landing/package-lock.json +++ b/mie-opensource-landing/package-lock.json @@ -43,7 +43,6 @@ "@docusaurus/module-type-aliases": "^3.9.2", "@docusaurus/tsconfig": "^3.9.2", "@docusaurus/types": "^3.9.2", - "concurrently": "^9.2.0", "typescript": "~5.6.2" }, "engines": { @@ -12284,48 +12283,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/concurrently": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.0.tgz", - "integrity": "sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.2", - "lodash": "^4.17.21", - "rxjs": "^7.8.1", - "shell-quote": "^1.8.1", - "supports-color": "^8.1.1", - "tree-kill": "^1.2.2", - "yargs": "^17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -25011,16 +24968,6 @@ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -26607,16 +26554,6 @@ "tslib": "2" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", diff --git a/mie-opensource-landing/package.json b/mie-opensource-landing/package.json index c1457731..dbc082a4 100644 --- a/mie-opensource-landing/package.json +++ b/mie-opensource-landing/package.json @@ -5,14 +5,11 @@ "scripts": { "docusaurus": "docusaurus", "start": "docusaurus start", - "proxy": "node proxy-server.js", - "dev": "concurrently \"npm run proxy\" \"npm run start -- --host 0.0.0.0 --port 2998\"", "build": "docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", - "serve": "npm run build && docusaurus serve --host 0.0.0.0 --port 2998", - "serve-with-proxy": "concurrently \"npm run proxy\" \"npm run serve\"", + "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", "typecheck": "tsc" @@ -53,7 +50,6 @@ "@docusaurus/module-type-aliases": "^3.9.2", "@docusaurus/tsconfig": "^3.9.2", "@docusaurus/types": "^3.9.2", - "concurrently": "^9.2.0", "typescript": "~5.6.2" }, "browserslist": {