Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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');
}
};
Original file line number Diff line number Diff line change
@@ -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');
}
};
8 changes: 7 additions & 1 deletion create-a-container/models/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
8 changes: 8 additions & 0 deletions create-a-container/models/external-domain.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 9 additions & 1 deletion create-a-container/models/http-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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,
Expand Down
72 changes: 53 additions & 19 deletions create-a-container/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<container-hostname>-<suffix>`.
From label `org.mieweb.opensource-server.services.http.<name>.hostnameSuffix`.
requireAuth:
type: boolean
description: |
Enable authentication via `auth_request` for this service.
From label `org.mieweb.opensource-server.services.http.<name>.requireAuth`.
description: |
HTTP services derived from OCI labels. Supports the legacy
`org.mieweb.opensource-server.services.http.default-port` label and named
labels: `...http.<name>.port`, `...http.<name>.hostnameSuffix`,
`...http.<name>.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
Expand All @@ -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=<url>` 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
Expand Down
21 changes: 13 additions & 8 deletions create-a-container/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion create-a-container/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
57 changes: 37 additions & 20 deletions create-a-container/routers/containers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand All @@ -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') {
Expand Down
10 changes: 6 additions & 4 deletions create-a-container/routers/external-domains.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,16 @@ 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,
acmeEmail: acmeEmail || null,
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`);
Expand All @@ -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
Expand Down
Loading
Loading