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 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//&/&amp;}"
158
+ value="${value//</&lt;}"
159
+ value="${value//>/&gt;}"
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
+ }