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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ LOCAL_STUDIO_API_KEY=
# Only set this to true on trusted LANs.
# LOCAL_STUDIO_ALLOW_UNAUTHENTICATED=false

# Exact request hostnames/IPs accepted by a keyless controller. Required for
# wildcard binds; ports, schemes, paths, credentials, and wildcards are rejected.
# LOCAL_STUDIO_ALLOWED_HOSTS=studio.lan,192.168.1.10

# Optional browser allowlist for direct controller access (comma-separated origins)
# LOCAL_STUDIO_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000

Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
push:
branches: [main]

permissions:
contents: read

jobs:
gates:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -56,3 +59,12 @@ jobs:
- name: Production quality gate
working-directory: ./frontend
run: npm run check:quality

release:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [gates, controller, frontend]
permissions:
contents: write
issues: write
pull-requests: write
uses: ./.github/workflows/release.yml
32 changes: 20 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,39 +1,47 @@
name: Release

on:
workflow_run:
workflows: [CI]
types: [completed]
branches: [main]
workflow_call:

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
queue: max

permissions:
contents: write
issues: write
pull-requests: write
contents: read

jobs:
release:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }}

- name: Select tested main revision
run: git checkout -B main "${{ github.event.workflow_run.head_sha }}"
ref: ${{ github.sha }}

- uses: actions/setup-node@v4
with:
node-version: 22.19.0

- name: Verify tested main revision
id: revision
env:
TESTED_SHA: ${{ github.sha }}
run: node scripts/release-revision.mjs

- name: Configure git author
if: steps.revision.outputs.current == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

- name: Release
if: steps.revision.outputs.current == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,10 @@ same way in production as in development: `bun src/main.ts`.
The controller binds `127.0.0.1` by default. Binding a non-loopback host (e.g.
`LOCAL_STUDIO_HOST=0.0.0.0`) requires `LOCAL_STUDIO_API_KEY` — startup throws
without it. On a trusted LAN you may instead set
`LOCAL_STUDIO_ALLOW_UNAUTHENTICATED=true` to opt out of authentication.
`LOCAL_STUDIO_ALLOW_UNAUTHENTICATED=true` to opt out of authentication. Keyless
wildcard binds also require `LOCAL_STUDIO_ALLOWED_HOSTS` with the exact hostnames
or IP addresses clients use. Set `LOCAL_STUDIO_CORS_ORIGINS` to the exact HTTP(S)
frontend origins allowed to call the controller from a browser.

