dsh-ris-yds 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,377 @@
1
+ // confcenter sync: pull the 4 YMS config files over YYCToken, cache the raw
2
+ // copies, and write the SOCKS-adapted (or direct) versions into yms-home/config.
3
+ // This replaces any bundled "golden config": a fresh environment pulls its own.
4
+ //
5
+ // Bump ADAPT_VERSION whenever the adaptation logic below changes shape: an
6
+ // existing yms-home/config written by an older adapter is then re-adapted from
7
+ // the cache on the next `yds start` (otherwise "yms_middleware.json exists"
8
+ // would short-circuit and old rewrites would stay live forever).
9
+ export const ADAPT_VERSION = '3'
10
+
11
+ /**
12
+ * Marker content for the current adaptation shape and mode.
13
+ * @param {boolean} rewrite - whether addresses were rewritten to 127.0.0.1.
14
+ * @returns {string} marker string compared by adaptIsCurrent().
15
+ */
16
+ export function adaptMarker(rewrite) {
17
+ return `${ADAPT_VERSION}:${rewrite ? 'rewrite' : 'direct'}`
18
+ }
19
+
20
+ // Private IPv4 ranges (RFC1918). Hosts in these ranges are on the dev-machine
21
+ // side of the SOCKS tunnel and get rewritten to 127.0.0.1:<localPort>.
22
+ const PRIVATE_HOST = /^(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})$/
23
+ // Hostnames the SOCKS server cannot resolve/reach (k8s-internal DNS): left
24
+ // unrewritten so behavior is unchanged, with a loud warning.
25
+ const UNREACHABLE_HOST = /\.inner-yonbip-k8s\.com$/i
26
+ // host(:port) occurrences inside string values (urls, "h:p" lists, "h1:p1,h2:p2")
27
+ const HOSTPORT_RE = /\b((?:\d{1,3}\.){3}\d{1,3}|[A-Za-z0-9][A-Za-z0-9.-]*[A-Za-z0-9]):(\d{2,5})\b/g
28
+
29
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, renameSync } from 'node:fs'
30
+ import { join } from 'node:path'
31
+ import { getYycToken } from './token.js'
32
+
33
+ export const CONF_FILES = ['yonbip_config.json', 'yms_middleware.json', 'module_config.json', 'ip_node_mapping.json']
34
+ // ip_node_mapping.json is NOT served by this endpoint (it is generated locally
35
+ // by the YonCode sync step); the other three come from confcenter.
36
+ const PULLED_FILES = ['yonbip_config.json', 'yms_middleware.json', 'module_config.json']
37
+
38
+ /**
39
+ * Pull the confcenter bundle for one app.
40
+ * GET <workbench>/iuap-yms-manage/confcenter/web/v2/config/new/file
41
+ * ?env=<env>&version=2.0.0&c_isolate=default&apps=<module>
42
+ * Response JSON: { "<file name>": <parsed file content>, ... } — a top-level
43
+ * object keyed by file name (values are the parsed JSON of each file).
44
+ * Returns the files as JSON text (normalized for the cache + adapt step).
45
+ */
46
+ export async function pullConfcenter(cfg, { signal } = {}) {
47
+ if (cfg.accessKey == null || cfg.accessSecret == null) throw new Error('缺少 accessKey/accessSecret,无法拉取配置')
48
+ if (cfg.module == null) throw new Error('未识别模块名,无法拉取配置')
49
+ const base = `${cfg.workbenchUrl}/iuap-yms-manage/confcenter/web/v2/config/new/file`
50
+ const url = `${base}?env=${encodeURIComponent(cfg.env)}&version=2.0.0&c_isolate=default&apps=${encodeURIComponent(cfg.module)}`
51
+ const token = await getYycToken(url, cfg.accessKey, cfg.accessSecret)
52
+ // timeout guard: hang DoS protection (fetch without a signal can hang forever)
53
+ const timeout = AbortSignal.timeout(60_000)
54
+ const signalOrTimeout = signal !== undefined ? AbortSignal.any([signal, timeout]) : timeout
55
+ const res = await fetch(url, { headers: { YYCToken: token }, signal: signalOrTimeout })
56
+ if (!res.ok) throw new Error(`confcenter HTTP ${res.status}`)
57
+ const body = await res.json()
58
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
59
+ throw new Error(`confcenter 响应形状异常: ${String(body).slice(0, 120)}`)
60
+ }
61
+ const files = {}
62
+ for (const [name, content] of Object.entries(body)) {
63
+ const fileName = String(name).split('/').pop()
64
+ if (!PULLED_FILES.includes(fileName)) continue
65
+ // accept either the parsed object or raw text
66
+ files[fileName] = typeof content === 'string' ? content : JSON.stringify(content, null, 2)
67
+ }
68
+ const missing = PULLED_FILES.filter((f) => files[f] === undefined)
69
+ if (missing.length > 0) throw new Error(`confcenter 响应缺文件: ${missing.join(', ')}`)
70
+ return files
71
+ }
72
+
73
+ /** Read the raw cache; used when the network is unreachable (offline start). */
74
+ export function readConfCache(cfg) {
75
+ const dir = join(cfg.stateDir, 'confcenter')
76
+ const files = {}
77
+ for (const name of CONF_FILES) {
78
+ const p = join(dir, name)
79
+ if (existsSync(p)) files[name] = readFileSync(p, 'utf8')
80
+ }
81
+ return files
82
+ }
83
+
84
+ export function writeConfCache(cfg, files) {
85
+ const dir = join(cfg.stateDir, 'confcenter')
86
+ // 0o700/0o600: the cache carries DB/Redis endpoints + credentials from
87
+ // confcenter — keep it owner-only.
88
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
89
+ for (const name of CONF_FILES) {
90
+ if (files[name] !== undefined) writeFileSync(join(dir, name), files[name], { encoding: 'utf8', mode: 0o600 })
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Adapt + write the 4 files into yms-home/config.
96
+ * SOCKS mode rewrites Oracle/MySQL hosts to 127.0.0.1:<dbForwardPort> and
97
+ * Redis pools to 127.0.0.1:<redisForwardPort>; direct mode keeps original
98
+ * hosts. ip_node_mapping always points the module at the callback address
99
+ * (serverBindAddress:serverPort).
100
+ * Returns a summary of what was written.
101
+ */
102
+ /** Atomic write: tmp file + rename, so a crash mid-write never leaves a truncated live config. */
103
+ function atomicWrite(path, text, mode) {
104
+ const tmp = `${path}.tmp-${process.pid}`
105
+ writeFileSync(tmp, text, { encoding: 'utf8', mode })
106
+ renameSync(tmp, path)
107
+ }
108
+
109
+ /**
110
+ * Write the four YMS config files, adapting every internal endpoint to this
111
+ * machine.
112
+ *
113
+ * `rewrite` decides WHICH adaptation applies:
114
+ * - true (fallback): private endpoints are replaced with 127.0.0.1:<localPort>
115
+ * and the forwarder binds those ports, so a JDBC/Redis client that
116
+ * cannot speak SOCKS still reaches the real server.
117
+ * - false (interception): endpoints keep their real host:port because a
118
+ * system-level redirector (ProxyBridge) already sends the java
119
+ * process' connections through the tunnel. Nothing is bound locally.
120
+ *
121
+ * Targets are collected either way: the redirector profile needs them.
122
+ * @param {object} cfg - resolved plugin config.
123
+ * @param {Record<string,string>} files - confcenter cache contents.
124
+ * `dir` defaults to `<ymsHome>/config`; the direct-launch copy passes the
125
+ * sibling `config-direct` instead, so the two launch modes never overwrite each
126
+ * other's endpoint addresses.
127
+ * @param {{rewrite?: boolean, dir?: string|null}} [options] - rewrite=false keeps
128
+ * real addresses; dir overrides the output directory.
129
+ * @returns {{files: object, dbTargets: object[], rewrite: boolean}} outcome.
130
+ */
131
+ export function adaptAndWriteConfig(cfg, files, { rewrite = true, dir = null } = {}) {
132
+ const cfgDir = dir ?? join(cfg.ymsHome, 'config')
133
+ mkdirSync(cfgDir, { recursive: true })
134
+
135
+ let mw
136
+ try {
137
+ mw = JSON.parse(files['yms_middleware.json'])
138
+ } catch (e) {
139
+ throw new Error(`yms_middleware.json 解析失败: ${e.message}——删除 .dsh-ris-yds/confcenter/ 缓存后重试(yds confcenter)`)
140
+ }
141
+
142
+ const dbTargets = [] // { host, port, localPort? } original endpoints for the forwarder
143
+ if (cfg.socks !== null) {
144
+ for (const pool of Object.values(mw.connectionPools ?? {})) {
145
+ if (typeof pool?.url === 'string' && pool.url.startsWith('jdbc:')) {
146
+ const m = parseJdbcEndpoint(pool.url)
147
+ if (m !== null) {
148
+ dbTargets.push({ host: m.host, port: m.port })
149
+ if (rewrite) pool.url = m.rewrite(`127.0.0.1`, cfg.dbForwardPort)
150
+ } else {
151
+ console.warn(`[yds] 警告: 无法解析 JDBC url(未重写、未转发): ${pool.url}`)
152
+ }
153
+ }
154
+ }
155
+ for (const pool of Object.values(mw['redis.pools'] ?? {})) {
156
+ if (typeof pool?.host === 'string') {
157
+ dbTargets.push({ host: pool.host, port: Number(pool.port) || 6379 })
158
+ if (rewrite) {
159
+ pool.host = '127.0.0.1'
160
+ pool.port = cfg.redisForwardPort
161
+ }
162
+ }
163
+ }
164
+ }
165
+ // 0o600: yms_middleware.json carries DB/Redis credentials.
166
+ atomicWrite(join(cfgDir, 'yms_middleware.json'), JSON.stringify(mw, null, 2), 0o600)
167
+
168
+ // module_config / yonbip_config: verbatim (platform global options);
169
+ // in SOCKS mode yonbip_config additionally gets its internal endpoints
170
+ // (kafka / ES / license-server / ...) rewritten to the local tunnel ports.
171
+ for (const name of ['module_config.json', 'yonbip_config.json']) {
172
+ if (typeof files[name] !== 'string') {
173
+ throw new Error(`confcenter 缓存缺 ${name}——先执行 yds confcenter 在线拉取一次`)
174
+ }
175
+ }
176
+ let yonbipText = files['yonbip_config.json']
177
+ if (cfg.socks !== null) {
178
+ let r
179
+ try {
180
+ r = rewriteYonbipInternal(yonbipText)
181
+ } catch (e) {
182
+ throw new Error(`yonbip_config.json 解析失败: ${e.message}——删除 .dsh-ris-yds/confcenter/ 缓存后重试(yds confcenter)`)
183
+ }
184
+ yonbipText = r.text
185
+ dbTargets.push(...r.targets)
186
+ }
187
+ atomicWrite(join(cfgDir, 'module_config.json'), files['module_config.json'], 0o600)
188
+ atomicWrite(join(cfgDir, 'yonbip_config.json'), yonbipText, 0o600)
189
+
190
+ // ip_node_mapping: route the module to our callback address. Merge with the
191
+ // existing file (YonCode sync may have written other modules' entries) so a
192
+ // shared workspace keeps working for both sides.
193
+ const mappingPath = join(cfgDir, 'ip_node_mapping.json')
194
+ let mapping = {}
195
+ let source = files['ip_node_mapping.json'] // may be undefined (not served)
196
+ if (source === undefined && existsSync(mappingPath)) source = readFileSync(mappingPath, 'utf8')
197
+ if (source !== undefined) {
198
+ try {
199
+ mapping = JSON.parse(source)
200
+ } catch (e) {
201
+ // malformed existing mapping: back it up, start from an empty mapping,
202
+ // and say so — silently replacing it would wipe other modules' entries.
203
+ try {
204
+ renameSync(mappingPath, `${mappingPath}.bak-${Date.now()}`)
205
+ console.warn(`[yds] 警告: 现有 ip_node_mapping.json 解析失败(${e.message})——已备份为 .bak-* 并重建,其它模块条目可能丢失`)
206
+ } catch { /* ignore */ }
207
+ mapping = {}
208
+ }
209
+ }
210
+ const entry = mapping[cfg.module] ?? {}
211
+ entry.context = cfg.context
212
+ entry.ip_nodes = [`${cfg.serverBindAddress}:${cfg.serverPort}`]
213
+ mapping[cfg.module] = entry
214
+ const mappingText = JSON.stringify(mapping, null, 2)
215
+ atomicWrite(mappingPath, mappingText, 0o600)
216
+ const filesOut = { ...files, 'ip_node_mapping.json': mappingText }
217
+
218
+ // adaptation stamp: prepareStart re-adapts from the cache when this is
219
+ // missing or stale (adapter logic upgrades must not leave old rewrites live).
220
+ writeFileSync(join(cfg.stateDir, 'adapt-version'), adaptMarker(rewrite), 'utf8')
221
+
222
+ // variables/default.properties: keep the build's copy; create a minimal one
223
+ // when the build did not ship it (kernel template normally does).
224
+ const variables = join(cfgDir, 'variables', 'default.properties')
225
+ if (!existsSync(variables)) {
226
+ mkdirSync(join(cfgDir, 'variables'), { recursive: true })
227
+ writeFileSync(variables, [
228
+ '# generated by dsh-ris-yds',
229
+ `yms.container.web.port=${cfg.ymsPort}`,
230
+ 'yms.container.web.https.port=9443',
231
+ 'yms.container.workerThreads=500',
232
+ 'yms.container.maxPostSize=11534336',
233
+ '',
234
+ ].join('\n'), 'utf8')
235
+ }
236
+
237
+ return { mode: cfg.socks !== null ? 'socks' : 'direct', dbTargets, files: filesOut }
238
+ }
239
+
240
+ /**
241
+ * Parse host:port out of the common JDBC url forms so we can rewrite them
242
+ * for the SOCKS tunnel:
243
+ * jdbc:oracle:thin:@//host:port/service (Oracle service form)
244
+ * jdbc:oracle:thin:@host:port:SID (Oracle SID form)
245
+ * jdbc:mysql://host:port/db?params (MySQL)
246
+ * jdbc:mysql://host:port (MySQL no-db)
247
+ * Returns { host, port, rewrite(host, port) } or null (unknown form).
248
+ */
249
+ export function parseJdbcEndpoint(url) {
250
+ let m = /^jdbc:[a-z0-9]+:thin:@\/\/([^/:]+):(\d+)(?=[/\s]|$)/.exec(url)
251
+ if (m !== null) {
252
+ return { host: m[1], port: Number(m[2]), rewrite: (h, p) => url.replace(/(@\/\/)[^/:]+:\d+/, `$1${h}:${p}`) }
253
+ }
254
+ m = /^jdbc:[a-z0-9]+:thin:@([^/:]+):(\d+):/.exec(url) // SID form
255
+ if (m !== null) {
256
+ return { host: m[1], port: Number(m[2]), rewrite: (h, p) => url.replace(/(thin:@)[^/:]+:\d+/, `$1${h}:${p}`) }
257
+ }
258
+ m = /^jdbc:mysql:\/\/([^/:]+):(\d+)(?=[/\s]|$)/.exec(url)
259
+ if (m !== null) {
260
+ return { host: m[1], port: Number(m[2]), rewrite: (h, p) => url.replace(/(jdbc:mysql:\/\/)[^/:]+:\d+/, `$1${h}:${p}`) }
261
+ }
262
+ return null
263
+ }
264
+
265
+ /**
266
+ * Rewrite the internal (RFC1918) endpoints inside yonbip_config.json to
267
+ * 127.0.0.1:<same port> so they traverse the SOCKS forwarder, and collect the
268
+ * tunnel targets. Rules (each backed by a probe of the real dev network):
269
+ * - host:port with a private IP host and port >= 1024 -> rewrite, tunnel
270
+ * (kafka 6880, ES 6888, license 30001, ...).
271
+ * - bare-IP values (e.g. middleware_es.elastic.host) -> 127.0.0.1, but only
272
+ * when that same host already has a tunneled port (the port comes from a
273
+ * sibling key).
274
+ * - *.inner-yonbip-k8s.com (e.g. domain.registry.url) -> NOT rewritten: the
275
+ * SOCKS server cannot resolve k8s-internal DNS, so rewriting would just
276
+ * move the failure; warn loudly instead.
277
+ * - port < 1024 (e.g. objectStorage SFTP :22) -> skipped, cannot bind
278
+ * privileged local ports; warn.
279
+ * - non-private hosts (${domain.url} placeholders, public names) -> verbatim.
280
+ * Returns { text, targets } where targets = [{ host, port, localPort }] and
281
+ * localPort === port (deterministic 1:1 mapping, readable in logs).
282
+ */
283
+ export function rewriteYonbipInternal(yonbipText) {
284
+ const doc = JSON.parse(yonbipText) // caller handles parse errors
285
+ const targets = []
286
+ const seen = new Set()
287
+ const warned = new Set()
288
+ const warnOnce = (msg) => {
289
+ if (!warned.has(msg)) { warned.add(msg); console.warn(`[yds] ${msg}`) }
290
+ }
291
+
292
+ // pass 1: collect eligible host:port targets (also detects k8s-internal
293
+ // hostnames and privileged ports for warnings). Bare private-IP values are
294
+ // NOT collected — they only ride along once their host has a real tunnel.
295
+ const collect = (node) => {
296
+ if (typeof node === 'string') {
297
+ if (UNREACHABLE_HOST.test(node) || (node.includes('://') && UNREACHABLE_HOST.test(node.split('://')[1] ?? ''))) {
298
+ warnOnce(`内网域名 ${node} 无法经 SOCKS 解析/转发(k8s 内部 DNS),保持原值——相关功能(服务注册发现等)将直连失败,属已知限制`)
299
+ return
300
+ }
301
+ for (const m of node.matchAll(HOSTPORT_RE)) {
302
+ const host = m[1]
303
+ const port = Number(m[2])
304
+ if (!PRIVATE_HOST.test(host)) continue
305
+ if (UNREACHABLE_HOST.test(host)) continue
306
+ if (port < 1024) {
307
+ warnOnce(`内网端口 ${host}:${port} < 1024,本地无法监听,未隧道化(保持原值直连)`)
308
+ continue
309
+ }
310
+ const key = `${host}:${port}`
311
+ if (!seen.has(key)) { seen.add(key); targets.push({ host, port, localPort: port }) }
312
+ }
313
+ } else if (node !== null && typeof node === 'object') {
314
+ for (const v of Object.values(node)) collect(v)
315
+ }
316
+ }
317
+
318
+ // pass 2: rewrite (order-independent: targets are already known)
319
+ const rewrite = (node) => {
320
+ if (typeof node === 'string') {
321
+ if (PRIVATE_HOST.test(node)) {
322
+ // bare private IP (e.g. middleware_es.elastic.host): its port comes
323
+ // from a sibling key, so rewrite only when the host has a tunnel.
324
+ return seenHosts().has(node) ? '127.0.0.1' : node
325
+ }
326
+ return node.replace(HOSTPORT_RE, (full, host, portStr) => {
327
+ const port = Number(portStr)
328
+ if (!PRIVATE_HOST.test(host) || UNREACHABLE_HOST.test(host) || port < 1024) return full
329
+ if (!seen.has(`${host}:${port}`)) return full
330
+ return `127.0.0.1:${port}`
331
+ })
332
+ }
333
+ if (node !== null && typeof node === 'object') {
334
+ for (const k of Object.keys(node)) node[k] = rewrite(node[k])
335
+ }
336
+ return node
337
+ }
338
+ const hostSetCache = { set: null }
339
+ const seenHosts = () => {
340
+ if (hostSetCache.set === null) {
341
+ hostSetCache.set = new Set([...seen].map((k) => k.split(':')[0]))
342
+ }
343
+ return hostSetCache.set
344
+ }
345
+
346
+ collect(doc)
347
+ const text = JSON.stringify(rewrite(doc), null, 2)
348
+ return { text, targets }
349
+ }
350
+
351
+ /**
352
+ * Extract the internal tunnel targets from a CACHED (raw) yonbip_config.json
353
+ * without rewriting anything — used by the forwarder bootstrap when the
354
+ * yms-home copy is already adapted. Same eligibility rules as
355
+ * rewriteYonbipInternal (private host, port >= 1024, not k8s-internal DNS).
356
+ */
357
+ export function extractYonbipTargets(yonbipText) {
358
+ const doc = JSON.parse(yonbipText)
359
+ const targets = []
360
+ const seen = new Set()
361
+ const walk = (node) => {
362
+ if (typeof node === 'string') {
363
+ for (const m of node.matchAll(HOSTPORT_RE)) {
364
+ const host = m[1]
365
+ const port = Number(m[2])
366
+ if (PRIVATE_HOST.test(host) && !UNREACHABLE_HOST.test(host) && port >= 1024) {
367
+ const key = `${host}:${port}`
368
+ if (!seen.has(key)) { seen.add(key); targets.push({ host, port, localPort: port }) }
369
+ }
370
+ }
371
+ } else if (node !== null && typeof node === 'object') {
372
+ for (const v of Object.values(node)) walk(v)
373
+ }
374
+ }
375
+ walk(doc)
376
+ return targets
377
+ }