A DeepSeek Harness (DSH) plugin that removes the blackholed A records of
opencode.aifrom undici's DNS lookup, so DSH never dials a dead IP.
English | 简体中文
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.
- Root-cause report (Chinese): docs/planning/DSH-opencode-长连接失效根因报告.md
- Interface contracts: docs/CONTRACTS.md
- Design decisions D1–D20: docs/planning/grill-决策记录.md
- After the plugin is mounted it installs a custom undici Agent as the global dispatcher,
replacing
connect.lookupwith 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.aiand 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). IfNO_PROXYexplicitly coversopencode.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.
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.
cd $env:USERPROFILE\.dsh\profiles\desktop
pnpm add dsh-opencode-ip-guard# 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"cd $env:USERPROFILE\.dsh\profiles\desktop
pnpm add "link:D:\DSH-Files-v2\02-DSH开发\dshapi长连接插件"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.
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: 10000 # TLS handshake timeout per IP (ms)
enabled: true # master switch
forceIps: [] # test hook: when non-empty, skip real DNS and use these IPs only
retryDelaysMs: [60000, 120000] # probe retry delays (ms) after a total failure| 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 | 10000 |
TLS handshake timeout per IP; also used as undici's connect timeout (the two intentionally share one value, defaulting to the 10 s used by DSH). All IPs are probed concurrently, so one round takes roughly this long. |
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. Used by acceptance test 3. |
retryDelaysMs |
number[] | [60000, 120000] |
Retry delays after a total probe failure. When the list is exhausted the state becomes failed and probing stops. |
Value validation — invalid values fall back to defaults with a single warning; they never throw and never break DSH startup:
probeTimeoutMsis clamped to 2000–30000 ms;retryDelaysMsaccepts only non-negative finite numbers; an empty result falls back to defaults;forceIpsaccepts only valid IPv4 literals;hostnamemust be a non-empty string (trimmed and lower-cased);enabledaccepts booleans only.
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 6state 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 |
Full steps, expected results and failure interpretation: docs/ACCEPTANCE.md (Chinese). Quick reference:
# 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 -UniqueExpected: only 172.65.90.21 / 172.65.90.23; never .20 / .22.
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.exedoes not go through the plugin — only DSH's own undici traffic does — so the comparison must exercise the Nodefetchpath. Save theacceptance-2.mjsscript 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# 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:
forceIpsmust 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.
- Only
opencode.ai. No generic IP optimisation; changinghostnamechanges 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
degradedorfailed. - Depends on undici's
connect.lookupbehaviour. 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.
- 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: ifNO_PROXYcoversopencode.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.
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.
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.