Compare commits

...

11 Commits

Author SHA1 Message Date
Owen Schwartz
a76c680914 Merge pull request #3267 from Josh-Voyles/memfix1-1.18.4
fix(sqlite): revert no-op finalize wrapper and aggressive PRAGMAs (#2120)
2026-06-22 07:35:56 -07:00
Owen Schwartz
4fd3d6078e Merge pull request #3296 from fosrl/copilot/public-resource-policy-asn-bug-fix
Allow `ALL` / `AS0` ASN values in public resource policy rules
2026-06-22 07:20:17 -07:00
Owen
bc28290d8e Convert things to regional cache 2026-06-21 16:01:46 -04:00
Owen
241610579c Show the input validation in the error report 2026-06-19 13:02:38 -04:00
copilot-swe-agent[bot]
7c15c428b3 test: add normalized ASN validation coverage 2026-06-16 23:48:28 +00:00
copilot-swe-agent[bot]
f3a52e31d1 refactor: normalize ASN validation value once 2026-06-16 23:46:44 +00:00
copilot-swe-agent[bot]
5e26ceaf02 fix: allow ALL ASN values in policy rule validation 2026-06-16 23:44:35 +00:00
copilot-swe-agent[bot]
d6fe357fcb Initial plan 2026-06-16 23:39:56 +00:00
Owen
f9cc52ece9 Remove NoNewPrivileges
Fixes https://github.com/fosrl/newt/issues/383
2026-06-14 15:02:18 -07:00
Josh Voyles
522ca671b5 fix: remove no-op autoFinalizeStatement wrapper and redundant busy_timeout (#2120)
better-sqlite3 11.x exposes no Statement.finalize() — the wrapper threw and
swallowed a TypeError on every query (verified: 'Statement.finalize exists:
undefined' in the runner image) while adding +122% per-statement overhead
(3.90 -> 8.66 us/op, 200k-op in-container microbench) and freeing nothing.
Statement lifecycle is GC-managed by the driver; drizzle-orm prepares fresh
per query, so nothing accumulates unbounded.

busy_timeout=5000 duplicates better-sqlite3's default timeout option, which
already arms sqlite3_busy_timeout(db, 5000) at open (lib/database.js).

With ENABLE_SQLITE_WAL_MODE unset the driver is now runtime-identical to
pre-1.18.3 (zero pragmas). The env-gated WAL block stays: journal_mode is
sticky in the DB file, so removing it would strand opted-in databases on
WAL+synchronous=FULL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 14:53:16 -04:00
Josh Voyles
d94af2b8ea fix(sqlite): remove cache_size and mmap_size PRAGMAs (#2120)
A 64 MB page cache plus a 256 MB memory-mapped region inflate RSS and
cause page-cache thrashing on small (~1 GB) instances. The PRAGMAs were
added to reduce event-loop blocking on TraefikConfigManager JOINs but
the memory cost outweighs the I/O benefit on the deployment shapes that
hit #2120. Leave SQLite on its conservative defaults.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-13 14:53:07 -04:00
14 changed files with 97 additions and 78 deletions

View File

@@ -1,6 +1,5 @@
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3"; import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import type BetterSqlite3 from "better-sqlite3";
import * as schema from "./schema/schema"; import * as schema from "./schema/schema";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
@@ -12,68 +11,31 @@ export const exists = checkFileExists(location);
bootstrapVolume(); bootstrapVolume();
/**
* Wraps better-sqlite3 Statement to call `finalize()` immediately after
* execution, freeing native sqlite3_stmt memory deterministically instead
* of waiting for GC. Fixes steady off-heap growth under load (#2120).
* WARNING: Finalizes after first execution — incompatible with drizzle's
* reusable .prepare() builders. No such usage exists in this codebase.
*/
function autoFinalizeStatement(
stmt: BetterSqlite3.Statement
): BetterSqlite3.Statement {
const wrapExec = <T extends (...args: any[]) => any>(fn: T): T => {
return function (this: any, ...args: any[]) {
try {
return fn.apply(this, args);
} finally {
try {
// finalize() exists on the native Statement at runtime but
// is missing from @types/better-sqlite3.
(stmt as any).finalize();
} catch {
// Already finalized — harmless
}
}
} as unknown as T;
};
stmt.run = wrapExec(stmt.run);
stmt.get = wrapExec(stmt.get);
stmt.all = wrapExec(stmt.all);
return stmt;
}
function createDb() { function createDb() {
const sqlite = new Database(location); const sqlite = new Database(location);
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") { if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
// Enable WAL mode — allows concurrent readers + single writer, preventing // Enable WAL mode — allows concurrent readers + single writer, preventing
// contention across subsystems (verifySession, Traefik, audit, ping). // contention across subsystems (verifySession, Traefik, audit, ping).
// NOTE: journal_mode persists in the DB file once set; unsetting this
// env var does NOT revert an existing WAL database.
sqlite.pragma("journal_mode = WAL"); sqlite.pragma("journal_mode = WAL");
// NORMAL sync mode: safe with WAL, reduces write lock hold time. // NORMAL sync mode: safe with WAL, reduces write lock hold time.
sqlite.pragma("synchronous = NORMAL"); sqlite.pragma("synchronous = NORMAL");
} }
// Wait up to 5s on SQLITE_BUSY instead of failing — prevents audit log // No busy_timeout pragma: better-sqlite3 already arms
// retry loops that accumulate memory. // sqlite3_busy_timeout(db, 5000) via its default `timeout` option
sqlite.pragma("busy_timeout = 5000"); // (lib/database.js), so an explicit pragma is redundant.
// 64 MB page cache (default 2 MB) — reduces I/O round-trips on large // Intentionally NOT setting cache_size or mmap_size: a large page cache plus
// TraefikConfigManager JOINs that block the event loop. // a multi-hundred-MB mmap region inflate RSS and cause page-cache thrashing
sqlite.pragma("cache_size = -65536"); // on small (~1 GB) instances. Leave SQLite on its conservative defaults.
// 256 MB memory-mapped I/O — OS serves reads from page cache directly, // Intentionally NOT wrapping prepare()/statements: better-sqlite3 finalizes
// reducing event-loop blocking. // sqlite3_stmt in the Statement destructor at GC, and drizzle-orm prepares a
sqlite.pragma("mmap_size = 268435456"); // fresh statement per query (no statement cache), so statements cannot
// accumulate. better-sqlite3 11.x exposes no Statement.finalize() at all.
// Wrap prepare() so every drizzle-orm statement is auto-finalized after
// first use, preventing sqlite3_stmt accumulation between GC cycles.
const originalPrepare = sqlite.prepare.bind(sqlite);
(sqlite as any).prepare = function autoFinalizePrepare(source: string) {
return autoFinalizeStatement(originalPrepare(source));
};
return DrizzleSqlite(sqlite, { return DrizzleSqlite(sqlite, {
schema schema

View File

@@ -12,7 +12,7 @@ import {
import { FeatureId, getFeatureMeterId } from "./features"; import { FeatureId, getFeatureMeterId } from "./features";
import logger from "@server/logger"; import logger from "@server/logger";
import { build } from "@server/build"; import { build } from "@server/build";
import cache from "#dynamic/lib/cache"; import { regionalCache as cache } from "#dynamic/lib/cache";
export function noop() { export function noop() {
if (build !== "saas") { if (build !== "saas") {
@@ -22,7 +22,6 @@ export function noop() {
} }
export class UsageService { export class UsageService {
constructor() { constructor() {
if (noop()) { if (noop()) {
return; return;
@@ -57,7 +56,10 @@ export class UsageService {
try { try {
let usage; let usage;
if (transaction) { if (transaction) {
const orgIdToUse = await this.getBillingOrg(orgId, transaction); const orgIdToUse = await this.getBillingOrg(
orgId,
transaction
);
usage = await this.internalAddUsage( usage = await this.internalAddUsage(
orgIdToUse, orgIdToUse,
featureId, featureId,

View File

@@ -48,18 +48,18 @@ export async function applyBlueprint({
name, name,
source = "API" source = "API"
}: ApplyBlueprintArgs): Promise<Blueprint> { }: ApplyBlueprintArgs): Promise<Blueprint> {
// Validate the input data
const validationResult = ConfigSchema.safeParse(configData);
if (!validationResult.success) {
throw new Error(fromError(validationResult.error).toString());
}
const config: Config = validationResult.data;
let blueprintSucceeded: boolean = false; let blueprintSucceeded: boolean = false;
let blueprintMessage: string; let blueprintMessage = "";
let error: any | null = null; let error: any | null = null;
try { try {
const validationResult = ConfigSchema.safeParse(configData);
if (!validationResult.success) {
throw new Error(fromError(validationResult.error).toString());
}
const config: Config = validationResult.data;
let proxyResourcesResults: PublicResourcesResults = []; let proxyResourcesResults: PublicResourcesResults = [];
let clientResourcesResults: ClientResourcesResults = []; let clientResourcesResults: ClientResourcesResults = [];
await db.transaction(async (trx) => { await db.transaction(async (trx) => {

View File

@@ -1,4 +1,7 @@
import { isValidUrlGlobPattern } from "./validators"; import {
getResourceRuleValueValidationError,
isValidUrlGlobPattern
} from "./validators";
import { assertEquals } from "@test/assert"; import { assertEquals } from "@test/assert";
function runTests() { function runTests() {
@@ -236,6 +239,43 @@ function runTests() {
"Path with isolated percent sign should be invalid" "Path with isolated percent sign should be invalid"
); );
// ASN validation tests
assertEquals(
getResourceRuleValueValidationError("ASN", "AS15169"),
null,
"Standard ASN should be valid"
);
assertEquals(
getResourceRuleValueValidationError("ASN", " As15169 "),
null,
"Standard ASN should be valid with mixed case and whitespace"
);
assertEquals(
getResourceRuleValueValidationError("ASN", "ALL"),
null,
"ALL ASN selector should be valid"
);
assertEquals(
getResourceRuleValueValidationError("ASN", " all "),
null,
"ALL ASN selector should be valid with mixed case and whitespace"
);
assertEquals(
getResourceRuleValueValidationError("ASN", "AS0"),
null,
"AS0 alias should be valid"
);
assertEquals(
getResourceRuleValueValidationError("ASN", " as0 "),
null,
"AS0 alias should be valid with mixed case and whitespace"
);
assertEquals(
getResourceRuleValueValidationError("ASN", "not-an-asn"),
"Invalid ASN provided",
"Invalid ASN should return an error"
);
console.log("All tests passed!"); console.log("All tests passed!");
} }

View File

@@ -100,7 +100,10 @@ export function getResourceRuleValueValidationError(
? null ? null
: "Invalid country code provided"; : "Invalid country code provided";
case "ASN": case "ASN":
return /^AS\d+$/i.test(value.trim()) const normalizedValue = value.trim().toUpperCase();
return /^AS\d+$/.test(normalizedValue) ||
normalizedValue === "ALL" ||
normalizedValue === "AS0"
? null ? null
: "Invalid ASN provided"; : "Invalid ASN provided";
default: default:

View File

@@ -17,7 +17,7 @@ import { certificates, db } from "@server/db";
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm"; import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
import { decrypt } from "@server/lib/crypto"; import { decrypt } from "@server/lib/crypto";
import logger from "@server/logger"; import logger from "@server/logger";
import cache from "#private/lib/cache"; import { regionalCache as cache } from "#private/lib/cache";
import { build } from "@server/build"; import { build } from "@server/build";
// Define the return type for clarity and type safety // Define the return type for clarity and type safety

View File

@@ -22,7 +22,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types"; import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
import cache from "#private/lib/cache"; import { regionalCache as cache } from "#private/lib/cache";
import semver from "semver"; import semver from "semver";
let stalePangolinNodeVersion: string | null = null; let stalePangolinNodeVersion: string | null = null;

View File

@@ -10,7 +10,7 @@ import { verifyPassword } from "@server/auth/password";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import logger from "@server/logger"; import logger from "@server/logger";
import cache from "#dynamic/lib/cache"; import { regionalCache as cache } from "#dynamic/lib/cache";
import config from "@server/lib/config"; import config from "@server/lib/config";
// Stale-while-revalidate in-memory fallback for the releases API. // Stale-while-revalidate in-memory fallback for the releases API.

View File

@@ -2,7 +2,7 @@ import { MessageHandler } from "@server/routers/ws";
import logger from "@server/logger"; import logger from "@server/logger";
import { Newt } from "@server/db"; import { Newt } from "@server/db";
import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint"; import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint";
import cache from "#dynamic/lib/cache"; import cache from "#dynamic/lib/cache"; // not using regional here because we dont know where the site is
export const handleDockerStatusMessage: MessageHandler = async (context) => { export const handleDockerStatusMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context; const { message, client, sendToClient } = context;

View File

@@ -20,7 +20,7 @@ import { handleFingerprintInsertion } from "./fingerprintingUtils";
import { build } from "@server/build"; import { build } from "@server/build";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config"; import config from "@server/lib/config";
import cache from "#dynamic/lib/cache"; import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is
const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18; const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18;
const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800; const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800;

View File

@@ -15,8 +15,7 @@ import logger from "@server/logger";
import { z } from "zod"; import { z } from "zod";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import type { PaginatedResponse } from "@server/types/Pagination"; import type { PaginatedResponse } from "@server/types/Pagination";
import { OpenAPITags, registry } from "@server/openApi"; import { regionalCache as cache } from "#dynamic/lib/cache";
import { localCache } from "#dynamic/lib/cache";
const USER_RESOURCE_ALIASES_CACHE_TTL_SEC = 60; const USER_RESOURCE_ALIASES_CACHE_TTL_SEC = 60;
@@ -153,7 +152,7 @@ export async function listUserResourceAliases(
pageSize pageSize
); );
const cachedData: ListUserResourceAliasesResponse | undefined = const cachedData: ListUserResourceAliasesResponse | undefined =
localCache.get(cacheKey); await cache.get(cacheKey);
if (cachedData) { if (cachedData) {
return response<ListUserResourceAliasesResponse>(res, { return response<ListUserResourceAliasesResponse>(res, {
@@ -211,7 +210,11 @@ export async function listUserResourceAliases(
page page
} }
}; };
localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC); await cache.set(
cacheKey,
data,
USER_RESOURCE_ALIASES_CACHE_TTL_SEC
);
return response<ListUserResourceAliasesResponse>(res, { return response<ListUserResourceAliasesResponse>(res, {
data, data,
success: true, success: true,
@@ -256,7 +259,7 @@ export async function listUserResourceAliases(
page page
} }
}; };
localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC); await cache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC);
return response<ListUserResourceAliasesResponse>(res, { return response<ListUserResourceAliasesResponse>(res, {
data, data,

View File

@@ -14,7 +14,7 @@ import {
siteLabels, siteLabels,
type Label type Label
} from "@server/db"; } from "@server/db";
import cache from "#dynamic/lib/cache"; import { regionalCache as cache } from "#dynamic/lib/cache";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";

View File

@@ -139,7 +139,6 @@ Restart=always
RestartSec=2 RestartSec=2
UMask=0077 UMask=0077
NoNewPrivileges=true
PrivateTmp=true PrivateTmp=true
[Install] [Install]

View File

@@ -83,9 +83,19 @@ export function createPolicyRuleValueSchema(t: TranslateFn, match: string) {
{ message: t("rulesErrorInvalidCountryDescription") } { message: t("rulesErrorInvalidCountryDescription") }
); );
case "ASN": case "ASN":
return required.refine((value) => /^AS\d+$/i.test(value.trim()), { return required.refine(
message: t("rulesErrorInvalidAsnDescription") (value) => {
}); const normalizedValue = value.trim().toUpperCase();
return (
/^AS\d+$/.test(normalizedValue) ||
normalizedValue === "ALL" ||
normalizedValue === "AS0"
);
},
{
message: t("rulesErrorInvalidAsnDescription")
}
);
default: default:
return required; return required;
} }