Point the frontend at a remote controller with `BACKEND_URL` or
`NEXT_PUBLIC_API_URL` (default `http://localhost:8080`).
Expand Down
149 changes: 132 additions & 17 deletions controller/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { config as loadEnvironment } from "dotenv";
import { Schema } from "effect";
import { existsSync } from "node:fs";
import { isIP } from "node:net";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
Expand All @@ -13,6 +14,7 @@ export interface Config {
host: string;
port: number;
api_key?: string;
allowed_hosts?: string[];
cors_origins?: string[];
inference_host: string;
inference_port: number;
Expand Down Expand Up @@ -44,6 +46,120 @@ export const loadDotEnvironment = (): string | undefined => {
const defaultModelsDirectory = (): string =>
process.platform === "win32" ? join(homedir(), "models") : "/models";

const hostnamePattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
const numericIpv4Pattern = /^(?:0x[0-9a-f]+|[0-9]+)(?:\.(?:0x[0-9a-f]+|[0-9]+))*$/;
const loopbackHosts = ["localhost", "127.0.0.1", "::1", "host.docker.internal"];

const normalizedIp = (value: string): string | null => {
const version = isIP(value);
if (version === 4) return value;
if (version !== 6) return null;
try {
return new URL(`http://[${value}]`).hostname.slice(1, -1).toLowerCase();
} catch {
return null;
}
};

export const normalizeControllerHost = (value: string): string | null => {
const candidate = value.trim().toLowerCase();
const bracketed = candidate.startsWith("[") || candidate.endsWith("]");
if (bracketed && !(candidate.startsWith("[") && candidate.endsWith("]"))) return null;
const unwrapped = bracketed ? candidate.slice(1, -1) : candidate;
const ip = normalizedIp(unwrapped);
if (ip) return bracketed && isIP(unwrapped) !== 6 ? null : ip;
if (numericIpv4Pattern.test(unwrapped)) {
try {
const numericIp = new URL(`http://${unwrapped}`).hostname;
return isIP(numericIp) === 4 ? numericIp : null;
} catch {
return null;
}
}
if (
bracketed ||
candidate.length > 253 ||
candidate.endsWith(".") ||
candidate.includes(":") ||
candidate.includes("@") ||
candidate.includes("/") ||
candidate.includes("*")
) {
return null;
}
return candidate.split(".").every((label) => hostnamePattern.test(label)) ? candidate : null;
};

export const isLoopbackHost = (value: string): boolean => {
const normalized = normalizeControllerHost(value);
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
};

export const isWildcardHost = (value: string): boolean => {
const normalized = normalizeControllerHost(value);
return normalized === "0.0.0.0" || normalized === "::";
};

export const normalizeHttpOrigin = (value: string): string | null => {
try {
const parsed = new URL(value.trim());
if (
(parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
parsed.username ||
parsed.password ||
parsed.pathname !== "/" ||
parsed.search ||
parsed.hash ||
!normalizeControllerHost(parsed.hostname)
) {
return null;
}
return parsed.origin === "null" ? null : parsed.origin;
} catch {
return null;
}
};

const allowedHostSchema = Schema.String.check(
Schema.makeFilter(
(value) => {
const normalized = normalizeControllerHost(value);
return normalized !== null && !isWildcardHost(normalized);
},
{ expected: "an exact hostname or IP address" },
),
);
const allowedHostsSchema = Schema.Array(allowedHostSchema).check(Schema.isNonEmpty());

const decodeAllowedHosts = (value: string): string[] => {
try {
const entries = Schema.decodeUnknownSync(allowedHostsSchema)(value.split(","));
return [
...new Set(
entries.flatMap((entry) => {
const normalized = normalizeControllerHost(entry);
return normalized ? [normalized] : [];
}),
),
];
} catch {
throw new Error(
"LOCAL_STUDIO_ALLOWED_HOSTS must contain a nonempty comma-separated list of exact hostnames or IP addresses",
);
}
};

const defaultAllowedHosts = (host: string): string[] => {
const normalized = normalizeControllerHost(host);
if (!normalized) throw new Error("LOCAL_STUDIO_HOST must be a hostname or IP address");
if (isLoopbackHost(normalized)) return [...new Set([normalized, ...loopbackHosts])];
if (isWildcardHost(normalized)) return [];
return [normalized];
};

const parseAllowedHosts = (value: string | undefined, host: string): string[] =>
value?.trim() ? decodeAllowedHosts(value) : defaultAllowedHosts(host);

export const createConfig = (): Config => {
loadDotEnvironment();

Expand All @@ -52,26 +168,14 @@ export const createConfig = (): Config => {
const controllerRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const defaultDataDirectory = resolve(controllerRoot, "..", "data");

const isLoopbackHost = (value: string): boolean => {
const normalized = value.trim().toLowerCase();
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
};

const normalizeOrigin = (value: string): string | null => {
try {
const origin = new URL(value.trim()).origin;
return origin === "null" ? null : origin;
} catch {
return null;
}
};

const parseCorsOrigins = (value: string | undefined): string[] => {
const defaults = [
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://[::1]:3000",
"http://localhost:3001",
"http://127.0.0.1:3001",
"http://[::1]:3001",
"http://host.docker.internal:3000",
"http://host.docker.internal:3001",
];
Expand All @@ -80,7 +184,7 @@ export const createConfig = (): Config => {
return [
...new Set(
candidates
.map((entry) => normalizeOrigin(entry))
.map((entry) => normalizeHttpOrigin(entry))
.filter((entry): entry is string => Boolean(entry)),
),
];
Expand All @@ -91,6 +195,7 @@ export const createConfig = (): Config => {
LOCAL_STUDIO_PORT: positiveIntegerSchema,
LOCAL_STUDIO_API_KEY: Schema.optional(Schema.String),
LOCAL_STUDIO_ALLOW_UNAUTHENTICATED: Schema.optional(Schema.String),
LOCAL_STUDIO_ALLOWED_HOSTS: Schema.optional(Schema.String),
LOCAL_STUDIO_CORS_ORIGINS: Schema.optional(Schema.String),
LOCAL_STUDIO_INFERENCE_HOST: Schema.String,
LOCAL_STUDIO_INFERENCE_PORT: positiveIntegerSchema,
Expand Down Expand Up @@ -133,6 +238,10 @@ export const createConfig = (): Config => {
const databasePath = resolve(
parsed.LOCAL_STUDIO_DB_PATH ?? resolve(dataDirectory, "controller.db"),
);
const apiKey = parsed.LOCAL_STUDIO_API_KEY?.trim();
const allowedHosts = apiKey
? undefined
: parseAllowedHosts(parsed.LOCAL_STUDIO_ALLOWED_HOSTS, host);

const config: Config = {
host,
Expand All @@ -144,12 +253,13 @@ export const createConfig = (): Config => {
db_path: databasePath,
models_dir: resolve(parsed.LOCAL_STUDIO_MODELS_DIR),
strict_openai_models: strictOpenAIModelsEnabled,
...(allowedHosts ? { allowed_hosts: allowedHosts } : {}),
cors_origins: parseCorsOrigins(parsed.LOCAL_STUDIO_CORS_ORIGINS),
providers: [],
};

if (parsed.LOCAL_STUDIO_API_KEY) {
config.api_key = parsed.LOCAL_STUDIO_API_KEY;
if (apiKey) {
config.api_key = apiKey;
}

const allowUnauthenticated = parseBooleanFlag(parsed.LOCAL_STUDIO_ALLOW_UNAUTHENTICATED);
Expand All @@ -158,6 +268,11 @@ export const createConfig = (): Config => {
"LOCAL_STUDIO_API_KEY is required when binding the controller to a non-loopback host. Set LOCAL_STUDIO_ALLOW_UNAUTHENTICATED=true only for trusted local environments.",
);
}
if (!config.api_key && isWildcardHost(host) && allowedHosts?.length === 0) {
throw new Error(
"LOCAL_STUDIO_ALLOWED_HOSTS is required for a keyless wildcard controller bind",
);
}

if (parsed.LOCAL_STUDIO_SGLANG_PYTHON) {
config.sglang_python = parsed.LOCAL_STUDIO_SGLANG_PYTHON;
Expand Down
2 changes: 2 additions & 0 deletions controller/src/http/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { registerAudioRoutes } from "../modules/audio/routes";
import { registerSpeechRoutes } from "../modules/speech/routes";
import { documentRoute, mergeRoutes, type ControllerRouteApp } from "./route-registrar";
import {
createKeylessRequestGuardMiddleware,
createMutatingAuthMiddleware,
createMutatingRateLimitMiddleware,
createReadRateLimitMiddleware,
Expand Down Expand Up @@ -47,6 +48,7 @@ export const createApp = (
const allowedCorsOrigins = context.config.cors_origins ?? [];

app.use("*", controllerRuntimeMiddleware(runtime));
app.use("*", createKeylessRequestGuardMiddleware(context));

app.use(
"*",
Expand Down
Loading