Refactor VPN proxy client implementation
Build and Deploy Gateway / build-and-push (push) Failing after 2s
Build and Deploy Gateway / deploy (push) Has been skipped

This commit is contained in:
2026-08-09 00:41:52 +03:00
parent 32be4380a3
commit 34d8b681ad
190 changed files with 20064 additions and 9761 deletions
+2 -1
View File
@@ -10,6 +10,7 @@ GIT_REF="$(git rev-parse --short HEAD 2>/dev/null || echo manual)"
IMAGE_TAG="${IMAGE_TAG:-${GIT_REF}-$(date +%Y%m%d%H%M%S)}"
GATEWAY_IMAGE="${GATEWAY_IMAGE:-${IMAGE_NAME}:${IMAGE_TAG}}"
BASE_IMAGE="${BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
NODE_BUILD_IMAGE="${NODE_BUILD_IMAGE:-node:20.19-alpine}"
RUNTIME_BASE_SOURCE_IMAGE="${RUNTIME_BASE_SOURCE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
SINGBOX_VERSION="${SINGBOX_VERSION:-1.12.13}"
DOCKER_BUILD_PULL="${DOCKER_BUILD_PULL:-false}"
@@ -62,7 +63,7 @@ else
fi
echo "Building image on ${BUILD_HOST}"
BUILD_COMMAND="set -e; echo 'Docker context:' \$(docker context show 2>/dev/null || true); docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'; cd '${BUILD_PATH}'; if ! docker image inspect '${BASE_IMAGE}' >/dev/null 2>&1; then if [ '${AUTO_BUILD_RUNTIME_BASE}' = 'true' ]; then echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}; building it now.'; BASE_IMAGE='${RUNTIME_BASE_SOURCE_IMAGE}' RUNTIME_BASE_IMAGE='${BASE_IMAGE}' SINGBOX_VERSION='${SINGBOX_VERSION}' ./scripts/build-runtime-base.sh; else echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}.'; echo 'Seed it once with: ./scripts/build-runtime-base.sh'; exit 1; fi; fi; npm ci && npm run build && docker build --pull='${DOCKER_BUILD_PULL}' --build-arg BASE_IMAGE='${BASE_IMAGE}' --build-arg SINGBOX_VERSION='${SINGBOX_VERSION}' --build-arg INSTALL_RUNTIME_DEPS='${INSTALL_RUNTIME_DEPS}' --build-arg INSTALL_SINGBOX='${INSTALL_SINGBOX}' -t '${GATEWAY_IMAGE}' ."
BUILD_COMMAND="set -e; echo 'Docker context:' \$(docker context show 2>/dev/null || true); docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'; cd '${BUILD_PATH}'; if ! docker image inspect '${BASE_IMAGE}' >/dev/null 2>&1; then if [ '${AUTO_BUILD_RUNTIME_BASE}' = 'true' ]; then echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}; building it now.'; BASE_IMAGE='${RUNTIME_BASE_SOURCE_IMAGE}' RUNTIME_BASE_IMAGE='${BASE_IMAGE}' SINGBOX_VERSION='${SINGBOX_VERSION}' ./scripts/build-runtime-base.sh; else echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}.'; echo 'Seed it once with: ./scripts/build-runtime-base.sh'; exit 1; fi; fi; npm ci && npm run build:production && docker build --pull='${DOCKER_BUILD_PULL}' --build-arg NODE_BUILD_IMAGE='${NODE_BUILD_IMAGE}' --build-arg BASE_IMAGE='${BASE_IMAGE}' --build-arg SINGBOX_VERSION='${SINGBOX_VERSION}' --build-arg INSTALL_RUNTIME_DEPS='${INSTALL_RUNTIME_DEPS}' --build-arg INSTALL_SINGBOX='${INSTALL_SINGBOX}' -t '${GATEWAY_IMAGE}' ."
if [ "${BUILD_HOST}" = "local" ]; then
bash -lc "${BUILD_COMMAND}"
else
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { SyntaxKind } from 'typescript/unstable/ast';
import { createScanner } from 'typescript/unstable/ast/scanner';
const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
function normalized(value) {
return value.replaceAll(path.sep, '/').replace(/^\.\//, '');
}
function relativeTarget(importer, specifier) {
if (!specifier.startsWith('.')) return null;
return normalized(path.posix.normalize(path.posix.join(path.posix.dirname(importer), specifier)));
}
function featurePath(file) {
const match = /^src\/(server|web)\/features\/([^/]+)(?:\/(.*))?$/.exec(file);
return match ? { layer: match[1], name: match[2], privatePath: match[3] || '' } : null;
}
export function importBoundaryViolation(importerValue, specifier) {
const importer = normalized(importerValue);
const target = relativeTarget(importer, specifier);
if (!target) return null;
if (importer.startsWith('src/shared/') && /^src\/(server|web)\//.test(target)) {
return 'shared cannot import server or web';
}
if (importer.startsWith('src/server/') && target.startsWith('src/web/')) {
return 'server cannot import web';
}
if (importer.startsWith('src/web/') && target.startsWith('src/server/')) {
return 'web cannot import server';
}
if (importer.startsWith('src/server/http/') && target.startsWith('src/server/infrastructure/')) {
return 'server/http cannot import infrastructure directly';
}
if (importer.startsWith('src/server/features/') && target.startsWith('src/server/http/')) {
return 'server/features cannot import http';
}
if (importer.startsWith('src/web/ui/')
&& /^src\/web\/(?:features(?:\/|$)|api(?:\/|\.[cm]?[jt]sx?$|$))/.test(target)) {
return 'web/ui cannot import api or features';
}
const fromFeature = featurePath(importer);
const toFeature = featurePath(target);
if (fromFeature && toFeature
&& fromFeature.layer === toFeature.layer
&& fromFeature.name !== toFeature.name
&& toFeature.privatePath
&& !/^index(?:\.[cm]?[jt]sx?)?$/.test(toFeature.privatePath)) {
return 'cross-feature imports must use the feature index';
}
return null;
}
function filesUnder(directory) {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const absolute = path.join(directory, entry.name);
return entry.isDirectory() ? filesUnder(absolute) : [absolute];
});
}
function importedSpecifiers(source) {
const specifiers = [];
const scanner = createScanner(true, undefined, source);
const tokens = [];
for (let token = scanner.scan(); token !== SyntaxKind.EndOfFile; token = scanner.scan()) {
if (token === SyntaxKind.SlashToken) token = scanner.reScanSlashToken();
tokens.push({ kind: token, text: scanner.getTokenText(), value: scanner.getTokenValue() });
}
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
const previous = tokens[index - 1];
const next = tokens[index + 1];
const isProperty = previous?.kind === SyntaxKind.DotToken
|| previous?.kind === SyntaxKind.QuestionDotToken;
if (token.text === 'import' && !isProperty) {
if (next?.kind === SyntaxKind.StringLiteral) {
specifiers.push(next.value);
} else if (next?.kind === SyntaxKind.OpenParenToken) {
const argument = tokens[index + 2];
if (argument?.kind === SyntaxKind.StringLiteral) specifiers.push(argument.value);
} else if (next?.kind === SyntaxKind.OpenBraceToken
|| next?.kind === SyntaxKind.AsteriskToken
|| next?.kind === SyntaxKind.Identifier
|| next?.text === 'type') {
for (let cursor = index + 1; cursor < tokens.length; cursor += 1) {
if (tokens[cursor].kind === SyntaxKind.SemicolonToken) break;
if (tokens[cursor].text === 'from'
&& tokens[cursor + 1]?.kind === SyntaxKind.StringLiteral) {
specifiers.push(tokens[cursor + 1].value);
break;
}
}
}
} else if (token.text === 'export' && !isProperty) {
if (next?.kind !== SyntaxKind.OpenBraceToken
&& next?.kind !== SyntaxKind.AsteriskToken
&& next?.text !== 'type') continue;
for (let cursor = index + 1; cursor < tokens.length; cursor += 1) {
if (tokens[cursor].kind === SyntaxKind.SemicolonToken) break;
if (tokens[cursor].text === 'from'
&& tokens[cursor + 1]?.kind === SyntaxKind.StringLiteral) {
specifiers.push(tokens[cursor + 1].value);
break;
}
}
} else if (token.text === 'require' && !isProperty) {
if (next?.kind === SyntaxKind.OpenParenToken
&& tokens[index + 2]?.kind === SyntaxKind.StringLiteral) {
specifiers.push(tokens[index + 2].value);
}
}
}
return specifiers;
}
export function checkImportBoundaries(repositoryRoot = root) {
const sourceRoot = path.join(repositoryRoot, 'src');
const files = filesUnder(sourceRoot).filter((file) => SOURCE_FILE.test(file));
const violations = [];
for (const file of files) {
const importer = normalized(path.relative(repositoryRoot, file));
const source = fs.readFileSync(file, 'utf8');
for (const specifier of importedSpecifiers(source)) {
const rule = importBoundaryViolation(importer, specifier);
if (rule) violations.push({ importer, specifier, rule });
}
}
return { filesChecked: files.length, violations };
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const result = checkImportBoundaries();
if (result.violations.length) {
for (const violation of result.violations) {
console.error(`${violation.importer}: ${violation.rule} (${violation.specifier})`);
}
process.exitCode = 1;
} else {
console.log(`Import boundaries: ${result.filesChecked} files checked.`);
}
}
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
fs.rmSync(path.resolve('.test-dist'), { recursive: true, force: true });
+35 -7
View File
@@ -3,10 +3,10 @@ import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { parseVersion, versionCompatibility } from '../src/shared/versions.js';
const COMPONENTS = ['macClient', 'gatewayClient', 'gatewayBackend'];
const VERSION_FILE = 'src/shared/versions.js';
const VERSION_FILE = 'src/shared/versions.ts';
const LEGACY_VERSION_FILE = 'src/shared/versions.js';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const aliases = {
mac: 'macClient',
@@ -17,6 +17,28 @@ const aliases = {
'gateway-backend': 'gatewayBackend',
};
export function parseVersion(value) {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(value || ''));
return match ? {
major: Number(match[1]),
minor: Number(match[2]),
hotfix: Number(match[3]),
} : null;
}
export function versionCompatibility(versions) {
const mac = parseVersion(versions?.macClient);
const client = parseVersion(versions?.gatewayClient);
const backend = parseVersion(versions?.gatewayBackend);
const major = Boolean(mac && client && backend
&& mac.major === client.major
&& client.major === backend.major);
const gateway = Boolean(client && backend
&& client.major === backend.major
&& client.minor === backend.minor);
return { compatible: major && gateway, major, gateway };
}
export function versionsFromSource(source) {
return Object.fromEntries(COMPONENTS.map((component) => {
const match = new RegExp(`${component}:\\s*'(\\d+\\.\\d+\\.\\d+)'`).exec(source);
@@ -30,14 +52,16 @@ export function affectedComponents(files) {
const add = (...components) => components.forEach((component) => affected.add(component));
for (const file of files) {
if (file === VERSION_FILE) continue;
if (/^(package-lock\.json|src\/shared\/)/.test(file)) add(...COMPONENTS);
else if (/^(src\/web\/|public\/|index\.html$|vite\.config\.js$)/.test(file)) {
if (/^(?:\.dockerignore$|package(?:-lock)?\.json$|tsconfig\.base\.json$|src\/shared\/)/.test(file)) add(...COMPONENTS);
else if (/^(src\/web\/|public\/|index\.html$|tsconfig\.web\.json$|vite\.config\.[cm]?[jt]s$)/.test(file)) {
add('macClient', 'gatewayClient');
} else if (/^src\/server\//.test(file)) add('macClient', 'gatewayBackend');
} else if (/^(src\/server\/|tsconfig\.server\.json$)/.test(file)) add('macClient', 'gatewayBackend');
else if (/^(install\.sh|Dockerfile\.client|docker-compose\.client(\.local)?\.yml|entrypoint\.client\.sh|scripts\/(install-macos-client|harbor-network-monitor)\.sh)$/.test(file)) {
add('macClient');
} else if (/^(Dockerfile|Dockerfile\.runtime-base|docker-compose\.gateway\.yml|entrypoint\.sh|scripts\/(deploy-gateway|build-runtime-base|build-on-107-deploy-111)\.sh)$/.test(file)) {
add('gatewayBackend');
} else if (/^(\.gitea\/workflows\/gateway-build\.yml|scripts\/runtime-impact\.mjs)$/.test(file)) {
add('gatewayBackend');
}
}
return COMPONENTS.filter((component) => affected.has(component));
@@ -91,7 +115,7 @@ function git(args) {
}
function changedFiles(base) {
const tracked = git(['diff', '--name-only', base, '--']).split('\n');
const tracked = git(['diff', '--no-renames', '--name-only', base, '--']).split('\n');
const untracked = git(['ls-files', '--others', '--exclude-standard']).split('\n');
return [...new Set([...tracked, ...untracked].filter(Boolean))];
}
@@ -100,7 +124,11 @@ function baselineVersions(base) {
try {
return versionsFromSource(git(['show', `${base}:${VERSION_FILE}`]));
} catch {
return null;
try {
return versionsFromSource(git(['show', `${base}:${LEGACY_VERSION_FILE}`]));
} catch {
return null;
}
}
}
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const CODE_EXTENSION = String.raw`\.[cm]?[jt]sx?$`;
const noRuntimeImpact = [
/^\.codex\//,
/^docs\//,
/^test\//,
/^workpack\//,
/^(?:AGENTS|PRODUCT|README)\.md$/,
/^\.env\.example$/,
/^\.gitignore$/,
/^Dockerfile\.client$/,
/^docker-compose\.client(?:\.local)?\.yml$/,
/^entrypoint\.client\.sh$/,
/^install\.sh$/,
/^scripts\/(?:check-import-boundaries\.mjs|clean-test-dist\.mjs|harbor-network-monitor\.sh|harbor-version\.mjs|install-macos-client\.sh)$/,
];
const foundation = [
/^\.dockerignore$/,
/^\.gitea\/workflows\//,
/^Dockerfile(?:\.runtime-base)?$/,
/^docker-compose\.gateway\.yml$/,
/^entrypoint\.sh$/,
/^package(?:-lock)?\.json$/,
/^scripts\/(?:build-on-107-deploy-111|build-runtime-base|deploy-gateway)\.sh$/,
/^scripts\/runtime-impact\.mjs$/,
/^tsconfig(?:\.[^.]+)?\.json$/,
];
const controlAndDataplane = [
new RegExp(`^src/server/main${CODE_EXTENSION}`),
new RegExp(`^src/server/(?:config|gatewayRouting|singbox|singboxRuntime|version)${CODE_EXTENSION}`),
new RegExp(`^src/server/adapters/neighbors${CODE_EXTENSION}`),
new RegExp(`^src/server/services/(?:connectivityDiagnosticsService|deviceInventoryService|devicePolicyService)${CODE_EXTENSION}`),
new RegExp(`^src/shared/(?:connectivityDiagnostics|errors)${CODE_EXTENSION}`),
/^src\/server\/infrastructure\/dataplane\//,
];
const dataplane = [
new RegExp(`^src/server/dataplane${CODE_EXTENSION}`),
new RegExp(`^src/server/services/(?:deviceTrafficService|domainTrafficService)${CODE_EXTENSION}`),
];
const control = [
/^index\.html$/,
/^monitoring\//,
/^public\//,
/^src\/server\//,
/^src\/shared\//,
/^src\/web\//,
/^vite\.config\.[cm]?[jt]s$/,
];
function matchesAny(file, patterns) {
return patterns.some((pattern) => pattern.test(file));
}
function normalizeFile(file) {
return file.trim().replaceAll('\\', '/').replace(/^\.\//, '');
}
export function classifyRuntimeImpact(files) {
const affected = new Set();
for (const value of files) {
const file = normalizeFile(value);
if (!file || matchesAny(file, noRuntimeImpact)) continue;
if (matchesAny(file, foundation) || matchesAny(file, controlAndDataplane)) {
affected.add('control');
affected.add('dataplane');
} else if (matchesAny(file, dataplane)) {
affected.add('dataplane');
} else if (matchesAny(file, control)) {
affected.add('control');
} else {
throw new Error(`Unclassified path: ${file}`);
}
}
const affectedComponents = ['control', 'dataplane'].filter((component) => affected.has(component));
const restartScope = affected.has('dataplane') ? 'both' : affected.has('control') ? 'control' : 'none';
return { affectedComponents, restartScope };
}
function formatImpact(impact) {
return [
`affected-components=${impact.affectedComponents.join('+') || 'none'}`,
`restart-scope=${impact.restartScope}`,
].join('\n');
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
const args = process.argv.slice(2);
const files = args.includes('--stdin')
? fs.readFileSync(0, 'utf8').split(/\r?\n/)
: args;
console.log(formatImpact(classifyRuntimeImpact(files)));
} catch (error) {
console.error(`[runtime-impact] ${error.message}`);
process.exitCode = 1;
}
}