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
5 changes: 5 additions & 0 deletions create-a-container/client/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export interface ServiceTransport {
id: number;
protocol: 'tcp' | 'udp';
externalPort: number;
tls: boolean;
backendTls: boolean;
externalHostname: string | null;
externalDomainId: number | null;
domain?: string;
}
export interface ServiceDns {
id: number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,22 @@ const SERVICE_TYPES = [
{ value: 'http', label: 'HTTP' },
{ value: 'https', label: 'HTTPS (backend TLS)' },
{ value: 'tcp', label: 'TCP' },
{ value: 'tls', label: 'TLS (backend TLS)' },
{ value: 'udp', label: 'UDP' },
{ value: 'srv', label: 'DNS (SRV record)' },
];

const serviceSchema = z
.object({
id: z.number().optional(),
type: z.enum(['http', 'https', 'tcp', 'udp', 'srv']),
type: z.enum(['http', 'https', 'tcp', 'tls', 'udp', 'srv']),
internalPort: z.string(),
externalPort: z.number().optional(),
externalHostname: z.string().optional(),
externalDomainId: z.string().optional(),
dnsName: z.string().optional(),
authRequired: z.boolean().optional(),
tls: z.boolean().optional(),
deleted: z.boolean().optional(),
})
.refine(
Expand All @@ -66,6 +69,18 @@ const serviceSchema = z
message: 'An external domain is required for HTTP services',
path: ['externalDomainId'],
},
)
.refine(
(s) =>
s.deleted ||
s.id !== undefined || // existing services are immutable here
(s.type !== 'tcp' && s.type !== 'tls') ||
!s.tls ||
!!s.externalDomainId,
{
message: 'An external domain is required when TLS termination is enabled',
path: ['externalDomainId'],
},
);

