Files

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,
})
}