Introduce bind presets for deployment setup

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-10 07:32:16 -05:00 committed by Dotta
parent e1bf9d66a7
commit 2a84e53c1b
35 changed files with 915 additions and 176 deletions

View file

@ -66,7 +66,7 @@ OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh
### 1) Start Paperclip
```bash
pnpm dev --tailscale-auth
pnpm dev --bind lan
curl -fsS http://127.0.0.1:3100/api/health
```

View file

@ -1,11 +1,13 @@
import { z } from "zod";
import {
AUTH_BASE_URL_MODES,
BIND_MODES,
DEPLOYMENT_EXPOSURES,
DEPLOYMENT_MODES,
SECRET_PROVIDERS,
STORAGE_PROVIDERS,
} from "./constants.js";
import { validateConfiguredBindMode } from "./network-bind.js";
export const configMetaSchema = z.object({
version: z.literal(1),
@ -46,6 +48,8 @@ export const loggingConfigSchema = z.object({
export const serverConfigSchema = z.object({
deploymentMode: z.enum(DEPLOYMENT_MODES).default("local_trusted"),
exposure: z.enum(DEPLOYMENT_EXPOSURES).default("private"),
bind: z.enum(BIND_MODES).optional(),
customBindHost: z.string().optional(),
host: z.string().default("127.0.0.1"),
port: z.number().int().min(1).max(65535).default(3100),
allowedHostnames: z.array(z.string().min(1)).default([]),
@ -132,15 +136,26 @@ export const paperclipConfigSchema = z
}),
})
.superRefine((value, ctx) => {
if (value.server.deploymentMode === "local_trusted") {
if (value.server.exposure !== "private") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "server.exposure must be private when deploymentMode is local_trusted",
path: ["server", "exposure"],
});
}
return;
if (value.server.deploymentMode === "local_trusted" && value.server.exposure !== "private") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "server.exposure must be private when deploymentMode is local_trusted",
path: ["server", "exposure"],
});
}
for (const message of validateConfiguredBindMode({
deploymentMode: value.server.deploymentMode,
deploymentExposure: value.server.exposure,
bind: value.server.bind,
host: value.server.host,
customBindHost: value.server.customBindHost,
})) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message,
path: message.includes("customBindHost") ? ["server", "customBindHost"] : ["server", "bind"],
});
}
if (value.auth.baseUrlMode === "explicit" && !value.auth.publicBaseUrl) {

View file

@ -7,6 +7,9 @@ export type DeploymentMode = (typeof DEPLOYMENT_MODES)[number];
export const DEPLOYMENT_EXPOSURES = ["private", "public"] as const;
export type DeploymentExposure = (typeof DEPLOYMENT_EXPOSURES)[number];
export const BIND_MODES = ["loopback", "lan", "tailnet", "custom"] as const;
export type BindMode = (typeof BIND_MODES)[number];
export const AUTH_BASE_URL_MODES = ["auto", "explicit"] as const;
export type AuthBaseUrlMode = (typeof AUTH_BASE_URL_MODES)[number];

View file

@ -3,6 +3,7 @@ export {
COMPANY_STATUSES,
DEPLOYMENT_MODES,
DEPLOYMENT_EXPOSURES,
BIND_MODES,
AUTH_BASE_URL_MODES,
AGENT_STATUSES,
AGENT_ADAPTER_TYPES,
@ -79,6 +80,7 @@ export {
type CompanyStatus,
type DeploymentMode,
type DeploymentExposure,
type BindMode,
type AuthBaseUrlMode,
type AgentStatus,
type AgentAdapterType,
@ -149,6 +151,16 @@ export {
type PluginBridgeErrorCode,
} from "./constants.js";
export {
ALL_INTERFACES_BIND_HOST,
LOOPBACK_BIND_HOST,
inferBindModeFromHost,
isAllInterfacesHost,
isLoopbackHost,
resolveRuntimeBind,
validateConfiguredBindMode,
} from "./network-bind.js";
export type {
Company,
FeedbackVote,

View file

@ -0,0 +1,105 @@
import type { BindMode, DeploymentExposure, DeploymentMode } from "./constants.js";
export const LOOPBACK_BIND_HOST = "127.0.0.1";
export const ALL_INTERFACES_BIND_HOST = "0.0.0.0";
function normalizeHost(host: string | null | undefined): string | undefined {
const trimmed = host?.trim();
return trimmed ? trimmed : undefined;
}
export function isLoopbackHost(host: string | null | undefined): boolean {
const normalized = normalizeHost(host)?.toLowerCase();
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
}
export function isAllInterfacesHost(host: string | null | undefined): boolean {
const normalized = normalizeHost(host)?.toLowerCase();
return normalized === "0.0.0.0" || normalized === "::";
}
export function inferBindModeFromHost(
host: string | null | undefined,
opts?: { tailnetBindHost?: string | null | undefined },
): BindMode {
const normalized = normalizeHost(host);
const tailnetBindHost = normalizeHost(opts?.tailnetBindHost);
if (!normalized || isLoopbackHost(normalized)) return "loopback";
if (isAllInterfacesHost(normalized)) return "lan";
if (tailnetBindHost && normalized === tailnetBindHost) return "tailnet";
return "custom";
}
export function validateConfiguredBindMode(input: {
deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure;
bind?: BindMode | null | undefined;
host?: string | null | undefined;
customBindHost?: string | null | undefined;
}): string[] {
const bind = input.bind ?? inferBindModeFromHost(input.host);
const customBindHost = normalizeHost(input.customBindHost);
const errors: string[] = [];
if (input.deploymentMode === "local_trusted" && bind !== "loopback") {
errors.push("local_trusted requires server.bind=loopback");
}
if (bind === "custom" && !customBindHost) {
const legacyHost = normalizeHost(input.host);
if (!legacyHost || isLoopbackHost(legacyHost) || isAllInterfacesHost(legacyHost)) {
errors.push("server.customBindHost is required when server.bind=custom");
}
}
if (input.deploymentMode === "authenticated" && input.deploymentExposure === "public" && bind === "tailnet") {
errors.push("server.bind=tailnet is only supported for authenticated/private deployments");
}
return errors;
}
export function resolveRuntimeBind(input: {
bind?: BindMode | null | undefined;
host?: string | null | undefined;
customBindHost?: string | null | undefined;
tailnetBindHost?: string | null | undefined;
}): {
bind: BindMode;
host: string;
customBindHost?: string;
errors: string[];
} {
const bind = input.bind ?? inferBindModeFromHost(input.host, { tailnetBindHost: input.tailnetBindHost });
const legacyHost = normalizeHost(input.host);
const customBindHost =
normalizeHost(input.customBindHost) ??
(bind === "custom" && legacyHost && !isLoopbackHost(legacyHost) && !isAllInterfacesHost(legacyHost)
? legacyHost
: undefined);
switch (bind) {
case "loopback":
return { bind, host: LOOPBACK_BIND_HOST, customBindHost, errors: [] };
case "lan":
return { bind, host: ALL_INTERFACES_BIND_HOST, customBindHost, errors: [] };
case "custom":
return customBindHost
? { bind, host: customBindHost, customBindHost, errors: [] }
: { bind, host: legacyHost ?? LOOPBACK_BIND_HOST, errors: ["server.customBindHost is required when server.bind=custom"] };
case "tailnet": {
const tailnetBindHost = normalizeHost(input.tailnetBindHost);
return tailnetBindHost
? { bind, host: tailnetBindHost, customBindHost, errors: [] }
: {
bind,
host: legacyHost ?? LOOPBACK_BIND_HOST,
customBindHost,
errors: [
"server.bind=tailnet requires a detected Tailscale address or PAPERCLIP_TAILNET_BIND_HOST",
],
};
}
}
}