const envVarSchema = z.object({ key: z.string(), value: z.string() });
Expand Down Expand Up @@ -161,12 +176,21 @@ export function ContainerFormPage() {
? s.httpService?.backendProtocol === 'https'
? 'https'
: 'http'
: (s.transportService?.protocol ?? 'tcp'),
: s.transportService?.backendTls
? 'tls'
: (s.transportService?.protocol ?? 'tcp'),
internalPort: String(s.internalPort),
externalHostname: s.httpService?.externalHostname || '',
externalDomainId: s.httpService ? String(s.httpService.externalDomainId) : '',
externalPort: s.transportService?.externalPort,
externalHostname:
s.httpService?.externalHostname || s.transportService?.externalHostname || '',
externalDomainId: s.httpService
? String(s.httpService.externalDomainId)
: s.transportService?.externalDomainId
? String(s.transportService.externalDomainId)
: '',
dnsName: s.dnsService?.dnsName || '',
authRequired: !!s.httpService?.authRequired,
tls: !!s.transportService?.tls,
deleted: false,
})),
environmentVars: Object.entries(container.environmentVars || {}).map(([key, value]) => ({
Expand Down Expand Up @@ -215,6 +239,7 @@ export function ContainerFormPage() {
externalDomainId: '',
dnsName: '',
authRequired: false,
tls: false,
deleted: false,
});
added += 1;
Expand Down Expand Up @@ -252,14 +277,16 @@ export function ContainerFormPage() {
return;
}
if (s.deleted) return;
const externalHostname = s.externalHostname?.trim();
servicesObj[`s${idx}`] = {
id: s.id,
type: s.type,
internalPort: s.internalPort ? parseInt(s.internalPort, 10) : undefined,
externalHostname: s.externalHostname,
externalHostname: externalHostname ? externalHostname : undefined,
externalDomainId: s.externalDomainId ? parseInt(s.externalDomainId, 10) : undefined,
dnsName: s.dnsName,
authRequired: s.authRequired,
tls: s.tls,
};
});
const payload = {
Expand Down Expand Up @@ -502,6 +529,7 @@ export function ContainerFormPage() {
externalDomainId: defaultExternalDomainId,
dnsName: '',
authRequired: false,
tls: false,
deleted: false,
})
}
Expand All @@ -524,7 +552,7 @@ export function ContainerFormPage() {
<div key={f.id} className="grid gap-3 rounded-lg border border-border p-4">
{isExisting && (
<p className="text-xs text-muted-foreground">
Existing service — only Require authentication can be changed. Delete and re-add to modify other fields.
Existing service — the type cannot be changed. Delete and re-add to change it.
</p>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_1fr_auto]">
Expand Down Expand Up @@ -552,7 +580,6 @@ export function ContainerFormPage() {
label="Internal port"
type="number"
inputMode="numeric"
readOnly={isExisting}
{...register(`services.${idx}.internalPort`)}
/>
<Button
Expand All @@ -573,7 +600,6 @@ export function ContainerFormPage() {
<Input
label="External hostname"
placeholder="app"
readOnly={isExisting}
{...register(`services.${idx}.externalHostname`)}
/>
<Select
Expand All @@ -583,7 +609,6 @@ export function ContainerFormPage() {
setValue(`services.${idx}.externalDomainId`, v)
}
options={domainOptions}
disabled={isExisting}
/>
<Switch
label="Require authentication"
Expand All @@ -594,11 +619,62 @@ export function ContainerFormPage() {
/>
</div>
)}
{(svc.type === 'tcp' || svc.type === 'tls' || svc.type === 'udp') && (
<div className="grid gap-3">
<div>
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
External port
</p>
<p className="font-mono text-sm">
{svc.externalPort ?? (
<span className="text-muted-foreground">
Auto-assigned on save
</span>
)}
</p>
</div>
{(svc.type === 'tcp' || svc.type === 'tls') && (
<>
<Switch
label="Terminate TLS at load balancer"
checked={!!svc.tls}
onCheckedChange={(c) => {
setValue(`services.${idx}.tls`, c);
// Ensure a domain is selected when enabling TLS so
// the load balancer has a certificate to use.
if (c && !svc.externalDomainId && defaultExternalDomainId) {
setValue(
`services.${idx}.externalDomainId`,
defaultExternalDomainId,
);
}
}}
/>
{svc.tls && (
<div className="grid gap-3 sm:grid-cols-2">
<Input
label="External hostname"
placeholder="db"
{...register(`services.${idx}.externalHostname`)}
/>
<Select
label="External domain"
value={svc.externalDomainId || ''}
onValueChange={(v) =>
setValue(`services.${idx}.externalDomainId`, v)
}
options={domainOptions}
/>
</div>
)}
</>
)}
</div>
)}
{svc.type === 'srv' && (
<Input
label="DNS name"
placeholder="_service._tcp.example"
readOnly={isExisting}
{...register(`services.${idx}.dnsName`)}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
// Subdomain label for TLS-terminated TCP services (e.g. "db" in
// "db.example.com"). Mirrors HTTPService.externalHostname. Nullable
// because plaintext TCP/UDP services don't use it.
await queryInterface.addColumn('TransportServices', 'externalHostname', {
type: Sequelize.STRING(255),
allowNull: true,
comment: 'Subdomain label used for TLS-enabled TCP services (e.g. "db")'
});

// External domain whose certificate (/etc/ssl/certs/<domain>.crt) is used
// to terminate TLS at the load balancer. Mirrors HTTPService.externalDomainId.
await queryInterface.addColumn('TransportServices', 'externalDomainId', {
type: Sequelize.INTEGER,
allowNull: true,
references: {
model: 'ExternalDomains',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
comment: 'External domain providing the TLS certificate for TCP TLS termination'
});
},

async down(queryInterface) {
await queryInterface.removeColumn('TransportServices', 'externalDomainId');
await queryInterface.removeColumn('TransportServices', 'externalHostname');
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
// Whether the load balancer connects to the backend over TLS (nginx
// stream `proxy_ssl on`). This is the transport analog of an HTTPS
// service's backendProtocol=https. Independent of the client-side `tls`
// termination flag; a TCP service may do either or both.
await queryInterface.addColumn('TransportServices', 'backendTls', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: 'Whether the load balancer re-encrypts to the backend via proxy_ssl'
});
},

async down(queryInterface) {
await queryInterface.removeColumn('TransportServices', 'backendTls');
}
};
48 changes: 48 additions & 0 deletions create-a-container/models/transport-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ module.exports = (sequelize, DataTypes) => {
class TransportService extends Model {
static associate(models) {
TransportService.belongsTo(models.Service, { foreignKey: 'serviceId', as: 'service' });
TransportService.belongsTo(models.ExternalDomain, { foreignKey: 'externalDomainId', as: 'externalDomain' });
}

// Find the next available external port for the given protocol in the specified range
Expand Down Expand Up @@ -68,10 +69,57 @@ module.exports = (sequelize, DataTypes) => {
type: DataTypes.BOOLEAN,
allowNull: true,
comment: 'Whether to use TLS for TCP connections'
},
backendTls: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: 'Whether the load balancer re-encrypts to the backend via proxy_ssl'
},
externalHostname: {
type: DataTypes.STRING(255),
allowNull: true,
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'
}
}
},
externalDomainId: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'ExternalDomains',
key: 'id'
}
}
}, {
sequelize,
modelName: 'TransportService',
validate: {
// TLS termination is only supported for TCP (nginx stream `ssl`).
// UDP would require DTLS, which this load balancer does not provide.
tlsRequiresTcp() {
if (this.tls && this.protocol !== 'tcp') {
throw new Error('TLS can only be enabled for TCP services');
}
},
// A TLS-enabled TCP service must reference an external domain so the
// load balancer knows which certificate to terminate with.
tlsRequiresDomain() {
if (this.tls && !this.externalDomainId) {
throw new Error('A TLS-enabled TCP service must have an externalDomainId');
}
},
// Backend TLS re-encryption (nginx stream `proxy_ssl`) is only valid for
// TCP. UDP cannot use proxy_ssl.
backendTlsRequiresTcp() {
if (this.backendTls && this.protocol !== 'tcp') {
throw new Error('Backend TLS can only be enabled for TCP services');
}
}
},
indexes: [
{
name: 'transport_services_unique_protocol_port',
Expand Down
Loading
Loading