dsh-opencode-ip-guard
A DeepSeek Harness (DSH) plugin that removes the blackholed A records of opencode.ai
from undici's DNS lookup, so DSH never dials a dead IP.
English | 简体中文
What problem does it solve?
opencode.ai has four A records: 172.65.90.20, .21, .22 and .23. Two of them,
.20 and .22, are permanent black holes: TCP connections to port 443 are never answered,
and Windows only gives up after burning through its SYN retransmissions (~21 s).
DSH sends model requests through the Node built-in fetch (undici's global dispatcher),
whose default connect timeout is 10 s. So whenever DNS happens to put a blackholed IP
first, DSH fails after 10 s, and llm-retry retries up to 5 times with backoff — up to
74–78 s with no model output at all, which looks exactly like a freeze.
This plugin takes over undici's connect.lookup first, then probes every A record in the
background with a full TLS handshake and keeps the timed-out/failed ones in an in-memory
blacklist. From then on every resolution of opencode.ai is filtered against that
blacklist and only a reachable IPv4 is returned, so DSH only ever connects to .21 / .23.
How it works (30-second version)
- After the plugin is mounted it installs a custom undici Agent as the global dispatcher,
replacing
connect.lookup with its filtering function. The blacklist is still empty at
this point (i.e. no filtering), so "hijack first, probe later" cannot disturb any request.
- It then probes asynchronously (DSH startup is not blocked): it resolves all A records
of
opencode.ai and performs a TCP + full TLS handshake against each IP (SNI =
opencode.ai, certificate chain verified, 10 s timeout, no HTTP request is sent).
- Handshake succeeds = usable (latency recorded); timeout or failure = black hole.
- For every later request to
opencode.ai: dns.resolve4 → drop blacklisted IPs →
return the first usable IPv4. If resolution fails it falls back to the system resolver;
if filtering leaves nothing it falls back to the original list. Those two fallbacks are
what guarantee "a bug in this plugin can never cut DSH off the network".
- If every probe fails (e.g. local network outage) the blacklist is not applied
(native behaviour), the plugin retries after 60 s, then after 120 s, and gives up after
the third failure (
state=failed).
- If a proxy is configured, the plugin disables itself instead of overwriting DSH's
proxy dispatcher (
state=disabled_by_proxy). If NO_PROXY explicitly covers
opencode.ai (that traffic never goes through the proxy), the plugin keeps working.
Deliberately not done: no hosts edits, no system DNS changes, no keep-alive tuning,
no change to the default 10 s connect timeout, no persistence, no changes to DSH business code.
Probing only happens at startup, on degraded retries and on user refresh — there is no
periodic polling of the remote side.
Installation
Prerequisites
- DSH installed and starting normally (the plugin runs inside the DSH process; you do not
need a separate Node install).
- DSH's bundled Node ≥ 22.19.0 (current releases ship Node 24.x).
- No HTTP(S) proxy configured: users behind a proxy do not need this plugin, and the plugin
disables itself when one is detected (see "Known limitations").
The commands below use PowerShell; the profile directory defaults to
$env:USERPROFILE\.dsh\profiles\desktop.
Option 1: npm (once the package is published)
cd $env:USERPROFILE\.dsh\profiles\desktop
pnpm add dsh-opencode-ip-guard
Option 2: tgz (offline / internal distribution)
# In the plugin repo root, build the tarball (dsh-opencode-ip-guard-0.1.0.tgz)
pnpm pack
# Then install it into the profile
cd $env:USERPROFILE\.dsh\profiles\desktop
pnpm add "file:D:\path\to\dsh-opencode-ip-guard-0.1.0.tgz"
Option 3: local link (development)
cd $env:USERPROFILE\.dsh\profiles\desktop
pnpm add "link:D:\DSH-Files-v2\02-DSH开发\dshapi长连接插件"
Final step: register the bundle and restart DSH
Add the package name to dsh.profile.bundles in the profile's package.json:
{
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"mac-tunnel",
"dsh-opencode-ip-guard"
]
}
}
}
Then fully quit DSH and start it again. Host plugins are loaded at process start and the
client panel is loaded with the Web GUI; hot reload only applies to configuration, so a new
package (or a code change) always needs a restart.
Configuration
Everything is optional. Override it by id in the profile's cordis.patch.yml:
- id: opencode-ip-guard
config:
hostname: 'opencode.ai' # domain to protect
probeTimeoutMs: 4000 # TLS handshake timeout per IP (ms)
connectTimeoutMs: 10000 # undici connect timeout at runtime (ms)
enabled: true # master switch
forceIps: [] # test hook: skip real DNS and use these IPs only
retryDelaysMs: [60000, 120000] # retry delays after a total probe failure (ms)
blacklistTtlMs: 300000 # soft expiry of a blacklist entry (ms)
confirmDelayMs: 2000 # second-confirmation delay for a failed probe (ms)
expiryCheckIntervalMs: 30000 # how often to scan for expired entries (ms)
sessionHeader: # fallback injection of the OpenCode session header
enabled: true
providers: [opencode, opencode-go]
mode: session-id # session-id | uuid
debug: false
| Key | Type | Default | Meaning |
|---|
hostname | string | 'opencode.ai' | Domain to protect. Protecting exactly one domain is intentional; this is not a generic IP optimiser. |
probeTimeoutMs | number | 4000 | TLS handshake timeout per IP. A reachable IP completes the handshake in ~0.35–0.4 s, so 4 s covers slow networks; all IPs are probed concurrently. |
connectTimeoutMs | number | 10000 | undici's runtime connect timeout. Independent of probeTimeoutMs: probing wants speed, connecting wants tolerance. |
enabled | boolean | true | Master switch. false means no probing and no dispatcher installation (as if not installed). |
forceIps | string[] | [] | Test hook: when non-empty, real DNS is ignored and only these IPs are filtered/selected. |
retryDelaysMs | number[] | [60000, 120000] | Retry delays after a total probe failure. When exhausted the state becomes failed and probing stops. |
blacklistTtlMs | number | 300000 | Soft expiry. IP reachability fluctuates, so any single-probe verdict expires after this long. |
confirmDelayMs | number | 2000 | A failed IP is re-probed once after this delay; only two consecutive failures blacklist it. |
expiryCheckIntervalMs | number | 30000 | Scan cadence for expired entries. This is not periodic probing: a re-probe only happens when an expired black entry exists. |
sessionHeader | object | see above | Fallback injection of x-opencode-session. A request that already carries the header is never modified. |
Value validation — invalid values fall back to defaults with a single warning; they never
throw and never break DSH startup:
probeTimeoutMs is clamped to 2000–30000 ms;
connectTimeoutMs is clamped to 2000–60000 ms;
retryDelaysMs accepts only non-negative finite numbers; an empty result falls back to defaults;
forceIps accepts only valid IPv4 literals;
hostname must be a non-empty string (trimmed and lower-cased);
enabled accepts booleans only;
blacklistTtlMs is clamped to 10000–3600000 ms;
confirmDelayMs is clamped to 100–10000 ms;
expiryCheckIntervalMs is clamped to 5000–600000 ms;
sessionHeader must be an object; its own fields fall back per-key.
Panel and status endpoints
When enabled, a small card appears in the conversation session header (same mount point as
mac-tunnel):
- normally shows "IP 防护:N 个黑洞" (IP guard: N black holes);
- hovering expands each IP's
state (ok / black) and TLS handshake latency;
- shows "上次探测:X 分钟前" (last probe: X minutes ago), updated every minute;
- clicking it sends
POST /opencode-ip-guard/refresh, re-probes and rebuilds the
blacklist from the new results (recovered IPs are removed);
- polls the status every 60 s;
- colours: green when ready, yellow while probing/degraded, red when failed/errored, grey with a reason when disabled by proxy or switched off in config.
The host side exposes two routes on the DSH Web GUI port:
# status snapshot
Invoke-RestMethod "http://127.0.0.1:<DSH_PORT>/opencode-ip-guard/status" | ConvertTo-Json -Depth 6
# re-probe and rebuild the blacklist
Invoke-RestMethod -Method Post "http://127.0.0.1:<DSH_PORT>/opencode-ip-guard/refresh" | ConvertTo-Json -Depth 6
state values:
| state | Meaning |
|---|
idle | Probing has not started yet (or enabled: false in config) |
probing | Probing in progress |
ready | Probing finished, blacklist active |
degraded | All probes failed, filtering suspended, retrying per retryDelaysMs |
failed | Retries exhausted, giving up (no filtering for the rest of this run) |
disabled_by_proxy | Proxy detected, plugin disabled itself |
error | Internal error, degraded |
Acceptance tests
Full steps, expected results and failure interpretation: docs/ACCEPTANCE.md
(Chinese). Quick reference:
1 · Connection observation
# Trigger one model request in DSH, then sample port-443 peers
1..20 | ForEach-Object {
Get-NetTCPConnection -RemotePort 443 -ErrorAction SilentlyContinue |
Where-Object { $_.RemoteAddress -like '172.65.*' } |
Select-Object -ExpandProperty RemoteAddress
Start-Sleep -Milliseconds 500
} | Sort-Object -Unique
Expected: only 172.65.90.21 / 172.65.90.23; never .20 / .22.
2 · On/off latency comparison
With the plugin on, 20 requests stay in the 1–2 s range; with it off the same batch may show
10 s spikes (black-hole connect timeout). Compare p50/p90/max. Note that curl.exe does not
go through the plugin — only DSH's own undici traffic does — so the comparison must exercise
the Node fetch path. Save the acceptance-2.mjs script from the
acceptance manual into the repo root, then run:
# baseline with the plugin off (native behaviour)
node acceptance-2.mjs off 20
# plugin on (filtered dispatcher installed in-process)
node acceptance-2.mjs on 20
3 · Forced black-hole injection (negative control)
# Put this in the profile's cordis.patch.yml, then restart DSH
- id: opencode-ip-guard
config:
forceIps: ['172.65.90.20', '172.65.90.21']
Expected: requests succeed in ~1 s and only .21 is used, while a control
curl.exe --resolve against .20 times out after 10 s.
Note: forceIps must contain at least one usable IP. If it contains black holes only,
filtering leaves nothing, the contract mandates falling back to the original list, and the
connection still goes to a black hole. That is the "never break connectivity" safety net,
not a bug.
Known limitations
- Only
opencode.ai. No generic IP optimisation; changing hostname changes the
protected domain, but the probe/certificate logic is built around that one host.
- No persistence. The blacklist lives only for this DSH run and is rebuilt on every
start; between start and probe completion nothing is filtered (native behaviour).
- Changing the proxy at runtime has no effect. Proxy detection runs once at startup;
restart DSH after changing proxy settings.
- An opencode IP-pool change can invalidate the blacklist. IPs not seen during this run
are passed through (never blocked), but stale black-hole verdicts may no longer match;
restart DSH to re-probe.
- A corporate TLS man-in-the-middle causes automatic degradation. Probes verify the full
certificate chain; an MITM certificate makes every IP fail → the "total failure" fallback
stops filtering and the panel/status shows
degraded or failed.
- Depends on undici's
connect.lookup behaviour. Verified against undici 8.x
(^8.11.2); a future major undici change to the lookup contract would need an update here.
At startup the plugin performs a capability check and skips the takeover (no filtering)
when a required API is missing.
- Does not address idle-connection recycling. No keep-alive tuning (intentional); this
plugin only prevents dialling black holes.
- Only the Node built-in fetch / undici path is affected.
curl, browsers and other
non-undici HTTP clients bypass the plugin entirely.
- IPv4 only (
dns.resolve4). On IPv6-only networks resolution is empty and falls back
to the system resolver, where filtering does not apply.
- Probing needs real outbound TLS. If a firewall blocks 443, probing fails entirely and
the plugin degrades to no filtering.
Interoperability
- Proxy plugins / DSH's own
installProxyFromEnvironment(): when a proxy is detected this
plugin does not install its dispatcher, so the proxy chain is never overwritten; the
trade-off is that the plugin is inactive (which is fine — proxied users do not need it).
Exception: if NO_PROXY covers opencode.ai, that traffic never uses the proxy and the
plugin keeps working.
- Any plugin that calls
setGlobalDispatcher: there is only one global dispatcher and the
last installer wins. Do not enable two dispatcher-replacing plugins at once — nothing
crashes, but one of them silently does nothing.
- mac-tunnel: different layer (SSH port forwarding, not undici), no conflict; this plugin
never touches its ssh connections.
- Panel plugins (dsh-ops, dsh-phone, ...): different mount points, no interference.
- The plugin does not edit other profile config, does not write files, and reports nothing
to any remote service.
Development and tests
Module layout: implementation plan §4.
# Unit tests (network-free cases must pass without network access)
node --test "test/*.test.mjs"
# Note: do not run bare `node --test` — Node 24's auto-discovery also picks up
# scripts/acceptance.mjs, which performs real network requests and hangs; and do not
# pass a directory (`node --test test/`) — Node 24 treats it as a module path and
# fails with MODULE_NOT_FOUND.
The only runtime dependency is undici (^8.11.2); no other third-party packages are used.
License
Apache License 2.0, Copyright 2026 Nicholas023.
You may use, modify and redistribute this plugin (including commercially) provided the
license and copyright notices are retained. The software is provided without warranty.
Why session-header injection
OpenCode's gateway returns 400 MissingSessionID for requests without x-opencode-session (reproduced locally: any non-empty value routes normally; missing → immediate 400). The value also pins a conversation to the same upstream backend, which keeps OpenCode's prompt cache warm.
DSH already sends this header (agent-loop stamps sessionId → adapter forwards it → pi-ai attaches it). This plugin is a safety net for versions or side-channel calls that miss it.
Safety boundary: a request that already carries the header is passed through untouched; non-target providers are never affected.