186 lines
8.4 KiB
JavaScript
186 lines
8.4 KiB
JavaScript
import { isIP } from 'node:net'
|
|
import { domainToASCII } from 'node:url'
|
|
import { ProxyConfigurationError } from './errors.mjs'
|
|
import { normalizeProxyRules, PLATFORM_NO_PROXY } from './rules.mjs'
|
|
import { OUTBOUND_PROXY_PORTS } from '../../../../platform/lib/outbound-proxy.mjs'
|
|
|
|
export const PROXY_SCHEMA = 1
|
|
export const PROXY_SCOPES = Object.freeze([
|
|
'updates', 'platform', 'dshCore', 'dshPlugins', 'agentNetwork', 'managementTerminal',
|
|
])
|
|
export const PROXY_PORTS = OUTBOUND_PROXY_PORTS
|
|
|
|
function deepFreeze(value) {
|
|
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
for (const child of Object.values(value)) deepFreeze(child)
|
|
Object.freeze(value)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function object(value, label) {
|
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new ProxyConfigurationError(`${label} must be an object`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function allowedKeys(value, allowed, label) {
|
|
const extras = Object.keys(value).filter(key => !allowed.includes(key))
|
|
if (extras.length > 0) throw new ProxyConfigurationError(`${label} contains unsupported fields: ${extras.sort().join(', ')}`)
|
|
}
|
|
|
|
function requiredKeys(value, required, label) {
|
|
const missing = required.filter(key => !(key in value))
|
|
if (missing.length > 0) throw new ProxyConfigurationError(`${label} is missing fields: ${missing.join(', ')}`)
|
|
}
|
|
|
|
function normalizeProxyHost(value) {
|
|
if (typeof value !== 'string' || value !== value.trim() || /[\u0000-\u0020\u007f\/@?#]/.test(value)) {
|
|
throw new ProxyConfigurationError('proxy host is invalid')
|
|
}
|
|
if (value === '') return ''
|
|
if (isIP(value) !== 0) return value.toLowerCase()
|
|
const ascii = domainToASCII(value.toLowerCase().replace(/\.$/, ''))
|
|
if (ascii === '' || ascii.length > 253 || ascii.split('.').some(part => part === '' || part.length > 63)) {
|
|
throw new ProxyConfigurationError('proxy host is invalid')
|
|
}
|
|
return ascii
|
|
}
|
|
|
|
function normalizeScopes(value) {
|
|
object(value, 'proxy scopes')
|
|
requiredKeys(value, PROXY_SCOPES, 'proxy scopes')
|
|
allowedKeys(value, PROXY_SCOPES, 'proxy scopes')
|
|
return Object.fromEntries(PROXY_SCOPES.map(scope => {
|
|
if (typeof value[scope] !== 'boolean') throw new ProxyConfigurationError(`proxy scope ${scope} must be boolean`)
|
|
return [scope, value[scope]]
|
|
}))
|
|
}
|
|
|
|
function normalizeProviderPolicy(value, label) {
|
|
object(value, label)
|
|
requiredKeys(value, ['proxyEnabled'], label)
|
|
allowedKeys(value, ['proxyEnabled'], label)
|
|
if (typeof value.proxyEnabled !== 'boolean') throw new ProxyConfigurationError(`${label} proxyEnabled must be boolean`)
|
|
return { proxyEnabled: value.proxyEnabled }
|
|
}
|
|
|
|
export function validProviderId(value) {
|
|
return typeof value === 'string' && /^[a-z0-9][a-z0-9._-]{0,127}$/.test(value)
|
|
}
|
|
|
|
function normalizeModelApi(value) {
|
|
object(value, 'model API proxy policy')
|
|
requiredKeys(value, ['default', 'providers'], 'model API proxy policy')
|
|
allowedKeys(value, ['default', 'providers'], 'model API proxy policy')
|
|
object(value.providers, 'model API provider policies')
|
|
const providers = {}
|
|
for (const [id, policy] of Object.entries(value.providers)) {
|
|
if (!validProviderId(id)) throw new ProxyConfigurationError('model API provider ID is invalid')
|
|
providers[id] = normalizeProviderPolicy(policy, `model API provider ${id}`)
|
|
}
|
|
return { default: normalizeProviderPolicy(value.default, 'model API default'), providers }
|
|
}
|
|
|
|
export function defaultProxyConfiguration() {
|
|
return Object.freeze({
|
|
schema: PROXY_SCHEMA,
|
|
enabled: false,
|
|
proxy: Object.freeze({
|
|
protocol: 'http', host: '', port: null, username: '', passwordConfigured: false, remoteDns: true,
|
|
}),
|
|
scopes: Object.freeze(Object.fromEntries(PROXY_SCOPES.map(scope => [scope, false]))),
|
|
environment: Object.freeze({ allProxy: null }),
|
|
modelApi: Object.freeze({
|
|
default: Object.freeze({ proxyEnabled: false }),
|
|
providers: Object.freeze({}),
|
|
}),
|
|
noProxy: Object.freeze({ system: PLATFORM_NO_PROXY, user: Object.freeze([]) }),
|
|
bypass: Object.freeze({ additional: Object.freeze([]) }),
|
|
})
|
|
}
|
|
|
|
export function validateProxyConfiguration(value, { existingPassword = null } = {}) {
|
|
object(value, 'proxy configuration')
|
|
requiredKeys(value, ['schema', 'enabled', 'proxy', 'scopes', 'environment', 'modelApi', 'noProxy', 'bypass'], 'proxy configuration')
|
|
allowedKeys(value, ['schema', 'enabled', 'proxy', 'scopes', 'environment', 'modelApi', 'noProxy', 'bypass'], 'proxy configuration')
|
|
if (value.schema !== PROXY_SCHEMA) throw new ProxyConfigurationError('proxy configuration schema must be 1')
|
|
if (typeof value.enabled !== 'boolean') throw new ProxyConfigurationError('proxy enabled must be boolean')
|
|
|
|
const proxy = object(value.proxy, 'proxy endpoint')
|
|
requiredKeys(proxy, ['protocol', 'host', 'port', 'username', 'remoteDns'], 'proxy endpoint')
|
|
allowedKeys(proxy, ['protocol', 'host', 'port', 'username', 'password', 'clearPassword', 'passwordConfigured', 'remoteDns'], 'proxy endpoint')
|
|
if (!['http', 'socks5'].includes(proxy.protocol)) throw new ProxyConfigurationError('proxy protocol must be http or socks5')
|
|
const host = normalizeProxyHost(proxy.host)
|
|
const port = proxy.port === null ? null : Number(proxy.port)
|
|
if (port !== null && (!Number.isSafeInteger(port) || port < 1 || port > 65535)) throw new ProxyConfigurationError('proxy port is invalid')
|
|
if (value.enabled && (host === '' || port === null)) throw new ProxyConfigurationError('enabled proxy requires a host and port')
|
|
if ((host === '') !== (port === null)) throw new ProxyConfigurationError('proxy host and port must be configured together')
|
|
if (typeof proxy.username !== 'string' || Buffer.byteLength(proxy.username, 'utf8') > 255 || /[\u0000-\u001f\u007f]/.test(proxy.username)) {
|
|
throw new ProxyConfigurationError('proxy username is invalid')
|
|
}
|
|
if (typeof proxy.remoteDns !== 'boolean') throw new ProxyConfigurationError('proxy remoteDns must be boolean')
|
|
if (proxy.clearPassword === true && proxy.password !== undefined && proxy.password !== null) {
|
|
throw new ProxyConfigurationError('proxy password cannot be set and cleared together')
|
|
}
|
|
let password = existingPassword
|
|
if (proxy.clearPassword === true) password = null
|
|
else if (proxy.password !== undefined && proxy.password !== null) {
|
|
if (typeof proxy.password !== 'string' || proxy.password === '' || Buffer.byteLength(proxy.password, 'utf8') > 255 || /[\u0000-\u001f\u007f]/.test(proxy.password)) {
|
|
throw new ProxyConfigurationError('proxy password is invalid')
|
|
}
|
|
password = proxy.password
|
|
}
|
|
|
|
const environment = object(value.environment, 'proxy environment')
|
|
requiredKeys(environment, ['allProxy'], 'proxy environment')
|
|
allowedKeys(environment, ['allProxy'], 'proxy environment')
|
|
if (environment.allProxy !== null && environment.allProxy !== 'scope-proxy') {
|
|
throw new ProxyConfigurationError('proxy environment allProxy must be null or scope-proxy')
|
|
}
|
|
const noProxy = object(value.noProxy, 'NO_PROXY')
|
|
requiredKeys(noProxy, ['user'], 'NO_PROXY')
|
|
allowedKeys(noProxy, ['user', 'system'], 'NO_PROXY')
|
|
const bypass = object(value.bypass, 'proxy bypass')
|
|
requiredKeys(bypass, ['additional'], 'proxy bypass')
|
|
allowedKeys(bypass, ['additional'], 'proxy bypass')
|
|
|
|
const normalized = {
|
|
schema: PROXY_SCHEMA,
|
|
enabled: value.enabled,
|
|
proxy: {
|
|
protocol: proxy.protocol,
|
|
host,
|
|
port,
|
|
username: proxy.username,
|
|
passwordConfigured: password !== null,
|
|
remoteDns: proxy.remoteDns,
|
|
},
|
|
scopes: normalizeScopes(value.scopes),
|
|
environment: { allProxy: environment.allProxy },
|
|
modelApi: normalizeModelApi(value.modelApi),
|
|
noProxy: {
|
|
system: PLATFORM_NO_PROXY,
|
|
user: normalizeProxyRules(noProxy.user, { allowWildcard: true, label: 'NO_PROXY' }).map(rule => rule.value),
|
|
},
|
|
bypass: {
|
|
additional: normalizeProxyRules(bypass.additional, { allowCidr: true, label: 'proxy bypass' }).map(rule => rule.value),
|
|
},
|
|
}
|
|
return Object.freeze({
|
|
configuration: deepFreeze(normalized),
|
|
credentials: deepFreeze({ username: proxy.username, password }),
|
|
})
|
|
}
|
|
|
|
export function sanitizedProxyConfiguration(configuration, revision, state = {}) {
|
|
return Object.freeze({
|
|
...configuration,
|
|
revision,
|
|
componentReady: state.componentReady === true,
|
|
routeHealth: state.routeHealth ?? Object.fromEntries([...PROXY_SCOPES, 'modelApi', 'sharedDsh'].map(scope => [scope, 'unknown'])),
|
|
lastTest: state.lastTest ?? null,
|
|
})
|
|
}
|