ocremote 1.3.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/README.md +277 -0
- package/install.sh +261 -0
- package/lib/crypto.mjs +146 -0
- package/lib/identity.mjs +165 -0
- package/lib/relay.mjs +495 -0
- package/lib/rpc.mjs +114 -0
- package/oc-remote.mjs +2390 -0
- package/package.json +34 -0
- package/vendor/qrcode.js +2297 -0
package/README.md
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# oc-remote companion
|
|
2
|
+
|
|
3
|
+
Proceso Node sin dependencias externas (solo APIs nativas de Node 22+) que
|
|
4
|
+
convierte un Mac en un host de **opencode** accesible desde la app iOS
|
|
5
|
+
OpenCode Remote, con autenticacion por token, pairing por QR y notificaciones
|
|
6
|
+
push opcionales.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
iOS ──token──► 0.0.0.0:4190 ┌──────────────┐
|
|
10
|
+
│ oc-remote │ inyecta Basic interno
|
|
11
|
+
│ (proxy+auth) │──────────────► 127.0.0.1:4191
|
|
12
|
+
└──────────────┘ opencode serve
|
|
13
|
+
QR / mDNS / ntfy (solo loopback)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- El companion genera un **token propio** (32 hex) y lo guarda en
|
|
17
|
+
`~/.config/oc-remote/config.json` (0600). Solo rota con `--new-token`.
|
|
18
|
+
- Arranca `opencode serve` en `127.0.0.1:<opencode-port>` con un **password
|
|
19
|
+
interno aleatorio distinto** (`OPENCODE_SERVER_PASSWORD`) que se regenera en
|
|
20
|
+
cada arranque y **nunca sale del Mac**: el companion lo inyecta en cada
|
|
21
|
+
peticion upstream. La red solo ve el token del companion.
|
|
22
|
+
- Escucha en `0.0.0.0:<port>` y hace proxy streaming (SSE sin buffering) a
|
|
23
|
+
`http://127.0.0.1:<opencode-port>`.
|
|
24
|
+
|
|
25
|
+
## Requisitos
|
|
26
|
+
|
|
27
|
+
- macOS con Node 22 o superior (`node --version`).
|
|
28
|
+
- El binario `opencode` (por defecto `~/.opencode/bin/opencode`).
|
|
29
|
+
- Opcional: `tailscale` (acceso remoto), `ntfy` (push, no requiere instalar
|
|
30
|
+
nada en el Mac), `dns-sd` (viene con macOS).
|
|
31
|
+
|
|
32
|
+
## Arranque manual
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
node companion/oc-remote.mjs --dir /ruta/al/proyecto
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Salida (stderr): hostname, URLs candidatas (LAN, Tailscale, `--public-url`),
|
|
39
|
+
token, y el **QR ASCII** con el deep link de pairing (`--print-qr` esta activo
|
|
40
|
+
por defecto; `--no-print-qr` lo desactiva).
|
|
41
|
+
|
|
42
|
+
Flags:
|
|
43
|
+
|
|
44
|
+
| Flag | Default | Descripcion |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `--dir <path>` | cwd | Directorio de proyecto que usa opencode (debe existir) |
|
|
47
|
+
| `--port <n>` | 4190 | Puerto publico del companion |
|
|
48
|
+
| `--opencode-port <n>` | 4191 | Puerto loopback del `opencode serve` interno |
|
|
49
|
+
| `--host <ip>` | 0.0.0.0 | Interfaz de escucha |
|
|
50
|
+
| `--token <str>` | — | Fija el token (se persiste en config.json) |
|
|
51
|
+
| `--new-token` | — | Rota el token y lo persiste |
|
|
52
|
+
| `--tunnel <mode>` | `auto` | `auto` \| `cloudflare` \| `tailscale` \| `none` (ver Acceso remoto) |
|
|
53
|
+
| `--ntfy <topic\|url>` | — | Activa notificaciones push |
|
|
54
|
+
| `--public-url <url>` | — | URL publica a anunciar (tu propio tunel; salta `--tunnel`) |
|
|
55
|
+
| `--no-auth` | — | Desactiva auth (**peligroso**, avisa por stderr) |
|
|
56
|
+
| `--opencode <path>` | `~/.opencode/bin/opencode` | Binario de opencode |
|
|
57
|
+
| `--no-mdns` | mDNS activo | No publicar `_ocremote._tcp` por Bonjour |
|
|
58
|
+
| `--print-qr` / `--no-print-qr` | on | Imprimir el QR al arrancar |
|
|
59
|
+
| `--help` | | Ayuda |
|
|
60
|
+
|
|
61
|
+
Variables de entorno: `OCREMOTE_CONFIG_DIR` (cambia `~/.config/oc-remote`),
|
|
62
|
+
`OPENCODE_BIN` (alternativa a `--opencode`), `OCREMOTE_CLOUDFLARED` (ruta a un
|
|
63
|
+
binario cloudflared propio), `OCREMOTE_TAILSCALE` (ruta al CLI `tailscale`; útil
|
|
64
|
+
para tests).
|
|
65
|
+
|
|
66
|
+
## Subcomandos
|
|
67
|
+
|
|
68
|
+
| Comando | Qué hace |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `oc-remote doctor` | Verifica toda la cadena: binario de opencode, token, puerto, auth, upstream, LaunchAgent, LAN, Tailscale/Funnel, endpoint público y ntfy. Salida `ok`/`FAIL` con el arreglo de cada fallo |
|
|
71
|
+
| `oc-remote status` | Config, estado del LaunchAgent, endpoint actual (de `endpoint.json`) y prueba de auth |
|
|
72
|
+
| `oc-remote restart` | `launchctl kickstart -k` del LaunchAgent |
|
|
73
|
+
| `oc-remote logs` | Ruta del log y últimas 40 líneas |
|
|
74
|
+
| `oc-remote funnel on\|off\|status` | Activa/desactiva el Funnel de Tailscale (la primera vez imprime el link de aprobación). El daemon en `auto` lo adopta solo |
|
|
75
|
+
| `oc-remote version` | Versión |
|
|
76
|
+
|
|
77
|
+
## Beacons de endpoint (auto-reparación)
|
|
78
|
+
|
|
79
|
+
Con `--ntfy <topic|url>`, además de las notificaciones push el companion publica
|
|
80
|
+
un **beacon** con el endpoint actual: al arrancar, cada vez que cambia la URL
|
|
81
|
+
pública, y como heartbeat cada 5 minutos. Formato (mensaje ntfy con tag
|
|
82
|
+
`ocremote-endpoint`):
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{"type":"endpoint","id":"1887884bfde3","name":"MacBook","version":"1.2.0",
|
|
86
|
+
"url":"https://mac.tailnet.ts.net","urls":[{"url":"...","label":"tunnel"}],
|
|
87
|
+
"provider":"funnel","auth":true,"port":4190,"ts":1789547880425}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- `id` es un **hash** del token (nunca el token): sirve para que la app ignore
|
|
91
|
+
beacons de otros Macs.
|
|
92
|
+
- La app guarda esa lista de `urls` y prueba los candidatos en orden cuando la
|
|
93
|
+
URL actual falla; si todos fallan, consulta el último beacon del topic
|
|
94
|
+
(`/json?poll=1`) y se actualiza sola. El QR incluye el topic (`ntfy=...`) y la
|
|
95
|
+
lista inicial (`urls=...`), así que un solo escaneo lo configura todo.
|
|
96
|
+
- El beacon también se escribe en `~/.config/oc-remote/endpoint.json` (0600) para
|
|
97
|
+
`oc-remote status/doctor`.
|
|
98
|
+
|
|
99
|
+
## Watchdog
|
|
100
|
+
|
|
101
|
+
- `opencode serve` se relanza solo si muere (backoff exponencial), y un chequeo
|
|
102
|
+
cada 30 s vía `/global/health` lo reinicia si queda colgado (3 fallos).
|
|
103
|
+
- `cloudflared` se relanza solo; el Funnel se re-aplica si deja de servir.
|
|
104
|
+
- El puerto del companion no cambia entre reinicios: la app solo necesita el
|
|
105
|
+
endpoint actual, no un puerto nuevo.
|
|
106
|
+
- Con `auto`, el companion **se adapta en caliente**: si aparece un Funnel activo
|
|
107
|
+
pasa a la URL estable, y si desaparece vuelve al quick tunnel o a LAN/Tailscale.
|
|
108
|
+
|
|
109
|
+
## Pairing
|
|
110
|
+
|
|
111
|
+
1. **QR del terminal**: contiene el deep link
|
|
112
|
+
`ocremote://pair?v=1&name=<hostname>&url=http%3A%2F%2F<ip>%3A<port>&token=<token>`.
|
|
113
|
+
2. **Pagina de pairing**: `http://127.0.0.1:4190/_ocremote/pair` (solo
|
|
114
|
+
loopback sin token; desde otra maquina requiere `?token=<token>`). Muestra
|
|
115
|
+
el QR como PNG, la URL, el token y botones de copiar.
|
|
116
|
+
3. **Manual**: URL `http://<ip>:<port>` + token.
|
|
117
|
+
|
|
118
|
+
La app envia `Authorization: Basic base64("opencode:"+token)` (llama "token" a
|
|
119
|
+
la password) y para SSE puede usar `?auth_token=<base64(opencode:token)>`.
|
|
120
|
+
El companion acepta ademas `Authorization: Bearer <token>` y `?token=<token>`.
|
|
121
|
+
`token`/`auth_token` se eliminan de la query antes de reenviar al upstream.
|
|
122
|
+
|
|
123
|
+
### Payload del QR
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
ocremote://pair?v=1&name=<hostname>&url=http%3A%2F%2F<ip>%3A<port>&token=<token>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
| Parametro | Significado |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `v` | Version del esquema de pairing (1) |
|
|
132
|
+
| `name` | `os.hostname()` del Mac, URL-encoded |
|
|
133
|
+
| `url` | Base URL a la que conectarse, URL-encoded |
|
|
134
|
+
| `token` | Token del companion, URL-encoded |
|
|
135
|
+
|
|
136
|
+
## Endpoints propios
|
|
137
|
+
|
|
138
|
+
| Endpoint | Auth | Respuesta |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| `GET /_ocremote/health` | no | `{"ok":true}` (monitor local) |
|
|
141
|
+
| `GET /_ocremote/status` | token | `{name, version, directory, uptime, port, opencodePort, clients, tunnel}` |
|
|
142
|
+
| `GET /_ocremote/pair` | loopback o token | Pagina HTML con QR PNG, URL y token |
|
|
143
|
+
|
|
144
|
+
Todo lo demas se proxya a opencode. Los `Upgrade`/WebSocket responden
|
|
145
|
+
`501` (PTY fuera de alcance). Metodos fuera de
|
|
146
|
+
`GET/HEAD/POST/PUT/PATCH/DELETE/OPTIONS` responden `405`. Errores de upstream
|
|
147
|
+
responden `502` JSON.
|
|
148
|
+
|
|
149
|
+
## Acceso remoto
|
|
150
|
+
|
|
151
|
+
`--tunnel` decide como se anuncia la URL (y cual va dentro del QR):
|
|
152
|
+
|
|
153
|
+
| Modo | Que hace |
|
|
154
|
+
|---|---|
|
|
155
|
+
| `auto` (default) | Si `cloudflared` ya esta instalado lo usa; si no, Tailscale si esta conectado; si no, solo LAN con un aviso |
|
|
156
|
+
| `cloudflare` | Quick tunnel: descarga `cloudflared` la primera vez (a `~/.config/oc-remote/bin/`), arranca el tunel y publica la URL HTTPS |
|
|
157
|
+
| `tailscale` | Anuncia la IP de Tailscale (URL estable) |
|
|
158
|
+
| `none` | Solo LAN |
|
|
159
|
+
|
|
160
|
+
### Cloudflare quick tunnel (cero configuracion)
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
oc-remote --dir ~/Projects/my-app --tunnel cloudflare
|
|
164
|
+
# [oc-remote] cloudflare quick tunnel ready: https://calm-river-1234.trycloudflare.com
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
No necesita cuenta. La URL cambia en cada reinicio: para una URL fija usa
|
|
168
|
+
Tailscale o un tunel con nombre propio. Si `cloudflared` muere, el companion lo
|
|
169
|
+
relanza con backoff; cuando hay URL nueva reimprime el QR.
|
|
170
|
+
|
|
171
|
+
### Tailscale (URL estable)
|
|
172
|
+
|
|
173
|
+
Es una VPN mesh: el trafico va cifrado y solo tus dispositivos entran, sin
|
|
174
|
+
abrir puertos ni exponer nada a Internet.
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
brew install --cask tailscale # o la app de la Mac App Store
|
|
178
|
+
tailscale up # login
|
|
179
|
+
tailscale ip -4 # 100.x.y.z
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Con `--tunnel tailscale` (o `auto` sin cloudflared) el companion imprime
|
|
183
|
+
`http://100.x.y.z:4190`. Desde el iPhone con Tailscale activo:
|
|
184
|
+
|
|
185
|
+
- por IP: `http://100.x.y.z:4190`
|
|
186
|
+
- por MagicDNS (mas comodo): `http://<nombre-maquina>.<tailnet>.ts.net:4190`
|
|
187
|
+
|
|
188
|
+
Opcional: `--host <tailscale-ip>` para escuchar solo en la VPN. La primera vez
|
|
189
|
+
macOS puede pedir permiso de firewall para `node`.
|
|
190
|
+
|
|
191
|
+
### Tunel propio
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
cloudflared tunnel --url http://127.0.0.1:4190
|
|
195
|
+
# imprime una URL https://xxxx.trycloudflare.com
|
|
196
|
+
oc-remote --public-url https://xxxx.trycloudflare.com
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Con `--public-url` esa URL se usa en el QR, en los logs y en el `click` de
|
|
200
|
+
ntfy (y se salta `--tunnel`). El tunel expone el companion a Internet: el token
|
|
201
|
+
es la unica barrera, con rate limit de 20 intentos fallidos por minuto y IP
|
|
202
|
+
(respuesta `429 too_many_requests`). Prefiere Tailscale para uso diario.
|
|
203
|
+
|
|
204
|
+
## ntfy (opcional)
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
node companion/oc-remote.mjs --ntfy ocremote-raul-a8f3k2
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
- `permission.asked` → `opencode: permission required` (priority 4, tags
|
|
211
|
+
`warning`, click a la URL).
|
|
212
|
+
- `session.status busy` seguido de `session.idle` → `opencode: task finished`.
|
|
213
|
+
- Se abre una conexion SSE propia a `/event` con el Basic interno; si falla,
|
|
214
|
+
se reintenta cada 5 s y nunca afecta al proxy.
|
|
215
|
+
- Acepta un topic de ntfy.sh o una URL completa (self-hosted):
|
|
216
|
+
`--ntfy https://ntfy.midominio.dev/mi-topic`.
|
|
217
|
+
- Los topics de ntfy.sh son publicos: usa uno largo y aleatorio.
|
|
218
|
+
|
|
219
|
+
## Instalacion como LaunchAgent
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
bash companion/install.sh --dir /Users/raul/Desktop/Projects/opencode-remote
|
|
223
|
+
bash companion/install.sh --port 4190 --token mi-token # idempotente
|
|
224
|
+
bash companion/install.sh --uninstall
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Crea:
|
|
228
|
+
|
|
229
|
+
- symlink `~/.local/bin/oc-remote` → `companion/oc-remote.mjs`
|
|
230
|
+
- `~/Library/LaunchAgents/com.raul.ocremote.plist` (`KeepAlive`, `RunAtLoad`,
|
|
231
|
+
`WorkingDirectory`, `ThrottleInterval` 10 s, permisos 0600)
|
|
232
|
+
- logs en `~/Library/Logs/oc-remote.log` (stdout y stderr)
|
|
233
|
+
|
|
234
|
+
El QR y el token quedan en el log: `tail -f ~/Library/Logs/oc-remote.log`.
|
|
235
|
+
`--no-load` genera los ficheros sin cargar el agente (util para inspeccionar
|
|
236
|
+
el plist). No borra `~/.config/oc-remote/config.json` al desinstalar.
|
|
237
|
+
|
|
238
|
+
## Seguridad
|
|
239
|
+
|
|
240
|
+
- Token de 32 hex comparado con `crypto.timingSafeEqual`; el config se escribe
|
|
241
|
+
0600 (directorio 0700).
|
|
242
|
+
- El opencode interno escucha solo en `127.0.0.1` con password aleatorio de 32
|
|
243
|
+
hex regenerado en cada arranque; la LAN no puede hablar con el directamente.
|
|
244
|
+
- Sin TLS: no expongas el puerto a Internet directamente. Usa Tailscale
|
|
245
|
+
(recomendado) o un tunel con HTTPS.
|
|
246
|
+
- `--no-auth` deja el control de opencode al alcance de cualquiera en la red:
|
|
247
|
+
usalo solo en loopback.
|
|
248
|
+
- `GET /config/providers` y `/provider.env` exponen API keys de OpenCode a
|
|
249
|
+
quien tenga el token: tratalo como un secreto.
|
|
250
|
+
- La pagina `/_ocremote/pair` solo se sirve sin token a clientes loopback.
|
|
251
|
+
|
|
252
|
+
## Troubleshooting
|
|
253
|
+
|
|
254
|
+
- `port 4190 is already in use`: cambia `--port` (o `--opencode-port`).
|
|
255
|
+
Comprueba con `lsof -nP -iTCP:4190 -sTCP:LISTEN`.
|
|
256
|
+
- `401`: token distinto (rota con `--new-token` y vuelve a emparejar), o el
|
|
257
|
+
cliente manda `Bearer`/Basic mal formado. Prueba
|
|
258
|
+
`curl -H 'Authorization: Bearer <token>' http://127.0.0.1:4190/_ocremote/status`.
|
|
259
|
+
- `opencode binary not found`: pasa `--opencode /ruta/al/opencode`.
|
|
260
|
+
- `opencode did not become healthy within 30s`: mira los logs con prefijo
|
|
261
|
+
`[opencode]` en stderr; suele ser un puerto ocupado o falta de login.
|
|
262
|
+
- El child muere: el companion lo relanza con backoff exponencial (1 s → 15 s).
|
|
263
|
+
- mDNS: anuncio Bonjour `_ocremote._tcp.local` via `dns-sd -R` (el responder
|
|
264
|
+
del sistema). Comprueba con `dns-sd -B _ocremote._tcp local`; desactiva con
|
|
265
|
+
`--no-mdns` si no lo quieres. Si `dns-sd` no existe, solo avisa.
|
|
266
|
+
- `node --test companion/test/`: en Node ≤ 22.13 el runner no expande
|
|
267
|
+
directorios, por eso `companion/test/package.json` apunta a `index.mjs`, que
|
|
268
|
+
importa la suite. Tambien puedes usar
|
|
269
|
+
`node --test companion/test/proxy.test.mjs`.
|
|
270
|
+
|
|
271
|
+
## Limitaciones
|
|
272
|
+
|
|
273
|
+
- Sin WebSocket/PTY (501), sin TLS, sin rate limiting, sin HTTP/2.
|
|
274
|
+
- Los clientes SSE se cuentan en `status.clients` mientras la conexion sigue
|
|
275
|
+
abierta.
|
|
276
|
+
- mDNS anuncia el servicio del companion (`_ocremote._tcp`), no el de opencode:
|
|
277
|
+
el `--mdns` de opencode no se usa porque anunciaria el puerto interno.
|
package/install.sh
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
LABEL="com.raul.ocremote"
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
ENTRY="$SCRIPT_DIR/oc-remote.mjs"
|
|
7
|
+
BIN_DIR="$HOME/.local/bin"
|
|
8
|
+
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
|
|
9
|
+
LOG_PATH="$HOME/Library/Logs/oc-remote.log"
|
|
10
|
+
PROJECT_DIR=""
|
|
11
|
+
PORT="4190"
|
|
12
|
+
TOKEN=""
|
|
13
|
+
TUNNEL="auto"
|
|
14
|
+
NTFY=""
|
|
15
|
+
RELAY=""
|
|
16
|
+
DEVICE_NAME=""
|
|
17
|
+
NO_LOAD="0"
|
|
18
|
+
UNINSTALL="0"
|
|
19
|
+
|
|
20
|
+
usage() {
|
|
21
|
+
cat <<USAGE
|
|
22
|
+
oc-remote installer
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
companion/install.sh [--dir <path>] [--port <n>] [--token <str>] [--tunnel <mode>] [--ntfy <topic>]
|
|
26
|
+
[--relay <wss://url>] [--device-name <name>]
|
|
27
|
+
companion/install.sh --uninstall
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
--dir <path> Project directory opencode runs in (default: repo root)
|
|
31
|
+
--port <n> Companion port (default: 4190)
|
|
32
|
+
--token <str> Pairing token to persist (default: companion generates one)
|
|
33
|
+
--tunnel <mode> Remote access: auto | funnel | cloudflare | tailscale | none (default: auto)
|
|
34
|
+
funnel stable public HTTPS URL via Tailscale Funnel (one-time approval)
|
|
35
|
+
cloudflare public URL from anywhere with zero setup (downloads cloudflared)
|
|
36
|
+
--ntfy <topic> ntfy topic for push notifications + endpoint beacons
|
|
37
|
+
--relay <url> Relay v2 URL (wss://...) for anywhere access without tunnels
|
|
38
|
+
--device-name <name> Name shown on the phone when pairing (default: hostname)
|
|
39
|
+
--no-load Write the symlink and plist but do not load the LaunchAgent
|
|
40
|
+
--uninstall Unload the LaunchAgent, remove the plist and the symlink
|
|
41
|
+
-h, --help Show this help
|
|
42
|
+
|
|
43
|
+
Files:
|
|
44
|
+
symlink $BIN_DIR/oc-remote
|
|
45
|
+
plist $PLIST
|
|
46
|
+
log $LOG_PATH
|
|
47
|
+
config ~/.config/oc-remote/config.json (0600, written by oc-remote)
|
|
48
|
+
USAGE
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
while [[ $# -gt 0 ]]; do
|
|
52
|
+
case "$1" in
|
|
53
|
+
--dir)
|
|
54
|
+
[[ $# -ge 2 ]] || { echo "error: --dir needs a value" >&2; exit 2; }
|
|
55
|
+
PROJECT_DIR="$2"
|
|
56
|
+
shift 2
|
|
57
|
+
;;
|
|
58
|
+
--port)
|
|
59
|
+
[[ $# -ge 2 ]] || { echo "error: --port needs a value" >&2; exit 2; }
|
|
60
|
+
PORT="$2"
|
|
61
|
+
shift 2
|
|
62
|
+
;;
|
|
63
|
+
--token)
|
|
64
|
+
[[ $# -ge 2 ]] || { echo "error: --token needs a value" >&2; exit 2; }
|
|
65
|
+
TOKEN="$2"
|
|
66
|
+
shift 2
|
|
67
|
+
;;
|
|
68
|
+
--tunnel)
|
|
69
|
+
[[ $# -ge 2 ]] || { echo "error: --tunnel needs a value" >&2; exit 2; }
|
|
70
|
+
case "$2" in
|
|
71
|
+
auto|funnel|cloudflare|tailscale|none) TUNNEL="$2" ;;
|
|
72
|
+
*) echo "error: invalid --tunnel: $2 (expected auto | funnel | cloudflare | tailscale | none)" >&2; exit 2 ;;
|
|
73
|
+
esac
|
|
74
|
+
shift 2
|
|
75
|
+
;;
|
|
76
|
+
--ntfy)
|
|
77
|
+
[[ $# -ge 2 ]] || { echo "error: --ntfy needs a value" >&2; exit 2; }
|
|
78
|
+
NTFY="$2"
|
|
79
|
+
shift 2
|
|
80
|
+
;;
|
|
81
|
+
--relay)
|
|
82
|
+
[[ $# -ge 2 ]] || { echo "error: --relay needs a value" >&2; exit 2; }
|
|
83
|
+
RELAY="$2"
|
|
84
|
+
shift 2
|
|
85
|
+
;;
|
|
86
|
+
--device-name)
|
|
87
|
+
[[ $# -ge 2 ]] || { echo "error: --device-name needs a value" >&2; exit 2; }
|
|
88
|
+
DEVICE_NAME="$2"
|
|
89
|
+
shift 2
|
|
90
|
+
;;
|
|
91
|
+
--no-load)
|
|
92
|
+
NO_LOAD="1"
|
|
93
|
+
shift
|
|
94
|
+
;;
|
|
95
|
+
--uninstall)
|
|
96
|
+
UNINSTALL="1"
|
|
97
|
+
shift
|
|
98
|
+
;;
|
|
99
|
+
-h|--help)
|
|
100
|
+
usage
|
|
101
|
+
exit 0
|
|
102
|
+
;;
|
|
103
|
+
*)
|
|
104
|
+
echo "error: unknown argument: $1" >&2
|
|
105
|
+
usage >&2
|
|
106
|
+
exit 2
|
|
107
|
+
;;
|
|
108
|
+
esac
|
|
109
|
+
done
|
|
110
|
+
|
|
111
|
+
UID_NUM="$(id -u)"
|
|
112
|
+
DOMAIN="gui/$UID_NUM"
|
|
113
|
+
|
|
114
|
+
if [[ "$UNINSTALL" == "1" ]]; then
|
|
115
|
+
if launchctl bootout "$DOMAIN/$LABEL" >/dev/null 2>&1; then
|
|
116
|
+
echo "unloaded LaunchAgent $LABEL"
|
|
117
|
+
elif [[ -f "$PLIST" ]] && launchctl unload "$PLIST" >/dev/null 2>&1; then
|
|
118
|
+
echo "unloaded LaunchAgent $LABEL (legacy launchctl unload)"
|
|
119
|
+
else
|
|
120
|
+
echo "LaunchAgent $LABEL was not loaded"
|
|
121
|
+
fi
|
|
122
|
+
rm -f "$PLIST"
|
|
123
|
+
rm -f "$BIN_DIR/oc-remote"
|
|
124
|
+
echo "removed $PLIST"
|
|
125
|
+
echo "removed $BIN_DIR/oc-remote"
|
|
126
|
+
echo "kept ~/.config/oc-remote/config.json (delete it manually to forget the token)"
|
|
127
|
+
exit 0
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
if [[ -z "$PROJECT_DIR" ]]; then
|
|
131
|
+
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
132
|
+
fi
|
|
133
|
+
if [[ ! -d "$PROJECT_DIR" ]]; then
|
|
134
|
+
echo "error: directory not found: $PROJECT_DIR" >&2
|
|
135
|
+
exit 1
|
|
136
|
+
fi
|
|
137
|
+
PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)"
|
|
138
|
+
|
|
139
|
+
if [[ ! -f "$ENTRY" ]]; then
|
|
140
|
+
echo "error: oc-remote.mjs not found next to install.sh" >&2
|
|
141
|
+
exit 1
|
|
142
|
+
fi
|
|
143
|
+
|
|
144
|
+
if [[ ! "$PORT" =~ ^[0-9]+$ ]] || (( PORT < 1 || PORT > 65535 )); then
|
|
145
|
+
echo "error: invalid port: $PORT" >&2
|
|
146
|
+
exit 1
|
|
147
|
+
fi
|
|
148
|
+
|
|
149
|
+
NODE_BIN="$(command -v node || true)"
|
|
150
|
+
if [[ -z "$NODE_BIN" ]]; then
|
|
151
|
+
echo "error: node not found in PATH" >&2
|
|
152
|
+
exit 1
|
|
153
|
+
fi
|
|
154
|
+
|
|
155
|
+
xml_escape() {
|
|
156
|
+
local value="$1"
|
|
157
|
+
value="${value//&/&}"
|
|
158
|
+
value="${value//</<}"
|
|
159
|
+
value="${value//>/>}"
|
|
160
|
+
printf '%s' "$value"
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
mkdir -p "$BIN_DIR" "$HOME/Library/LaunchAgents" "$HOME/Library/Logs"
|
|
164
|
+
chmod +x "$ENTRY"
|
|
165
|
+
ln -sfn "$ENTRY" "$BIN_DIR/oc-remote"
|
|
166
|
+
|
|
167
|
+
{
|
|
168
|
+
echo '<?xml version="1.0" encoding="UTF-8"?>'
|
|
169
|
+
echo '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
|
|
170
|
+
echo '<plist version="1.0">'
|
|
171
|
+
echo '<dict>'
|
|
172
|
+
echo ' <key>Label</key>'
|
|
173
|
+
echo " <string>$LABEL</string>"
|
|
174
|
+
echo ' <key>ProgramArguments</key>'
|
|
175
|
+
echo ' <array>'
|
|
176
|
+
echo " <string>$(xml_escape "$NODE_BIN")</string>"
|
|
177
|
+
echo " <string>$(xml_escape "$BIN_DIR/oc-remote")</string>"
|
|
178
|
+
echo ' <string>--dir</string>'
|
|
179
|
+
echo " <string>$(xml_escape "$PROJECT_DIR")</string>"
|
|
180
|
+
echo ' <string>--port</string>'
|
|
181
|
+
echo " <string>$PORT</string>"
|
|
182
|
+
echo ' <string>--tunnel</string>'
|
|
183
|
+
echo " <string>$TUNNEL</string>"
|
|
184
|
+
if [[ -n "$NTFY" ]]; then
|
|
185
|
+
echo ' <string>--ntfy</string>'
|
|
186
|
+
echo " <string>$(xml_escape "$NTFY")</string>"
|
|
187
|
+
fi
|
|
188
|
+
if [[ -n "$RELAY" ]]; then
|
|
189
|
+
echo ' <string>--relay</string>'
|
|
190
|
+
echo " <string>$(xml_escape "$RELAY")</string>"
|
|
191
|
+
fi
|
|
192
|
+
if [[ -n "$DEVICE_NAME" ]]; then
|
|
193
|
+
echo ' <string>--device-name</string>'
|
|
194
|
+
echo " <string>$(xml_escape "$DEVICE_NAME")</string>"
|
|
195
|
+
fi
|
|
196
|
+
if [[ -n "$TOKEN" ]]; then
|
|
197
|
+
echo ' <string>--token</string>'
|
|
198
|
+
echo " <string>$(xml_escape "$TOKEN")</string>"
|
|
199
|
+
fi
|
|
200
|
+
echo ' </array>'
|
|
201
|
+
echo ' <key>WorkingDirectory</key>'
|
|
202
|
+
echo " <string>$(xml_escape "$PROJECT_DIR")</string>"
|
|
203
|
+
echo ' <key>EnvironmentVariables</key>'
|
|
204
|
+
echo ' <dict>'
|
|
205
|
+
echo ' <key>PATH</key>'
|
|
206
|
+
echo " <string>$(xml_escape "$(dirname "$NODE_BIN"):/opt/homebrew/bin:/usr/local/bin:$BIN_DIR:$HOME/.opencode/bin:/usr/bin:/bin:/usr/sbin:/sbin")</string>"
|
|
207
|
+
echo ' </dict>'
|
|
208
|
+
echo ' <key>RunAtLoad</key>'
|
|
209
|
+
echo ' <true/>'
|
|
210
|
+
echo ' <key>KeepAlive</key>'
|
|
211
|
+
echo ' <true/>'
|
|
212
|
+
echo ' <key>ThrottleInterval</key>'
|
|
213
|
+
echo ' <integer>10</integer>'
|
|
214
|
+
echo ' <key>StandardOutPath</key>'
|
|
215
|
+
echo " <string>$(xml_escape "$LOG_PATH")</string>"
|
|
216
|
+
echo ' <key>StandardErrorPath</key>'
|
|
217
|
+
echo " <string>$(xml_escape "$LOG_PATH")</string>"
|
|
218
|
+
echo '</dict>'
|
|
219
|
+
echo '</plist>'
|
|
220
|
+
} > "$PLIST"
|
|
221
|
+
chmod 600 "$PLIST"
|
|
222
|
+
|
|
223
|
+
plutil -lint "$PLIST"
|
|
224
|
+
|
|
225
|
+
if [[ "$NO_LOAD" == "1" ]]; then
|
|
226
|
+
echo "wrote $PLIST (not loaded)"
|
|
227
|
+
echo "wrote $BIN_DIR/oc-remote -> $ENTRY"
|
|
228
|
+
exit 0
|
|
229
|
+
fi
|
|
230
|
+
|
|
231
|
+
launchctl bootout "$DOMAIN/$LABEL" >/dev/null 2>&1 || true
|
|
232
|
+
if ! launchctl bootstrap "$DOMAIN" "$PLIST" >/dev/null 2>&1; then
|
|
233
|
+
launchctl load -w "$PLIST"
|
|
234
|
+
fi
|
|
235
|
+
launchctl kickstart -k "$DOMAIN/$LABEL" >/dev/null 2>&1 || true
|
|
236
|
+
|
|
237
|
+
echo "installed LaunchAgent $LABEL"
|
|
238
|
+
echo " binary: $BIN_DIR/oc-remote -> $ENTRY"
|
|
239
|
+
echo " plist: $PLIST"
|
|
240
|
+
echo " log: $LOG_PATH"
|
|
241
|
+
echo " port: $PORT"
|
|
242
|
+
echo " tunnel: $TUNNEL"
|
|
243
|
+
[[ -n "$RELAY" ]] && echo " relay: $RELAY"
|
|
244
|
+
echo
|
|
245
|
+
ENDPOINT_JSON="$HOME/.config/oc-remote/endpoint.json"
|
|
246
|
+
for _ in $(seq 1 15); do
|
|
247
|
+
[[ -f "$ENDPOINT_JSON" ]] && break
|
|
248
|
+
sleep 1
|
|
249
|
+
done
|
|
250
|
+
if [[ -f "$ENDPOINT_JSON" ]]; then
|
|
251
|
+
ENDPOINT_URL="$(node -e "try{const e=require('$ENDPOINT_JSON');process.stdout.write(e.url||'')}catch{}" 2>/dev/null || true)"
|
|
252
|
+
if [[ -n "$ENDPOINT_URL" ]]; then
|
|
253
|
+
echo "endpoint: $ENDPOINT_URL"
|
|
254
|
+
fi
|
|
255
|
+
fi
|
|
256
|
+
echo "Pairing details (URL, token, QR) are in the log:"
|
|
257
|
+
echo " tail -f \"$LOG_PATH\""
|
|
258
|
+
echo
|
|
259
|
+
echo "Verify the whole chain with: oc-remote doctor"
|
|
260
|
+
echo "Restart the daemon with: oc-remote restart"
|
|
261
|
+
echo "Uninstall with: $0 --uninstall"
|
package/lib/crypto.mjs
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export const PROTOCOL_VERSION = 2
|
|
4
|
+
export const CHANNEL_CONTROL = 0x00
|
|
5
|
+
export const CHANNEL_OPEN = 0x01
|
|
6
|
+
export const CHANNEL_SEALED = 0x02
|
|
7
|
+
export const DIR_CLIENT = 0x01
|
|
8
|
+
export const DIR_COMPANION = 0x02
|
|
9
|
+
export const AAD = Buffer.from('ocremote-v2', 'utf8')
|
|
10
|
+
|
|
11
|
+
export function b64(buffer) {
|
|
12
|
+
return Buffer.from(buffer).toString('base64url')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function unb64(text) {
|
|
16
|
+
return Buffer.from(String(text), 'base64url')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function frame(channel, session, payload) {
|
|
20
|
+
const out = Buffer.alloc(3 + payload.length)
|
|
21
|
+
out[0] = channel
|
|
22
|
+
out.writeUInt16BE(session, 1)
|
|
23
|
+
payload.copy(out, 3)
|
|
24
|
+
return out
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseFrame(data) {
|
|
28
|
+
const buffer = Buffer.from(data)
|
|
29
|
+
if (buffer.length < 3) return null
|
|
30
|
+
return {
|
|
31
|
+
channel: buffer[0],
|
|
32
|
+
session: buffer.readUInt16BE(1),
|
|
33
|
+
payload: buffer.subarray(3),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function control(payload) {
|
|
38
|
+
return frame(CHANNEL_CONTROL, 0, Buffer.from(JSON.stringify(payload), 'utf8'))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function hkdf(ikm, salt, info, length = 32) {
|
|
42
|
+
return Buffer.from(crypto.hkdfSync('sha256', ikm, salt, info, length))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function sessionKey(pairKey, clientNonce, serverNonce, pairID) {
|
|
46
|
+
const salt = crypto.createHash('sha256').update(Buffer.concat([clientNonce, serverNonce, Buffer.from(pairID)])).digest()
|
|
47
|
+
return hkdf(pairKey, salt, 'ocremote-v2 session', 32)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function inviteSessionKey(secret, clientNonce, serverNonce) {
|
|
51
|
+
const salt = crypto.createHash('sha256').update(Buffer.concat([clientNonce, serverNonce])).digest()
|
|
52
|
+
return hkdf(secret, salt, 'ocremote invite session', 32)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function pairKeyFromInvite(secret, clientNonce, serverNonce) {
|
|
56
|
+
const salt = crypto.createHash('sha256').update(Buffer.concat([clientNonce, serverNonce])).digest()
|
|
57
|
+
return hkdf(secret, salt, 'ocremote pairKey', 32)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function nonceFor(dir, counter) {
|
|
61
|
+
const nonce = Buffer.alloc(12)
|
|
62
|
+
nonce[0] = dir
|
|
63
|
+
let value = BigInt(counter)
|
|
64
|
+
for (let index = 11; index >= 1; index--) {
|
|
65
|
+
nonce[index] = Number(value & 0xffn)
|
|
66
|
+
value >>= 8n
|
|
67
|
+
}
|
|
68
|
+
return nonce
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function seal(key, dir, counter, plaintext) {
|
|
72
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonceFor(dir, counter))
|
|
73
|
+
cipher.setAAD(AAD)
|
|
74
|
+
const body = Buffer.concat([cipher.update(plaintext), cipher.final()])
|
|
75
|
+
return Buffer.concat([body, cipher.getAuthTag()])
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function open(key, dir, counter, sealed) {
|
|
79
|
+
if (sealed.length < 16) throw new Error('sealed frame too short')
|
|
80
|
+
const tag = sealed.subarray(sealed.length - 16)
|
|
81
|
+
const body = sealed.subarray(0, sealed.length - 16)
|
|
82
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonceFor(dir, counter))
|
|
83
|
+
decipher.setAAD(AAD)
|
|
84
|
+
decipher.setAuthTag(tag)
|
|
85
|
+
return Buffer.concat([decipher.update(body), decipher.final()])
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function sealWire(key, dir, counter, plaintext) {
|
|
89
|
+
const header = Buffer.alloc(12)
|
|
90
|
+
header[0] = dir
|
|
91
|
+
let value = BigInt(counter)
|
|
92
|
+
for (let index = 11; index >= 1; index--) {
|
|
93
|
+
header[index] = Number(value & 0xffn)
|
|
94
|
+
value >>= 8n
|
|
95
|
+
}
|
|
96
|
+
return Buffer.concat([header, seal(key, dir, counter, plaintext)])
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function openWire(key, expectedDir, payload, minCounter = -1n) {
|
|
100
|
+
if (payload.length < 12 + 16) throw new Error('short sealed frame')
|
|
101
|
+
const dir = payload[0]
|
|
102
|
+
if (dir !== expectedDir) throw new Error('bad direction')
|
|
103
|
+
let counter = 0n
|
|
104
|
+
for (const byte of payload.subarray(1, 12)) counter = (counter << 8n) | BigInt(byte)
|
|
105
|
+
if (counter <= minCounter) throw new Error('replayed counter')
|
|
106
|
+
const plaintext = open(key, dir, counter, payload.subarray(12))
|
|
107
|
+
return { counter: Number(counter), plaintext }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function generateIdentityKeys() {
|
|
111
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
|
|
112
|
+
return {
|
|
113
|
+
publicRaw: exportRawPublic(publicKey),
|
|
114
|
+
privateKey,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function exportRawPublic(publicKey) {
|
|
119
|
+
const der = publicKey.export({ type: 'spki', format: 'der' })
|
|
120
|
+
return der.subarray(der.length - 32)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function importRawPublic(raw) {
|
|
124
|
+
const prefix = Buffer.from('302a300506032b6570032100', 'hex')
|
|
125
|
+
return crypto.createPublicKey({ key: Buffer.concat([prefix, Buffer.from(raw)]), format: 'der', type: 'spki' })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function sign(privateKey, message) {
|
|
129
|
+
return crypto.sign(null, Buffer.from(message, 'utf8'), privateKey)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function verify(rawPublic, signature, message) {
|
|
133
|
+
try {
|
|
134
|
+
return crypto.verify(null, Buffer.from(message, 'utf8'), importRawPublic(rawPublic), Buffer.from(signature))
|
|
135
|
+
} catch {
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function deviceIDFromPublic(rawPublic) {
|
|
141
|
+
return crypto.createHash('sha256').update(rawPublic).digest('hex').slice(0, 32)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function randomBytes(length) {
|
|
145
|
+
return crypto.randomBytes(length)
|
|
146
|
+
}
|