opencode-ufr 0.2.4 → 0.2.6
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 +11 -10
- package/package.json +1 -1
- package/src/daemon/vpn/fortinet.ts +9 -1
- package/src/daemon/vpn/manager.ts +21 -1
- package/src/daemon/vpn/ppp.ts +3 -2
- package/src/plugin/index.ts +6 -0
package/README.md
CHANGED
|
@@ -25,20 +25,21 @@ opencode plugin add opencode-ufr # registers the plugin
|
|
|
25
25
|
|
|
26
26
|
## Updating
|
|
27
27
|
|
|
28
|
-
opencode
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
opencode installs the plugin once into its cache and does not look for a newer
|
|
29
|
+
version while that copy exists — restarting opencode alone keeps the old one.
|
|
30
|
+
It also loads plugins inside a background server (`opencode serve --service`)
|
|
31
|
+
that keeps running after you close the TUI. To update, remove the cached copy
|
|
32
|
+
and stop that server:
|
|
32
33
|
|
|
33
34
|
```bash
|
|
34
|
-
bun add -g opencode-ufr@latest
|
|
35
|
-
|
|
35
|
+
bun add -g opencode-ufr@latest # update the `ufr` CLI
|
|
36
|
+
rm -rf ~/.cache/opencode/npm/opencode-ufr@latest # opencode's cached plugin copy
|
|
37
|
+
pkill -f "opencode serve --service" # opencode's background server
|
|
36
38
|
```
|
|
37
39
|
|
|
38
|
-
The next opencode start installs the latest
|
|
39
|
-
|
|
40
|
-
and
|
|
41
|
-
Manager instead of `pkill`.
|
|
40
|
+
The next opencode start installs the latest version and replaces the old
|
|
41
|
+
gateway once it is idle. On Windows, end the `opencode` server process in Task
|
|
42
|
+
Manager and delete the `opencode-ufr@latest` folder in opencode's cache.
|
|
42
43
|
|
|
43
44
|
## Setup
|
|
44
45
|
|
package/package.json
CHANGED
|
@@ -126,10 +126,15 @@ export async function authenticate(o: {
|
|
|
126
126
|
fetch?: typeof fetch
|
|
127
127
|
on2fa?: (challenge: { kind: string; message: string }) => Promise<string> // returns the OTP ("" = FTM push)
|
|
128
128
|
log?: (msg: string) => void
|
|
129
|
+
/** Deadline for the whole login. Without one a gateway that stops answering stalls it forever. */
|
|
130
|
+
signal?: AbortSignal
|
|
129
131
|
}): Promise<AuthOutcome> {
|
|
130
132
|
const f = o.fetch ?? fetch
|
|
131
133
|
const log = o.log ?? (() => {})
|
|
132
134
|
const base = o.gateway.replace(/\/$/, "")
|
|
135
|
+
// Fresh connections only: a pooled socket opened before the user's own VPN changed
|
|
136
|
+
// the route is dead, and a request on it is never answered.
|
|
137
|
+
const net = { keepalive: false, signal: o.signal }
|
|
133
138
|
|
|
134
139
|
// 1. GET / — follow redirects manually (fetch drops intermediate Set-Cookies),
|
|
135
140
|
// keep the cookie jar: FortiOS binds the logincheck to the login-page session.
|
|
@@ -137,7 +142,7 @@ export async function authenticate(o: {
|
|
|
137
142
|
let jar = ""
|
|
138
143
|
let url = `${base}/`
|
|
139
144
|
for (let hops = 0; hops < 5; hops++) {
|
|
140
|
-
const res = await f(url, { headers: { "User-Agent": FORTI_UA, ...(jar ? { Cookie: jar } : {}) }, redirect: "manual" })
|
|
145
|
+
const res = await f(url, { headers: { "User-Agent": FORTI_UA, ...(jar ? { Cookie: jar } : {}) }, redirect: "manual", ...net })
|
|
141
146
|
jar = cookiesFrom(res, jar)
|
|
142
147
|
const body = await res.text().catch(() => "")
|
|
143
148
|
if (res.status >= 300 && res.status < 400) {
|
|
@@ -170,6 +175,7 @@ export async function authenticate(o: {
|
|
|
170
175
|
},
|
|
171
176
|
body: formEncode(body),
|
|
172
177
|
redirect: "manual",
|
|
178
|
+
...net,
|
|
173
179
|
})
|
|
174
180
|
|
|
175
181
|
let res = await post({ username: o.user, credential: o.pass, realm, ajax: "1", just_logged_in: "1" })
|
|
@@ -449,6 +455,8 @@ export async function openTunnel(o: {
|
|
|
449
455
|
await fetch(`${o.gateway.replace(/\/$/, "")}/remote/logout`, {
|
|
450
456
|
headers: { "User-Agent": FORTI_UA, Cookie: o.cookie },
|
|
451
457
|
redirect: "manual",
|
|
458
|
+
keepalive: false,
|
|
459
|
+
signal: AbortSignal.timeout(5_000), // a reconnect and the daemon's shutdown wait for this
|
|
452
460
|
})
|
|
453
461
|
} catch { /* best effort */ }
|
|
454
462
|
}
|
|
@@ -29,6 +29,7 @@ export type VpnState = {
|
|
|
29
29
|
|
|
30
30
|
const DIRECT_CHECK_CACHE_MS = 60_000
|
|
31
31
|
const ESTABLISH_TIMEOUT_MS = 30_000
|
|
32
|
+
const LOGIN_TIMEOUT_MS = 15_000
|
|
32
33
|
const RECONNECT_BASE_MS = 5_000
|
|
33
34
|
const RECONNECT_MAX_MS = 10 * 60_000
|
|
34
35
|
|
|
@@ -58,6 +59,8 @@ export class VpnManager {
|
|
|
58
59
|
log: (msg: string) => void
|
|
59
60
|
/** Outgoing direct-leg fetch (injectable for tests). */
|
|
60
61
|
fetch?: FetchFn
|
|
62
|
+
/** Deadline for the gateway login (default 15 s). */
|
|
63
|
+
loginTimeoutMs?: number
|
|
61
64
|
},
|
|
62
65
|
private readonly now: () => number = Date.now,
|
|
63
66
|
) {
|
|
@@ -74,7 +77,7 @@ export class VpnManager {
|
|
|
74
77
|
return {
|
|
75
78
|
name: "vpn",
|
|
76
79
|
fetch: async (url, init) => {
|
|
77
|
-
const path = await this.ensurePath()
|
|
80
|
+
const path = await untilAborted(this.ensurePath(), init?.signal)
|
|
78
81
|
if (path === "vpn" && this.proxy) {
|
|
79
82
|
return await fetch(url, { ...init, proxy: `http://127.0.0.1:${this.proxy.port}` } as RequestInit)
|
|
80
83
|
}
|
|
@@ -133,6 +136,7 @@ export class VpnManager {
|
|
|
133
136
|
headers: { "User-Agent": "opencode-ufr" },
|
|
134
137
|
signal: AbortSignal.timeout(6_000),
|
|
135
138
|
redirect: "manual",
|
|
139
|
+
keepalive: false, // measure the current route, not a socket pooled before it changed
|
|
136
140
|
})
|
|
137
141
|
const type = res.headers.get("content-type") ?? ""
|
|
138
142
|
this.directCheckedAt = this.now()
|
|
@@ -157,11 +161,16 @@ export class VpnManager {
|
|
|
157
161
|
private async open(): Promise<void> {
|
|
158
162
|
const creds = this.o.credentials!
|
|
159
163
|
this.setState("connecting", "logging in to the uni VPN")
|
|
164
|
+
const loginMs = this.o.loginTimeoutMs ?? LOGIN_TIMEOUT_MS
|
|
165
|
+
const signal = AbortSignal.timeout(loginMs)
|
|
160
166
|
const { cookie } = await authenticate({
|
|
161
167
|
gateway: this.o.gateway,
|
|
162
168
|
user: creds.user,
|
|
163
169
|
pass: creds.pass,
|
|
164
170
|
log: this.o.log,
|
|
171
|
+
signal,
|
|
172
|
+
}).catch((e) => {
|
|
173
|
+
throw signal.aborted ? new Error(`the uni VPN gateway did not answer within ${Math.ceil(loginMs / 1000)} s`) : e
|
|
165
174
|
})
|
|
166
175
|
if (this.remoteIp === null) this.remoteIp = await resolveIp(this.o.upstreamHost)
|
|
167
176
|
|
|
@@ -251,6 +260,17 @@ export class VpnManager {
|
|
|
251
260
|
}
|
|
252
261
|
}
|
|
253
262
|
|
|
263
|
+
/** Waits for a shared attempt, but lets one caller give up on it (the attempt keeps running for the others). */
|
|
264
|
+
function untilAborted<T>(p: Promise<T>, signal?: AbortSignal | null): Promise<T> {
|
|
265
|
+
if (!signal) return p
|
|
266
|
+
if (signal.aborted) return Promise.reject(signal.reason)
|
|
267
|
+
return new Promise<T>((resolve, reject) => {
|
|
268
|
+
const onAbort = () => reject(signal.reason)
|
|
269
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
270
|
+
p.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort))
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
254
274
|
async function resolveIp(host: string): Promise<Ipv4> {
|
|
255
275
|
const { lookup } = await import("node:dns/promises")
|
|
256
276
|
const res = await lookup(host, { family: 4 })
|
package/src/daemon/vpn/ppp.ts
CHANGED
|
@@ -388,9 +388,10 @@ export class PppSession {
|
|
|
388
388
|
this.nextId = (this.nextId + 1) & 0xff || 1
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
-
/** Send an IPv4 datagram into the tunnel (protocol 0x0021).
|
|
391
|
+
/** Send an IPv4 datagram into the tunnel (protocol 0x0021). A dead link drops it, like a
|
|
392
|
+
* downed interface: teardown sends RSTs from timers and socket handlers, where a throw kills the daemon. */
|
|
392
393
|
sendIp(datagram: Uint8Array): void {
|
|
393
|
-
if (this.dead)
|
|
394
|
+
if (this.dead) return
|
|
394
395
|
this.ev.sendPpp(pppFrame(PPP_IP, datagram))
|
|
395
396
|
}
|
|
396
397
|
|
package/src/plugin/index.ts
CHANGED
|
@@ -10,6 +10,11 @@ import { toModelInfo } from "./model-info"
|
|
|
10
10
|
|
|
11
11
|
const DAEMON_ENTRY = fileURLToPath(new URL("../daemon/main.ts", import.meta.url))
|
|
12
12
|
|
|
13
|
+
// opencode takes a request's key from the provider's integration (default: the one with the provider's
|
|
14
|
+
// id), and a key credential there replaces settings.apiKey. The /connect credential on "unifreiburg"
|
|
15
|
+
// holds the UFR keys, not the gateway token — so link the provider to an integration with no credential.
|
|
16
|
+
const GATEWAY_INTEGRATION_ID = "opencode-ufr-gateway"
|
|
17
|
+
|
|
13
18
|
function log(msg: string): void {
|
|
14
19
|
console.warn(`[opencode-ufr] ${msg}`)
|
|
15
20
|
}
|
|
@@ -58,6 +63,7 @@ async function registerProvider(
|
|
|
58
63
|
info: {
|
|
59
64
|
id: providerId,
|
|
60
65
|
name: providerName,
|
|
66
|
+
integrationID: GATEWAY_INTEGRATION_ID,
|
|
61
67
|
activation: "enabled",
|
|
62
68
|
package: "@opencode/ai/providers/openai-compatible",
|
|
63
69
|
settings: { baseURL: `http://127.0.0.1:${conn.port}/v1`, apiKey: conn.token },
|