Refactor VPN proxy client implementation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user