ddeck-remote 0.0.0-stage → 0.9.7-beta

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/tls.js ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Certificates for the gateway.
3
+ *
4
+ * HTTPS is not an optional hardening step here: the phone's manifest forbids cleartext,
5
+ * so a plain-HTTP listener is a door nobody can open. The certificate must exist before
6
+ * the first pairing, and it must stay the same afterwards — the phone pins the CA
7
+ * fingerprint it learned, and a CA that changed on restart would break every phone
8
+ * already connected.
9
+ *
10
+ * The gateway's own contract decides the shape: `instanceId` must equal the SHA-256
11
+ * fingerprint of the pairing CA, and the leaf must be signed by that CA. So the CA is
12
+ * generated first, its fingerprint becomes the installation's identity, and the leaf is
13
+ * signed by it with the machine's address in `subjectAltName`.
14
+ *
15
+ * @module dsh-remote/tls
16
+ */
17
+
18
+ import { createHash } from 'node:crypto'
19
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
20
+ import { isIP } from 'node:net'
21
+ import { join } from 'node:path'
22
+ import forge from './node-forge-bundle.js'
23
+
24
+ /** How long the installation CA lives: re-pinning every phone is the cost of shorter. */
25
+ const CA_DAYS = 3650
26
+
27
+ /** How long the leaf lives; short enough to rotate without touching the CA. */
28
+ const LEAF_DAYS = 397
29
+
30
+ /**
31
+ * Read a file, or return undefined when it is not there.
32
+ *
33
+ * @param {string} file
34
+ * @returns {Promise<string | undefined>}
35
+ */
36
+ async function readIfPresent(file) {
37
+ try {
38
+ return await readFile(file, 'utf8')
39
+ } catch {
40
+ return undefined
41
+ }
42
+ }
43
+
44
+ /**
45
+ * SHA-256 of a DER certificate, lower-case hex without separators.
46
+ *
47
+ * This is exactly what the gateway compares against `instanceId`, and what the phone
48
+ * pins after pairing.
49
+ *
50
+ * @param {string} pem - certificate in PEM form.
51
+ * @returns {string} 64 hex characters.
52
+ */
53
+ export function certificateFingerprint(pem) {
54
+ const der = forge.pem.decode(pem)[0].body
55
+ return createHash('sha256').update(der, 'binary').digest('hex')
56
+ }
57
+
58
+ /**
59
+ * Make a key pair.
60
+ *
61
+ * 2048 bits: the phone may be a modest device, and a longer key only slows the handshake
62
+ * down for no benefit on a home network.
63
+ *
64
+ * @returns {object} forge key pair.
65
+ */
66
+ function keyPair() {
67
+ return forge.pki.rsa.generateKeyPair({ bits: 2048, workers: -1 })
68
+ }
69
+
70
+ /**
71
+ * Build the installation CA.
72
+ *
73
+ * @returns {{ cert: string, key: string, fingerprint: string }}
74
+ */
75
+ function createCa() {
76
+ const pair = keyPair()
77
+ const certificate = forge.pki.createCertificate()
78
+ certificate.publicKey = pair.publicKey
79
+ certificate.serialNumber = `01${forge.util.bytesToHex(forge.random.getBytesSync(8))}`
80
+ certificate.validity.notBefore = new Date(Date.now() - 60_000)
81
+ certificate.validity.notAfter = new Date(Date.now() + CA_DAYS * 24 * 60 * 60 * 1000)
82
+ const attributes = [
83
+ { name: 'commonName', value: 'DDeck CA' },
84
+ { name: 'organizationName', value: 'DDeck' },
85
+ ]
86
+ certificate.setSubject(attributes)
87
+ certificate.setIssuer(attributes)
88
+ certificate.setExtensions([
89
+ { name: 'basicConstraints', cA: true, critical: true },
90
+ { name: 'keyUsage', keyCertSign: true, cRLSign: true, critical: true },
91
+ ])
92
+ certificate.sign(pair.privateKey, forge.md.sha256.create())
93
+ const cert = forge.pki.certificateToPem(certificate)
94
+ return { cert, key: forge.pki.privateKeyToPem(pair.privateKey), fingerprint: certificateFingerprint(cert) }
95
+ }
96
+
97
+ /**
98
+ * Sign a leaf for one address with the installation CA.
99
+ *
100
+ * @param {{ cert: string, key: string }} ca - the installation CA.
101
+ * @param {string} host - IPv4 address the phone dials.
102
+ * @returns {{ cert: string, key: string }}
103
+ */
104
+ function createLeaf(ca, host) {
105
+ // A host list lets the same leaf cover the LAN address and a Tailscale name
106
+ // (`DSH_REMOTE_ORIGINS`); the first entry is the common name.
107
+ const hosts = String(host).split(',').map((entry) => entry.trim()).filter(Boolean)
108
+ const primary = hosts[0]
109
+ const pair = keyPair()
110
+ const certificate = forge.pki.createCertificate()
111
+ certificate.publicKey = pair.publicKey
112
+ certificate.serialNumber = `02${forge.util.bytesToHex(forge.random.getBytesSync(8))}`
113
+ certificate.validity.notBefore = new Date(Date.now() - 60_000)
114
+ certificate.validity.notAfter = new Date(Date.now() + LEAF_DAYS * 24 * 60 * 60 * 1000)
115
+ certificate.setSubject([{ name: 'commonName', value: primary }])
116
+ certificate.setIssuer(forge.pki.certificateFromPem(ca.cert).subject.attributes)
117
+ // Tailscale uses names (`mac.tailnet.ts.net`) as well as 100.x addresses. An IP in
118
+ // the `ip` field is what node-forge accepts for IP SANs; a DNS name belongs in a
119
+ // `dns` entry (type 2) — putting it in `ip` throws and the gateway would not start.
120
+ const altNames = hosts.map((entry) => (isIP(entry) === 0
121
+ ? { type: 2, value: entry }
122
+ : { type: 7, ip: entry }))
123
+ certificate.setExtensions([
124
+ { name: 'basicConstraints', cA: false },
125
+ { name: 'keyUsage', digitalSignature: true, keyEncipherment: true, critical: true },
126
+ { name: 'extKeyUsage', serverAuth: true },
127
+ { name: 'subjectAltName', altNames },
128
+ ])
129
+ certificate.sign(forge.pki.privateKeyFromPem(ca.key), forge.md.sha256.create())
130
+ return { cert: forge.pki.certificateToPem(certificate), key: forge.pki.privateKeyToPem(pair.privateKey) }
131
+ }
132
+
133
+ /**
134
+ * Load the installation's certificates, creating them on first use.
135
+ *
136
+ * @param {string} stateDirectory - where the material lives.
137
+ * @param {string} host - address the phone will dial.
138
+ * @returns {Promise<{ caFile: string, certFile: string, keyFile: string, instanceId: string }>}
139
+ */
140
+ export async function ensureCertificates(stateDirectory, host) {
141
+ const directory = join(stateDirectory, 'tls')
142
+ await mkdir(directory, { recursive: true })
143
+ const caFile = join(directory, 'ca.pem')
144
+ const caKeyFile = join(directory, 'ca-key.pem')
145
+ const certFile = join(directory, 'server.pem')
146
+ const keyFile = join(directory, 'server-key.pem')
147
+ const hostFile = join(directory, 'leaf-host.txt')
148
+
149
+ let ca = await readIfPresent(caFile)
150
+ let caKey = await readIfPresent(caKeyFile)
151
+ if (ca === undefined || caKey === undefined) {
152
+ const created = createCa()
153
+ ca = created.cert
154
+ caKey = created.key
155
+ await writeFile(caFile, ca, { mode: 0o600 })
156
+ await writeFile(caKeyFile, caKey, { mode: 0o600 })
157
+ }
158
+ const instanceId = certificateFingerprint(ca)
159
+
160
+ // The leaf is re-signed when the address changes (a laptop moves between networks) or
161
+ // when it is missing. The CA stays, so phones keep their pin.
162
+ const previousHost = await readIfPresent(hostFile)
163
+ const cert = await readIfPresent(certFile)
164
+ const key = await readIfPresent(keyFile)
165
+ const wantedHost = String(host)
166
+ if (cert === undefined || key === undefined || previousHost?.trim() !== wantedHost) {
167
+ const leaf = createLeaf({ cert: ca, key: caKey }, wantedHost)
168
+ await writeFile(certFile, leaf.cert, { mode: 0o600 })
169
+ await writeFile(keyFile, leaf.key, { mode: 0o600 })
170
+ await writeFile(hostFile, `${wantedHost}\n`, { mode: 0o600 })
171
+ }
172
+ return { caFile, certFile, keyFile, instanceId }
173
+ }
package/locale/en.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "title": "DDeck",
3
+ "description": "Connect your phone to this computer: show a code, scan it once."
4
+ }
package/locale/ru.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "title": "DDeck",
3
+ "description": "Подключите телефон к этому компьютеру: покажите код, отсканируйте его один раз."
4
+ }
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "ddeck-remote",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.9.7-beta",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Connect your phone to DeepSeek Harness: one code on the computer, one scan on the phone.",
7
+ "license": "Apache-2.0",
8
+ "main": "./lib/index.js",
9
+ "exports": {
10
+ ".": "./lib/index.js",
11
+ "./icon": "./icon.svg",
12
+ "./locale/*.json": "./locale/*.json",
13
+ "./package.json": "./package.json",
14
+ "./client": "./lib/client.js"
15
+ },
16
+ "files": [
17
+ "lib",
18
+ "cordis.patch.yml",
19
+ "install.sh",
20
+ "install.ps1",
21
+ "install.cmd",
22
+ "uninstall.sh",
23
+ "uninstall.ps1",
24
+ "icon.svg",
25
+ "locale/*.json",
26
+ "README.md",
27
+ "LICENSE",
28
+ "NOTICE",
29
+ "THIRD-PARTY-LICENSES.md"
30
+ ],
31
+ "dsh": {
32
+ "bundle": {
33
+ "patch": "./cordis.patch.yml"
34
+ },
35
+ "client": {
36
+ "platform": "web",
37
+ "inject": [
38
+ "@deepseek-ai/dsh-client-ui-settings"
39
+ ]
40
+ }
41
+ },
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/nemezida1337/ddeck-client.git"
45
+ },
46
+ "homepage": "https://github.com/nemezida1337/ddeck-client#readme",
47
+ "bugs": {
48
+ "url": "https://github.com/nemezida1337/ddeck-client/issues"
49
+ },
50
+ "keywords": [
51
+ "dsh",
52
+ "deepseek-harness",
53
+ "remote",
54
+ "android",
55
+ "plugin"
56
+ ],
57
+ "engines": {
58
+ "dsh": "^0.1.5-rc.1 || ^0.2.1-rc.2"
59
+ },
60
+ "devDependencies": {
61
+ "qrcode": "^1.5.4",
62
+ "node-forge": "^1.3.1",
63
+ "esbuild": "^0.28.2"
64
+ }
65
+ }
package/uninstall.ps1 ADDED
@@ -0,0 +1,60 @@
1
+ #Requires -Version 5.1
2
+ <#
3
+ .SYNOPSIS
4
+ Удаляет плагин DDeck из профиля DeepSeek Harness на Windows.
5
+ .DESCRIPTION
6
+ Убирает link-зависимость, бандл, строку cordis.patch.yml и junction.
7
+ Данные сопряжений в ~/.dsh/dsh-remote не трогает.
8
+ #>
9
+ [CmdletBinding()]
10
+ param([string]$Profile = $env:DSH_PROFILE)
11
+
12
+ $ErrorActionPreference = 'Stop'
13
+ $PackageName = 'ddeck-remote'
14
+ $LegacyName = 'dsh-remote'
15
+ if ([string]::IsNullOrWhiteSpace($Profile)) {
16
+ $Profile = Join-Path $HOME '.dsh\profiles\desktop'
17
+ } elseif (-not [System.IO.Path]::IsPathRooted($Profile)) {
18
+ $Profile = Join-Path (Join-Path $HOME '.dsh\profiles') $Profile
19
+ }
20
+ if (-not (Test-Path $Profile)) { throw "профиль DSH не найден: $Profile" }
21
+ $Profile = (Resolve-Path $Profile).Path
22
+
23
+ $stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
24
+ $backup = Join-Path $Profile ".dsh-remote-backups\$stamp"
25
+ New-Item -ItemType Directory -Path $backup -Force | Out-Null
26
+ $manifestPath = Join-Path $Profile 'package.json'
27
+ if (Test-Path $manifestPath) {
28
+ Copy-Item $manifestPath (Join-Path $backup 'package.json') -Force
29
+ $manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json
30
+ if ($manifest.PSObject.Properties['dependencies']) {
31
+ foreach ($name in @($PackageName, $LegacyName)) {
32
+ $manifest.dependencies.PSObject.Properties.Remove($name)
33
+ }
34
+ }
35
+ if ($manifest.PSObject.Properties['dsh'] -and $manifest.dsh.PSObject.Properties['profile'] -and $manifest.dsh.profile.PSObject.Properties['bundles']) {
36
+ $manifest.dsh.profile.bundles = @($manifest.dsh.profile.bundles | Where-Object { $_ -ne $PackageName -and $_ -ne $LegacyName })
37
+ }
38
+ [System.IO.File]::WriteAllText($manifestPath, (($manifest | ConvertTo-Json -Depth 50) + [Environment]::NewLine), (New-Object System.Text.UTF8Encoding($false)))
39
+ }
40
+ $patchPath = Join-Path $Profile 'cordis.patch.yml'
41
+ if (Test-Path $patchPath) {
42
+ Copy-Item $patchPath (Join-Path $backup 'cordis.patch.yml') -Force
43
+ $patch = Get-Content $patchPath -Raw
44
+ foreach ($name in @($PackageName, $LegacyName)) {
45
+ $pattern = '(?ms)^\s*[-]\s*insert:\s*\r?\n\s*-\s*id:\s*' + [regex]::Escape($name) + '\s*\r?\n\s*name:\s*' + [regex]::Escape($name) + '\s*\r?\n?'
46
+ $patch = [regex]::Replace($patch, $pattern, '')
47
+ }
48
+ [System.IO.File]::WriteAllText($patchPath, $patch, (New-Object System.Text.UTF8Encoding($false)))
49
+ }
50
+ foreach ($name in @($PackageName, $LegacyName)) {
51
+ $target = Join-Path (Join-Path $Profile 'node_modules') $name
52
+ if (-not (Test-Path $target)) { continue }
53
+ $item = Get-Item $target -Force
54
+ if ($item.LinkType -eq 'Junction') {
55
+ $item.Delete()
56
+ } else {
57
+ Move-Item $target (Join-Path $backup "node_modules-$name.old") -Force
58
+ }
59
+ }
60
+ Write-Host "DDeck удалён. Бэкап: $backup`nПерезапустите DeepSeek Harness."
package/uninstall.sh ADDED
@@ -0,0 +1,31 @@
1
+ #!/bin/sh
2
+ # Удаляет DDeck из профиля DSH, не трогая данные сопряжений (~/.dsh/dsh-remote).
3
+ set -eu
4
+ PROFILE="${1:-${DSH_PROFILE:-$HOME/.dsh/profiles/desktop}}"
5
+ # DSH выставляет DSH_PROFILE как имя профиля («desktop»), а не путь.
6
+ case "$PROFILE" in /*) ;; *) PROFILE="$HOME/.dsh/profiles/$PROFILE" ;; esac
7
+ [ -d "$PROFILE" ] || { echo "профиль DSH не найден: $PROFILE" >&2; exit 1; }
8
+ python3 - "$PROFILE" <<'PY'
9
+ import json, pathlib, sys
10
+ profile = pathlib.Path(sys.argv[1])
11
+ manifest = profile / 'package.json'
12
+ if manifest.exists():
13
+ data = json.loads(manifest.read_text())
14
+ for name in ('ddeck-remote', 'dsh-remote'):
15
+ data.get('dependencies', {}).pop(name, None)
16
+ bundles = data.get('dsh', {}).get('profile', {}).get('bundles', [])
17
+ for name in ('ddeck-remote', 'dsh-remote'):
18
+ while name in bundles:
19
+ bundles.remove(name)
20
+ manifest.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n')
21
+ patch = profile / 'cordis.patch.yml'
22
+ if patch.exists():
23
+ text = patch.read_text()
24
+ for name in ('ddeck-remote', 'dsh-remote'):
25
+ marker = f"- insert:\n - id: {name}\n name: {name}\n"
26
+ text = text.replace(marker, '')
27
+ patch.write_text(text)
28
+ PY
29
+ rm -f "$PROFILE/node_modules/ddeck-remote" "$PROFILE/node_modules/dsh-remote"
30
+ echo "DDeck удалён из профиля. Данные сопряжений остались в ~/.dsh/dsh-remote."
31
+ echo "Перезапустите DeepSeek Harness."