From 98ddf96eac44d91ad8bb40cf79cd328ce213be7c Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 9 Apr 2026 09:17:35 -0400 Subject: [PATCH 01/10] feat: add nginx auth_request support for HTTP services Add per-service authRequired flag for HTTP services. When enabled, NGINX uses auth_request to verify authentication via the manager app before proxying requests. Unauthenticated users are redirected to the login page with a cross-domain redirect back to the original URL. Changes: - Add authRequired boolean to HTTPService model - Add managerUrl field to ExternalDomain model - Add GET /verify endpoint for NGINX auth_request subrequests - Update NGINX template with auth_request blocks and login redirect - Add isSafeRedirectUrl() for cross-domain redirect validation - Update login router to allow safe cross-domain redirects - Update container and external-domain forms with new fields - Upgrade express-session to 1.19 for dynamic cookie domain support - Add auth-unavailable error page for misconfigured services - Update OpenAPI spec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...08000000-add-http-service-auth-required.js | 16 ++ ...8000001-add-external-domain-manager-url.js | 16 ++ create-a-container/models/external-domain.js | 8 + create-a-container/models/http-service.js | 5 + create-a-container/openapi.yaml | 16 ++ create-a-container/package-lock.json | 21 ++- create-a-container/package.json | 2 +- create-a-container/routers/containers.js | 26 +++- .../routers/external-domains.js | 10 +- create-a-container/routers/login.js | 9 +- create-a-container/routers/verify.js | 66 ++++++++ create-a-container/server.js | 20 ++- create-a-container/utils/index.js | 27 ++++ create-a-container/views/containers/form.ejs | 27 +++- .../views/external-domains/form.ejs | 49 ++++++ create-a-container/views/nginx-conf.ejs | 57 +++++++ error-pages/auth-unavailable.html | 141 ++++++++++++++++++ 17 files changed, 485 insertions(+), 31 deletions(-) create mode 100644 create-a-container/migrations/20260408000000-add-http-service-auth-required.js create mode 100644 create-a-container/migrations/20260408000001-add-external-domain-manager-url.js create mode 100644 create-a-container/routers/verify.js create mode 100644 error-pages/auth-unavailable.html 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/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..7c02cd0a 100644 --- a/create-a-container/models/http-service.js +++ b/create-a-container/models/http-service.js @@ -38,6 +38,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..4047fa0a 100644 --- a/create-a-container/openapi.yaml +++ b/create-a-container/openapi.yaml @@ -795,6 +795,13 @@ 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 @@ -833,6 +840,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..1c47f1b1 100644 --- a/create-a-container/routers/containers.js +++ b/create-a-container/routers/containers.js @@ -380,7 +380,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 +412,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 +577,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 +610,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/index.js b/create-a-container/utils/index.js index 65f53eaa..aaa39825 100644 --- a/create-a-container/utils/index.js +++ b/create-a-container/utils/index.js @@ -81,10 +81,37 @@ 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..15ed1063 100644 --- a/create-a-container/views/containers/form.ejs +++ b/create-a-container/views/containers/form.ejs @@ -346,7 +346,7 @@ const breadcrumbLabel = isEdit ? 'Edit' : 'New'; } } - function addServiceRow(type = 'http', internalPort = '', externalHostname = '', externalDomainId = '', serviceId = null, externalPort = null, dnsName = '') { + function addServiceRow(type = 'http', internalPort = '', externalHostname = '', externalDomainId = '', serviceId = null, externalPort = null, dnsName = '', authRequired = false) { const row = document.createElement('tr'); row.id = `service-row-${serviceCounter}`; const isExisting = !!serviceId; @@ -420,7 +420,7 @@ const breadcrumbLabel = isEdit ? 'Edit' : 'New'; const externalCell = document.createElement('td'); externalCell.style.cssText = 'border: 1px solid #ddd; padding: 8px;'; externalCell.id = `external-cell-${serviceCounter}`; - + // Action cell const actionCell = document.createElement('td'); actionCell.style.cssText = 'border: 1px solid #ddd; padding: 8px; text-align: center;'; @@ -442,11 +442,11 @@ const breadcrumbLabel = isEdit ? 'Edit' : 'New'; document.getElementById('servicesTableBody').appendChild(row); - updateExternalCell(serviceCounter, type, externalHostname, externalDomainId, externalPort, isExisting, dnsName); + updateExternalCell(serviceCounter, type, externalHostname, externalDomainId, externalPort, isExisting, dnsName, authRequired); serviceCounter++; } - function updateExternalCell(index, type, hostname = '', domainId = '', externalPort = null, isExisting = false, dnsName = '') { + function updateExternalCell(index, type, hostname = '', domainId = '', externalPort = null, isExisting = false, dnsName = '', authRequired = false) { const cell = document.getElementById(`external-cell-${index}`); // Clear existing content @@ -493,6 +493,19 @@ const breadcrumbLabel = isEdit ? 'Edit' : 'New'; container.appendChild(dot); container.appendChild(domainSelect); cell.appendChild(container); + + const authLabel = document.createElement('label'); + authLabel.style.cssText = 'display: flex; align-items: center; gap: 4px; margin-top: 4px; margin-bottom: 0; font-size: 0.85em; line-height: 1; color: #555; cursor: pointer;'; + const authCheckbox = document.createElement('input'); + authCheckbox.type = 'checkbox'; + authCheckbox.name = `services[${index}][authRequired]`; + authCheckbox.value = 'true'; + authCheckbox.checked = !!authRequired; + authCheckbox.style.cssText = 'width: 14px; height: 14px; cursor: pointer; vertical-align: middle; margin: 0;'; + authCheckbox.setAttribute('aria-label', 'Require authentication'); + authLabel.appendChild(authCheckbox); + authLabel.appendChild(document.createTextNode('Require auth')); + cell.appendChild(authLabel); } else if (type === 'srv') { // SRV record - DNS name input with internal domain display const container = document.createElement('div'); @@ -573,7 +586,6 @@ const breadcrumbLabel = isEdit ? 'Edit' : 'New'; if (e.target.classList.contains('service-type')) { const index = e.target.dataset.index; const type = e.target.value; - // Try to find existing external port from hidden field const row = document.getElementById(`service-row-${index}`); const externalPortInput = row?.querySelector('input[name*="[externalPort]"]'); const externalPort = externalPortInput?.value || null; @@ -587,12 +599,13 @@ const breadcrumbLabel = isEdit ? 'Edit' : 'New'; if (isEdit && existingServices.length > 0) { // Load existing services when editing existingServices.forEach(service => { - let type, externalHostname = '', externalDomainId = '', externalPort = null, dnsName = ''; + let type, externalHostname = '', externalDomainId = '', externalPort = null, dnsName = '', authRequired = false; if (service.type === 'http') { type = service.httpService?.backendProtocol === 'https' ? 'https' : 'http'; externalHostname = service.httpService?.externalHostname || ''; externalDomainId = service.httpService?.externalDomainId || ''; + authRequired = !!service.httpService?.authRequired; } else if (service.type === 'dns') { type = 'srv'; dnsName = service.dnsService?.dnsName || ''; @@ -602,7 +615,7 @@ const breadcrumbLabel = isEdit ? 'Edit' : 'New'; externalPort = service.transportService?.externalPort || null; } - addServiceRow(type, service.internalPort, externalHostname, externalDomainId, service.id, externalPort, dnsName); + addServiceRow(type, service.internalPort, externalHostname, externalDomainId, service.id, externalPort, dnsName, authRequired); }); } diff --git a/create-a-container/views/external-domains/form.ejs b/create-a-container/views/external-domains/form.ejs index d65133a3..b324f625 100644 --- a/create-a-container/views/external-domains/form.ejs +++ b/create-a-container/views/external-domains/form.ejs @@ -109,6 +109,29 @@ +
+ + +
+ 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..7dc5ebbb 100644 --- a/create-a-container/views/nginx-conf.ejs +++ b/create-a-container/views/nginx-conf.ejs @@ -52,6 +52,7 @@ http { location /403.html { } location /404.html { } location /502.html { } + location /auth-unavailable.html { } } server { @@ -175,6 +176,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 +229,61 @@ http { proxy_pass http://error_pages; } + <%_ if (authRequired && authServer) { _%> + # Auth subrequest — proxied to the auth server's /verify endpoint. + 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; + } + + # 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 +311,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.
  • +
+
+
+ + + From 3af1a429228a8c316ec7d883551c9df2ee928988 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 9 Apr 2026 10:38:43 -0400 Subject: [PATCH 02/10] feat: support named HTTP service labels from Docker images Add support for named OCI labels to define multiple HTTP services with per-service configuration: org.mieweb.opensource-server.services.http..port org.mieweb.opensource-server.services.http..hostnameSuffix org.mieweb.opensource-server.services.http..requireAuth The form auto-populates services from image metadata, building external hostnames as - and setting the auth flag. The existing default-port label remains supported for backward compatibility. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- create-a-container/openapi.yaml | 56 +++++++++++++------- create-a-container/utils/docker-registry.js | 51 +++++++++++++----- create-a-container/views/containers/form.ejs | 9 ++-- 3 files changed, 79 insertions(+), 37 deletions(-) diff --git a/create-a-container/openapi.yaml b/create-a-container/openapi.yaml index 4047fa0a..8aeff31c 100644 --- a/create-a-container/openapi.yaml +++ b/create-a-container/openapi.yaml @@ -806,31 +806,49 @@ components: 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 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/views/containers/form.ejs b/create-a-container/views/containers/form.ejs index 15ed1063..cebcc71f 100644 --- a/create-a-container/views/containers/form.ejs +++ b/create-a-container/views/containers/form.ejs @@ -314,11 +314,12 @@ const breadcrumbLabel = isEdit ? 'Edit' : 'New'; // Pre-populate HTTP Services from OCI labels if (metadata.httpServices && Array.isArray(metadata.httpServices)) { for (const httpService of metadata.httpServices) { - // Use container hostname as the external hostname - const hostname = document.getElementById('hostname')?.value || ''; - // Auto-select first domain if available + const baseHostname = document.getElementById('hostname')?.value || ''; + const externalHostname = httpService.hostnameSuffix + ? baseHostname + '-' + httpService.hostnameSuffix + : baseHostname; const firstDomainId = externalDomains.length > 0 ? externalDomains[0].id : ''; - addServiceRow('http', httpService.port, hostname, firstDomainId); + addServiceRow('http', httpService.port, externalHostname, firstDomainId, null, null, '', !!httpService.requireAuth); } } From 34bafecd7718f68753bf8285e2b4ace44df86e59 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 9 Apr 2026 11:01:40 -0400 Subject: [PATCH 03/10] fix: show all HTTP services on container index page The HTTP column now displays all HTTP services for each container instead of only the first one. Each entry is a clickable link when an external URL is configured. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- create-a-container/routers/containers.js | 24 ++++++++++++------- create-a-container/views/containers/index.ejs | 15 ++++++++---- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/create-a-container/routers/containers.js b/create-a-container/routers/containers.js index 1c47f1b1..6493be55 100644 --- a/create-a-container/routers/containers.js +++ b/create-a-container/routers/containers.js @@ -181,12 +181,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 +205,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 diff --git a/create-a-container/views/containers/index.ejs b/create-a-container/views/containers/index.ejs index 2155a7dc..cb024ab8 100644 --- a/create-a-container/views/containers/index.ejs +++ b/create-a-container/views/containers/index.ejs @@ -25,7 +25,7 @@ Template Node SSH Port - HTTP Port + HTTP Actions @@ -82,10 +82,17 @@ <% } %> - <% if (r.httpExternalUrl) { %> - <%= r.httpPort %> + <% if (r.httpEntries && r.httpEntries.length > 0) { %> + <% r.httpEntries.forEach((entry, i) => { %> + <% if (i > 0) { %>
<% } %> + <% if (entry.externalUrl) { %> + <%= entry.port %> + <% } else { %> + <%= entry.port %> + <% } %> + <% }) %> <% } else { %> - <%= r.httpPort || '-' %> + - <% } %> From dc0ac5522a59aa7104f48f0346638605fbfd5613 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 9 Apr 2026 11:03:26 -0400 Subject: [PATCH 04/10] Add Ozwell Studio as a default template option --- create-a-container/views/containers/form.ejs | 1 + 1 file changed, 1 insertion(+) diff --git a/create-a-container/views/containers/form.ejs b/create-a-container/views/containers/form.ejs index cebcc71f..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'; +