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/LICENSE +202 -0
- package/NOTICE +40 -0
- package/README.md +96 -2
- package/THIRD-PARTY-LICENSES.md +69 -0
- package/cordis.patch.yml +22 -0
- package/icon.svg +6 -0
- package/install.cmd +4 -0
- package/install.ps1 +119 -0
- package/install.sh +95 -0
- package/lib/admin-trust.js +64 -0
- package/lib/client.js +751 -0
- package/lib/constants.js +11 -0
- package/lib/gateway-bundle.js +7063 -0
- package/lib/gateway.js +275 -0
- package/lib/index.js +1088 -0
- package/lib/mobile/index.js +82 -0
- package/lib/mobile/skin.css +1904 -0
- package/lib/mobile/skin.js +3614 -0
- package/lib/node-forge-bundle.js +17841 -0
- package/lib/qr-bundle.js +1990 -0
- package/lib/tls.js +173 -0
- package/locale/en.json +4 -0
- package/locale/ru.json +4 -0
- package/package.json +63 -4
- package/uninstall.ps1 +60 -0
- package/uninstall.sh +31 -0
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
package/locale/ru.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ddeck-remote",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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."
|