dsh-ssh-vault 0.0.0-stage → 2.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.
package/lib/vault.js ADDED
@@ -0,0 +1,397 @@
1
+ /**
2
+ * 加密保险库(vault)—— 用 AES-256-GCM 保护 SSH 主机清单。
3
+ *
4
+ * 设计要点:
5
+ * - 主机地址、端口、账号、口令全部位于密文内部,磁盘上只留下 `vault.json` 的
6
+ * `iv` / `tag` / `data` 三段 base64,不出现任何明文。
7
+ * - 数据密钥(DEK)为 32 字节随机数,其保护方式由 `keyProtection` 决定:
8
+ * · `dpapi`(默认)—— 用 Windows DPAPI 以当前用户身份加密后写入 `key.json`,
9
+ * 文件被复制到其他机器或其他用户下都无法解开;
10
+ * · `file` —— 直接写入 `key.json`,安全边界等同于文件系统权限;
11
+ * · `passphrase` —— 由用户主口令经 scrypt 派生,磁盘上不保存密钥材料。
12
+ * - GCM 的附加认证数据(AAD)绑定格式版本,避免密文被降级重放。
13
+ *
14
+ * @module dsh-ssh-vault/vault
15
+ */
16
+
17
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto'
18
+ import { spawn } from 'node:child_process'
19
+ import { existsSync } from 'node:fs'
20
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
21
+ import { homedir } from 'node:os'
22
+ import path from 'node:path'
23
+ import { t } from './i18n.js'
24
+ import { assertSupportedPlatform } from './platform.js'
25
+
26
+ /** 保险库格式版本;同时参与 AAD 计算。 */
27
+ export const VAULT_VERSION = 1
28
+ /** AES-256 密钥长度(字节)。 */
29
+ const KEY_BYTES = 32
30
+ /** GCM 推荐 IV 长度(字节)。 */
31
+ const IV_BYTES = 12
32
+ /** scrypt 盐长度(字节)。 */
33
+ const SALT_BYTES = 16
34
+ /** GCM 认证标签长度(字节)。 */
35
+ const TAG_BYTES = 16
36
+ /** 绑定到密文的附加认证数据。 */
37
+ const AAD = Buffer.from('dsh-ssh-vault:v1', 'utf8')
38
+ /** scrypt 参数:约 32 MiB 内存开销,兼顾强度与启动延迟。 */
39
+ const SCRYPT = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }
40
+
41
+ /** 默认保险库目录:优先使用 DSH_HOME,保持与 Harness 其他私有数据同处一地。 */
42
+ export function defaultVaultDir() {
43
+ const home = process.env.DSH_HOME ?? path.join(homedir(), '.dsh')
44
+ return path.join(home, 'ssh-vault')
45
+ }
46
+
47
+ /**
48
+ * 用 Windows DPAPI 保护一段数据(仅当前用户可解密)。
49
+ * @param data - 待保护的原始字节。
50
+ * @returns base64 形式的受保护数据。
51
+ */
52
+ async function dpapiProtect(data) {
53
+ return runPowerShell(
54
+ "Add-Type -AssemblyName System.Security;"
55
+ + "$in=[Console]::In.ReadToEnd().Trim();"
56
+ + "$b=[Convert]::FromBase64String($in);"
57
+ + "$p=[System.Security.Cryptography.ProtectedData]::Protect($b,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser);"
58
+ + "[Console]::Out.Write([Convert]::ToBase64String($p))",
59
+ data.toString('base64'),
60
+ )
61
+ }
62
+
63
+ /**
64
+ * 解除 DPAPI 保护。
65
+ * @param payload - base64 形式的受保护数据。
66
+ * @returns 原始字节。
67
+ */
68
+ async function dpapiUnprotect(payload) {
69
+ const out = await runPowerShell(
70
+ "Add-Type -AssemblyName System.Security;"
71
+ + "$in=[Console]::In.ReadToEnd().Trim();"
72
+ + "$b=[Convert]::FromBase64String($in);"
73
+ + "$p=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser);"
74
+ + "[Console]::Out.Write([Convert]::ToBase64String($p))",
75
+ payload,
76
+ )
77
+ return Buffer.from(out.trim(), 'base64')
78
+ }
79
+
80
+ /**
81
+ * 通过 stdin 传入数据并执行一段 Windows PowerShell,避免任何命令行转义问题。
82
+ * @param script - PowerShell 脚本,从 stdin 读取输入。
83
+ * @param input - 写入子进程 stdin 的文本。
84
+ * @returns 子进程的标准输出。
85
+ */
86
+ function runPowerShell(script, input) {
87
+ return new Promise((resolve, reject) => {
88
+ const systemRoot = process.env.SystemRoot ?? 'C:\\Windows'
89
+ const exe = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
90
+ const child = spawn(exe, ['-NoProfile', '-NonInteractive', '-Command', script], {
91
+ stdio: ['pipe', 'pipe', 'pipe'],
92
+ windowsHide: true,
93
+ })
94
+ let stdout = ''
95
+ let stderr = ''
96
+ const timer = setTimeout(() => {
97
+ child.kill()
98
+ reject(new Error(t('vault.dpapiTimeout')))
99
+ }, 15000)
100
+ child.stdout.setEncoding('utf8')
101
+ child.stderr.setEncoding('utf8')
102
+ child.stdout.on('data', (chunk) => { stdout += chunk })
103
+ child.stderr.on('data', (chunk) => { stderr += chunk })
104
+ child.on('error', (error) => {
105
+ clearTimeout(timer)
106
+ reject(error)
107
+ })
108
+ child.on('close', (code) => {
109
+ clearTimeout(timer)
110
+ if (code === 0 && stdout.trim().length > 0) resolve(stdout)
111
+ else reject(new Error(t('vault.dpapiFailed', { code, output: stderr.trim() || t('vault.noOutput') })))
112
+ })
113
+ child.stdin.end(input)
114
+ })
115
+ }
116
+
117
+ /**
118
+ * 对主机地址做脱敏,只保留网络前缀。
119
+ * @param host - 原始地址。
120
+ * @returns 形如 `192.168.*.*` 的掩码。
121
+ */
122
+ export function maskHost(host) {
123
+ if (typeof host !== 'string' || host.length === 0) return '(unknown)'
124
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
125
+ const parts = host.split('.')
126
+ return `${parts[0]}.${parts[1]}.*.*`
127
+ }
128
+ const dot = host.indexOf('.')
129
+ if (dot > 0) return `${host.slice(0, dot)}.${'*'.repeat(Math.max(1, host.length - dot - 1))}`
130
+ return `${host.slice(0, 2)}${'*'.repeat(Math.max(1, host.length - 2))}`
131
+ }
132
+
133
+ /**
134
+ * 对账号名做脱敏,保留首字符。
135
+ * @param user - 原始账号。
136
+ * @returns 掩码后的账号。
137
+ */
138
+ export function maskUser(user) {
139
+ if (typeof user !== 'string' || user.length === 0) return '(unknown)'
140
+ if (user.length <= 2) return `${user[0]}*`
141
+ return `${user[0]}${'*'.repeat(Math.min(user.length - 1, 4))}`
142
+ }
143
+
144
+ /**
145
+ * 加密保险库。所有主机记录都以密文形式保存在一个文件中,读取时才在内存解密。
146
+ */
147
+ export class Vault {
148
+ #dir
149
+ #keyProtection
150
+ #masterPassword
151
+ #logger
152
+ #key = null
153
+ #mode = null
154
+ #hosts = null
155
+ #chain = Promise.resolve()
156
+
157
+ /**
158
+ * @param options - 保险库配置。
159
+ * @param options.dir - 保险库目录。
160
+ * @param options.keyProtection - 数据密钥的保护方式。
161
+ * @param options.masterPassword - `passphrase` 模式下的主口令。
162
+ * @param options.logger - 可选的日志接口。
163
+ */
164
+ constructor({ dir, keyProtection = 'dpapi', masterPassword, logger } = {}) {
165
+ // 运行期兜底:非 Windows 上继续构造会在 #loadKey 里退化成明文 key.json。
166
+ // 直接拒绝,比「能跑但密钥明文落盘」诚实。
167
+ assertSupportedPlatform()
168
+ this.#dir = dir ?? defaultVaultDir()
169
+ this.#keyProtection = keyProtection
170
+ this.#masterPassword = masterPassword
171
+ this.#logger = logger
172
+ }
173
+
174
+ /** 保险库目录(其中包含密文、密钥与 askpass 辅助脚本)。 */
175
+ get dir() {
176
+ return this.#dir
177
+ }
178
+
179
+ /** 当前数据密钥的保护方式,初始化后可用。 */
180
+ get keyMode() {
181
+ return this.#mode
182
+ }
183
+
184
+ #file(name) {
185
+ return path.join(this.#dir, name)
186
+ }
187
+
188
+ async #ensureDir() {
189
+ await mkdir(this.#dir, { recursive: true })
190
+ }
191
+
192
+ /** 载入或首次创建数据密钥。 */
193
+ async #loadKey() {
194
+ if (this.#key !== null) return
195
+ await this.#ensureDir()
196
+ const keyPath = this.#file('key.json')
197
+ if (existsSync(keyPath)) {
198
+ const stored = JSON.parse(await readFile(keyPath, 'utf8'))
199
+ if (stored.mode === 'passphrase') {
200
+ if (typeof this.#masterPassword !== 'string' || this.#masterPassword.length === 0) {
201
+ throw new Error(t('vault.masterPasswordMissing'))
202
+ }
203
+ this.#key = scryptSync(this.#masterPassword, Buffer.from(stored.salt, 'base64'), KEY_BYTES, SCRYPT)
204
+ } else if (stored.mode === 'dpapi') {
205
+ try {
206
+ this.#key = await dpapiUnprotect(stored.payload)
207
+ } catch (error) {
208
+ throw new Error(t('vault.dpapiUnwrapFailed', {
209
+ message: error instanceof Error ? error.message : String(error),
210
+ }))
211
+ }
212
+ } else {
213
+ this.#key = Buffer.from(stored.payload, 'base64')
214
+ }
215
+ this.#mode = stored.mode
216
+ return
217
+ }
218
+ // 首次运行:生成数据密钥并按既定方式保护。
219
+ const key = randomBytes(KEY_BYTES)
220
+ let record
221
+ if (this.#keyProtection === 'passphrase') {
222
+ if (typeof this.#masterPassword !== 'string' || this.#masterPassword.length === 0) {
223
+ throw new Error(t('vault.masterPasswordRequired'))
224
+ }
225
+ const salt = randomBytes(SALT_BYTES)
226
+ record = { version: VAULT_VERSION, mode: 'passphrase', salt: salt.toString('base64') }
227
+ await writeFile(keyPath, `${JSON.stringify(record, null, 2)}\n`, 'utf8')
228
+ this.#key = scryptSync(this.#masterPassword, salt, KEY_BYTES, SCRYPT)
229
+ this.#mode = 'passphrase'
230
+ return
231
+ }
232
+ if (this.#keyProtection === 'dpapi') {
233
+ try {
234
+ const payload = await dpapiProtect(key)
235
+ record = { version: VAULT_VERSION, mode: 'dpapi', payload }
236
+ await writeFile(keyPath, `${JSON.stringify(record, null, 2)}\n`, 'utf8')
237
+ this.#key = key
238
+ this.#mode = 'dpapi'
239
+ return
240
+ } catch (error) {
241
+ // DPAPI 不可用时降级为明文密钥文件,并明确告知使用者当前安全边界。
242
+ this.#logger?.warn?.(t('vault.dpapiUnavailable', {
243
+ message: error instanceof Error ? error.message : String(error),
244
+ }))
245
+ }
246
+ }
247
+ record = { version: VAULT_VERSION, mode: 'file', payload: key.toString('base64') }
248
+ await writeFile(keyPath, `${JSON.stringify(record, null, 2)}\n`, 'utf8')
249
+ this.#key = key
250
+ this.#mode = 'file'
251
+ }
252
+
253
+ /** 读取并解密主机清单。 */
254
+ async #loadHosts() {
255
+ if (this.#hosts !== null) return this.#hosts
256
+ await this.#loadKey()
257
+ const vaultPath = this.#file('vault.json')
258
+ if (!existsSync(vaultPath)) {
259
+ this.#hosts = { version: VAULT_VERSION, hosts: {} }
260
+ return this.#hosts
261
+ }
262
+ const envelope = JSON.parse(await readFile(vaultPath, 'utf8'))
263
+ const decipher = createDecipheriv('aes-256-gcm', this.#key, Buffer.from(envelope.iv, 'base64'))
264
+ decipher.setAAD(AAD)
265
+ decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'))
266
+ let plain
267
+ try {
268
+ plain = Buffer.concat([decipher.update(Buffer.from(envelope.data, 'base64')), decipher.final()])
269
+ } catch {
270
+ throw new Error(t('vault.decryptFailed'))
271
+ }
272
+ const parsed = JSON.parse(plain.toString('utf8'))
273
+ this.#hosts = { version: VAULT_VERSION, hosts: parsed.hosts ?? {} }
274
+ return this.#hosts
275
+ }
276
+
277
+ /** 原子地把主机清单加密落盘。 */
278
+ async #storeHosts() {
279
+ await this.#loadKey()
280
+ const iv = randomBytes(IV_BYTES)
281
+ const cipher = createCipheriv('aes-256-gcm', this.#key, iv)
282
+ cipher.setAAD(AAD)
283
+ const plain = Buffer.from(JSON.stringify(this.#hosts), 'utf8')
284
+ const data = Buffer.concat([cipher.update(plain), cipher.final()])
285
+ const tag = cipher.getAuthTag()
286
+ const envelope = {
287
+ version: VAULT_VERSION,
288
+ alg: 'aes-256-gcm',
289
+ iv: iv.toString('base64'),
290
+ tag: tag.toString('base64'),
291
+ data: data.toString('base64'),
292
+ }
293
+ const target = this.#file('vault.json')
294
+ const temp = this.#file(`vault.json.${process.pid}.tmp`)
295
+ await writeFile(temp, `${JSON.stringify(envelope)}\n`, 'utf8')
296
+ await rename(temp, target)
297
+ }
298
+
299
+ /**
300
+ * 在保险库锁内执行一次读改写,避免并发工具调用互相覆盖。
301
+ * @param fn - 收到当前主机表并返回结果的操作。
302
+ * @returns 操作结果。
303
+ */
304
+ #locked(fn) {
305
+ const run = this.#chain.then(fn, fn)
306
+ this.#chain = run.then(() => undefined, () => undefined)
307
+ return run
308
+ }
309
+
310
+ /**
311
+ * 列出全部主机记录(含明文,仅供插件内部使用,调用方负责脱敏)。
312
+ * @returns 主机记录数组。
313
+ */
314
+ async list() {
315
+ return this.#locked(async () => {
316
+ const data = await this.#loadHosts()
317
+ return Object.values(data.hosts).sort((a, b) => a.alias.localeCompare(b.alias))
318
+ })
319
+ }
320
+
321
+ /**
322
+ * 读取一台主机。
323
+ * @param alias - 主机别名。
324
+ * @returns 主机记录,不存在时为 `undefined`。
325
+ */
326
+ async get(alias) {
327
+ return this.#locked(async () => {
328
+ const data = await this.#loadHosts()
329
+ return data.hosts[alias]
330
+ })
331
+ }
332
+
333
+ /**
334
+ * 新增或整体替换一台主机。
335
+ * @param record - 完整的主机记录,至少包含 `alias`。
336
+ * @returns 写入后的记录。
337
+ */
338
+ async put(record) {
339
+ return this.#locked(async () => {
340
+ const data = await this.#loadHosts()
341
+ const now = new Date().toISOString()
342
+ const previous = data.hosts[record.alias]
343
+ const next = {
344
+ ...record,
345
+ createdAt: previous?.createdAt ?? now,
346
+ updatedAt: now,
347
+ }
348
+ data.hosts[record.alias] = next
349
+ await this.#storeHosts()
350
+ return next
351
+ })
352
+ }
353
+
354
+ /**
355
+ * 删除一台主机。
356
+ * @param alias - 主机别名。
357
+ * @returns 是否确实删除了记录。
358
+ */
359
+ async remove(alias) {
360
+ return this.#locked(async () => {
361
+ const data = await this.#loadHosts()
362
+ if (data.hosts[alias] === undefined) return false
363
+ delete data.hosts[alias]
364
+ await this.#storeHosts()
365
+ return true
366
+ })
367
+ }
368
+
369
+ /**
370
+ * 就地更新一台主机的部分字段(口令等敏感字段始终整体替换)。
371
+ * @param alias - 主机别名。
372
+ * @param patch - 需要覆盖的字段。
373
+ * @returns 更新后的记录,主机不存在时为 `undefined`。
374
+ */
375
+ async patch(alias, patch) {
376
+ return this.#locked(async () => {
377
+ const data = await this.#loadHosts()
378
+ const previous = data.hosts[alias]
379
+ if (previous === undefined) return undefined
380
+ const next = { ...previous, ...patch, alias, updatedAt: new Date().toISOString() }
381
+ data.hosts[alias] = next
382
+ await this.#storeHosts()
383
+ return next
384
+ })
385
+ }
386
+
387
+ /** 保险库中 `known_hosts` 文件的路径(OpenSSH 主机密钥记录)。 */
388
+ get knownHostsPath() {
389
+ return this.#file('known_hosts')
390
+ }
391
+
392
+ /** 等待目录就绪,供需要写入辅助脚本的调用方使用。 */
393
+ async ensureDir() {
394
+ await this.#ensureDir()
395
+ return this.#dir
396
+ }
397
+ }
package/locale/en.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "title": "SSH Vault (Windows)",
3
+ "description": "Windows-only: connect to remote hosts over SSH with the host address, port, account, and password kept in an encrypted vault."
4
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "title": "SSH 保险库(Windows)",
3
+ "description": "仅支持 Windows:在加密保险库中保存主机地址、端口、账号与口令,让智能体通过别名安全地连接远端主机。"
4
+ }
package/package.json CHANGED
@@ -1,6 +1,77 @@
1
1
  {
2
2
  "name": "dsh-ssh-vault",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.0",
4
+ "type": "module",
5
+ "description": "Encrypted SSH host vault and model-facing SSH tools for the DeepSeek Harness — Windows only",
6
+ "os": [
7
+ "win32"
8
+ ],
9
+ "license": "MIT",
10
+ "author": "scientificmaniac",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/scientificmaniac/dsh-ssh-vault.git"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/scientificmaniac/dsh-ssh-vault/issues"
17
+ },
18
+ "homepage": "https://github.com/scientificmaniac/dsh-ssh-vault#readme",
19
+ "keywords": [
20
+ "dsh",
21
+ "deepseek-harness",
22
+ "cordis-plugin",
23
+ "ssh",
24
+ "vault",
25
+ "encryption"
26
+ ],
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "scripts": {
31
+ "test": "node test/platform.mjs && node test/diagnose.mjs && node test/unit.mjs && node test/background.mjs && node test/vault-api.mjs && node test/i18n.mjs && node test/host-apply.mjs && node test/client-smoke.mjs",
32
+ "test:live": "node test/live.mjs",
33
+ "test:self": "node test/selftest.mjs"
34
+ },
35
+ "exports": {
36
+ ".": "./index.js",
37
+ "./client": "./client.js",
38
+ "./package.json": "./package.json",
39
+ "./locale/*.json": "./locale/*.json"
40
+ },
41
+ "files": [
42
+ "index.js",
43
+ "client.js",
44
+ "lib",
45
+ "scripts",
46
+ "locale",
47
+ "cordis.patch.yml",
48
+ "icon.svg",
49
+ "assets",
50
+ "README.md",
51
+ "README.zh-CN.md",
52
+ "CHANGELOG.md",
53
+ "CHANGELOG.zh-CN.md",
54
+ "CONTRIBUTING.md",
55
+ "CONTRIBUTING.zh-CN.md",
56
+ "SECURITY.md",
57
+ "SECURITY.zh-CN.md",
58
+ "LICENSE"
59
+ ],
60
+ "icon": "./icon.svg",
61
+ "meta": {
62
+ "title": "SSH Vault (Windows)",
63
+ "description": "Connect to remote hosts over SSH from Windows with the host address, port, account, and password kept in an encrypted vault."
64
+ },
65
+ "dsh": {
66
+ "bundle": {
67
+ "patch": "./cordis.patch.yml"
68
+ },
69
+ "client": {
70
+ "platform": "web",
71
+ "immediately": true,
72
+ "inject": [
73
+ "@deepseek-ai/dsh-client-ui-settings"
74
+ ]
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,81 @@
1
+ #Requires -RunAsAdministrator
2
+ <#
3
+ .SYNOPSIS
4
+ 给 Kali 虚拟机做 VMware DHCP 地址保留(固定租约)。
5
+
6
+ .DESCRIPTION
7
+ 把 kali 的 MAC 地址钉死到 192.0.2.150,这样 VMware 的 DHCP 每次都会把同一个地址
8
+ 发给它,不会因为 DHCP 池重新分配而漂移。
9
+
10
+ 脚本可重复运行:已经存在同名 host 块时会更新而不是重复追加。
11
+
12
+ .PARAMETER Mac
13
+ Kali eth0 的 MAC 地址。默认 00:0c:29:00:00:01(示例值,请按实际填写)。
14
+
15
+ .PARAMETER Address
16
+ 要保留的地址。默认 192.0.2.150。
17
+
18
+ .PARAMETER HostName
19
+ 配置文件里的条目名,仅作标识,不影响行为。
20
+
21
+ .NOTES
22
+ VMware 在增删虚拟网卡时会重写 vmnetdhcp.conf,那时需要重新运行本脚本。
23
+ #>
24
+ [CmdletBinding()]
25
+ param(
26
+ [string]$Mac = '00:0c:29:00:00:01',
27
+ [string]$Address = '192.0.2.150',
28
+ [string]$HostName = 'kali'
29
+ )
30
+
31
+ $ErrorActionPreference = 'Stop'
32
+
33
+ $conf = Join-Path $env:ProgramData 'VMware\vmnetdhcp.conf'
34
+ if (-not (Test-Path $conf)) {
35
+ throw "找不到 VMware DHCP 配置:$conf"
36
+ }
37
+
38
+ # 1. 先备份。保留最近几份即可,避免堆满目录。
39
+ $stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
40
+ $backup = "$conf.bak-$stamp"
41
+ Copy-Item -LiteralPath $conf -Destination $backup -Force
42
+ Write-Host "已备份配置到 $backup" -ForegroundColor Green
43
+
44
+ $text = Get-Content -LiteralPath $conf -Raw
45
+ $blockPattern = "(?ms)^[ \t]*host[ \t]+$([regex]::Escape($HostName))[ \t]*\{.*?^[ \t]*\}[ \t]*\r?\n?"
46
+
47
+ # 2. 已经有了就替换,保证幂等。
48
+ $newBlock = @"
49
+ host $HostName {
50
+ hardware ethernet $Mac;
51
+ fixed-address $Address;
52
+ }
53
+ "@
54
+
55
+ if ($text -match $blockPattern) {
56
+ $text = [regex]::Replace($text, $blockPattern, ($newBlock + "`r`n"), 1)
57
+ Write-Host "已更新既有的 host $HostName 条目" -ForegroundColor Yellow
58
+ } else {
59
+ if (-not $text.EndsWith("`n")) { $text += "`r`n" }
60
+ $text += "`r`n# DHCP 保留:$HostName($Mac -> $Address),由 dhcp-reserve-kali.ps1 添加`r`n"
61
+ $text += $newBlock
62
+ Write-Host "已追加 host $HostName 条目" -ForegroundColor Yellow
63
+ }
64
+
65
+ Set-Content -LiteralPath $conf -Value $text -NoNewline -Encoding utf8
66
+
67
+ # 3. 重启 DHCP 服务让新配置生效。
68
+ $service = Get-Service -Name 'VMnetDHCP' -ErrorAction SilentlyContinue
69
+ if ($null -eq $service) {
70
+ Write-Warning '没找到 VMnetDHCP 服务,配置已写入但需要手动重启 VMware DHCP。'
71
+ } else {
72
+ Restart-Service -Name 'VMnetDHCP' -Force
73
+ Write-Host '已重启 VMnetDHCP 服务' -ForegroundColor Green
74
+ }
75
+
76
+ Write-Host ''
77
+ Write-Host '完成。接下来在 Kali 里释放并重新获取租约:' -ForegroundColor Cyan
78
+ Write-Host ' sudo dhclient -r eth0 && sudo dhclient eth0'
79
+ Write-Host ' ip -4 addr show eth0'
80
+ Write-Host ''
81
+ Write-Host "预期拿到 $Address。" -ForegroundColor Cyan
@@ -0,0 +1,63 @@
1
+ """生成 dsh-market 用的位图图标(180x180 PNG)。
2
+
3
+ 市场卡片约 96px 显示,所以图形必须是大色块、少细节。主题是「加密的 SSH 通道」:
4
+ 深色圆角底 + 终端提示符 + 一把锁,用几何图形画,不依赖任何字体文件。
5
+ """
6
+ from PIL import Image, ImageDraw
7
+
8
+ SIZE = 180
9
+ SCALE = 2 # 先画 2 倍再缩小,边缘更干净
10
+ S = SIZE * SCALE
11
+
12
+
13
+ def rounded_rect(draw, box, radius, fill):
14
+ draw.rounded_rectangle(box, radius=radius, fill=fill)
15
+
16
+
17
+ img = Image.new("RGBA", (S, S), (0, 0, 0, 0))
18
+ d = ImageDraw.Draw(img)
19
+
20
+ # —— 底板:深色圆角方块 ——
21
+ rounded_rect(d, [0, 0, S - 1, S - 1], radius=40 * SCALE, fill=(22, 24, 30, 255))
22
+
23
+ # —— 终端窗口:比底板略小的圆角矩形,稍亮一档 ——
24
+ pad = 26 * SCALE
25
+ win = [pad, pad + 6 * SCALE, S - pad, S - pad]
26
+ rounded_rect(d, win, radius=14 * SCALE, fill=(32, 36, 46, 255))
27
+
28
+ # 窗口标题栏三个点
29
+ dot_r = 4 * SCALE
30
+ dot_y = win[1] + 12 * SCALE
31
+ for i, color in enumerate([(255, 95, 86), (255, 189, 46), (39, 201, 63)]):
32
+ cx = win[0] + (14 + i * 14) * SCALE
33
+ d.ellipse([cx - dot_r, dot_y - dot_r, cx + dot_r, dot_y + dot_r], fill=color)
34
+
35
+ # —— 提示符 ">" ——
36
+ accent = (86, 182, 255)
37
+ cx, cy = win[0] + 26 * SCALE, win[1] + 44 * SCALE
38
+ d.line([cx, cy, cx + 13 * SCALE, cy + 13 * SCALE], fill=accent, width=6 * SCALE)
39
+ d.line([cx + 13 * SCALE, cy + 13 * SCALE, cx, cy + 26 * SCALE], fill=accent, width=6 * SCALE)
40
+ # 光标下划线
41
+ d.line([cx + 19 * SCALE, cy + 26 * SCALE, cx + 38 * SCALE, cy + 26 * SCALE],
42
+ fill=accent, width=6 * SCALE)
43
+
44
+ # —— 锁:压在终端右下角,表示"加密" ——
45
+ lw, lh = 40 * SCALE, 34 * SCALE
46
+ lx, ly = S - pad - lw + 14 * SCALE, win[3] - lh + 10 * SCALE
47
+ shackle_w = 22 * SCALE
48
+ shackle_top = ly - 16 * SCALE
49
+ shackle_x = lx + (lw - shackle_w) // 2
50
+ d.arc([shackle_x, shackle_top, shackle_x + shackle_w, ly + 8 * SCALE],
51
+ start=180, end=360, fill=(255, 199, 89), width=6 * SCALE)
52
+
53
+ lock_fill = (255, 199, 89)
54
+ rounded_rect(d, [lx, ly, lx + lw, ly + lh], radius=6 * SCALE, fill=lock_fill)
55
+ # 锁孔:圆 + 竖槽
56
+ k_cx, k_cy = lx + lw // 2, ly + lh // 2 - 2 * SCALE
57
+ d.ellipse([k_cx - 4 * SCALE, k_cy - 4 * SCALE, k_cx + 4 * SCALE, k_cy + 4 * SCALE],
58
+ fill=(32, 36, 46, 255))
59
+ d.line([k_cx, k_cy, k_cx, k_cy + 9 * SCALE], fill=(32, 36, 46, 255), width=4 * SCALE)
60
+
61
+ img = img.resize((SIZE, SIZE), Image.LANCZOS)
62
+ img.save("assets/icon-180.png", "PNG")
63
+ print("wrote assets/icon-180.png", img.size)