opencode-ufr 0.2.5 → 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 CHANGED
@@ -25,20 +25,21 @@ opencode plugin add opencode-ufr # registers the plugin
25
25
 
26
26
  ## Updating
27
27
 
28
- opencode loads plugins inside a background server (`opencode serve --service`)
29
- that keeps running after you close the TUI, and it keeps using the plugin
30
- version it loaded when it started — a new release only arrives once that server
31
- restarts:
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 # update the `ufr` CLI
35
- pkill -f "opencode serve --service" # stop opencode's background server
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 plugin. If it still loads the old
39
- one, delete opencode's cached copy (`~/.cache/opencode/npm/opencode-ufr@latest`)
40
- and start opencode again. On Windows, end the `opencode` server process in Task
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-ufr",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Uni Freiburg (UFR) Open WebUI models in opencode — local gateway with key pool, UFR-aware rate limiting, circuit breaker and fallbacks",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 })
@@ -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) throw new Error("ppp link is dead")
394
+ if (this.dead) return
394
395
  this.ev.sendPpp(pppFrame(PPP_IP, datagram))
395
396
  }
396
397