feat(services): HTTP/2 + HTTP/3 support via Caddy reverse-proxy

Swap Werkzeug for Caddy as the protocol layer for http and https decoy
services. Flask keeps owning app logic (fake_app, custom_body, headers,
syslog) on 127.0.0.1:8080; Caddy terminates h1/h2/h2c/h3 on the wire
with real-world TLS/QUIC fingerprints.

- Add `multi_enum` FieldType to ServiceConfigField + _coerce
- Add `http_versions` field to HTTPService (h1/h2c) and HTTPSService
  (h1/h2/h3); selecting h3 emits UDP/443 port mapping in compose
- Rewrite both Dockerfiles with multi-stage Caddy binary copy +
  setcap for port binding as the logrelay user
- Entrypoints parse HTTP_VERSIONS JSON, render a Caddyfile, start
  Flask in background, wait for it, then exec Caddy
- https/server.py drops direct TLS handling; Caddy owns the cert
- Add ProxyFix to both server.py so Flask sees real attacker IPs
- Frontend: multi_enum checkbox-group renderer in ServiceConfigFields;
  FormValue union extended to string[]; compactPayload skips []
- Fix stale test_smtp_relay_schema_matches_smtp: relay schema is a
  superset of smtp, not equal; update assertions accordingly
This commit is contained in:
2026-05-10 00:04:37 -04:00
parent ec5b49144e
commit 0653e500b5
14 changed files with 435 additions and 31 deletions

View File

@@ -5,7 +5,7 @@ import './ServiceConfigForm.css';
export interface ServiceConfigFieldDTO {
key: string;
label: string;
type: 'string' | 'password' | 'int' | 'bool' | 'textarea' | 'enum';
type: 'string' | 'password' | 'int' | 'bool' | 'textarea' | 'enum' | 'multi_enum';
default?: unknown;
secret?: boolean;
help?: string | null;
@@ -20,17 +20,19 @@ export interface SchemaResponse {
fields: ServiceConfigFieldDTO[];
}
export type FormValue = string | number | boolean;
export type FormValue = string | number | boolean | string[];
export type FormState = Record<string, FormValue>;
export function toFormValue(field: ServiceConfigFieldDTO, raw: unknown): FormValue {
if (raw === undefined || raw === null) {
if (field.type === 'bool') return Boolean(field.default);
if (field.type === 'int') return field.default == null ? ('' as unknown as number) : Number(field.default);
if (field.type === 'multi_enum') return Array.isArray(field.default) ? (field.default as string[]) : [];
return (field.default as string | undefined) ?? '';
}
if (field.type === 'bool') return Boolean(raw);
if (field.type === 'int') return Number(raw);
if (field.type === 'multi_enum') return Array.isArray(raw) ? (raw as string[]) : [];
return String(raw);
}
@@ -51,6 +53,7 @@ export function compactPayload(
for (const f of fields) {
const v = state[f.key];
if (v === '' || v === undefined || v === null) continue;
if (Array.isArray(v) && v.length === 0) continue;
out[f.key] = v;
}
return out;
@@ -129,7 +132,31 @@ const ServiceConfigFields: React.FC<Props> = ({
{f.label}
{f.secret && <span className="svc-cfg-secret-tag">· secret</span>}
</label>
{f.type === 'bool' ? (
{f.type === 'multi_enum' ? (
<fieldset className="svc-cfg-multi-enum">
{(f.enum ?? []).map((opt) => {
const optId = `${id}-${opt}`;
const selected = Array.isArray(v) ? (v as string[]) : [];
const checked = selected.includes(opt);
return (
<label key={opt} htmlFor={optId} className="svc-cfg-multi-enum-option">
<input
id={optId}
type="checkbox"
checked={checked}
onChange={() => {
const next = checked
? selected.filter((x) => x !== opt)
: [...selected, opt];
setVal(f.key, next);
}}
/>
{opt}
</label>
);
})}
</fieldset>
) : f.type === 'bool' ? (
<input
id={id}
type="checkbox"

View File

@@ -181,3 +181,30 @@ select.svc-cfg-input {
font-style: italic;
}
.svc-cfg-status.alert-text { font-style: normal; }
/* multi_enum checkbox group — sits in the input column, stacks vertically. */
.svc-cfg-multi-enum {
grid-column: 2 / 3;
grid-row: 1 / 2;
display: flex;
flex-direction: column;
gap: 4px;
border: none;
padding: 0;
margin: 0;
}
.svc-cfg-multi-enum-option {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.68rem;
cursor: pointer;
user-select: none;
}
.svc-cfg-multi-enum-option input[type="checkbox"] {
width: 13px;
height: 13px;
accent-color: var(--violet);
cursor: pointer;
flex-shrink: 0;
}