dsh-arbitrary-host
其他 活跃维护

dsh-arbitrary-host

FairyScript/dsh-arbitrary-host

非侵入式补丁工具,无需修改dsh-web-app源码即可突破默认绑定限制,支持通过启动参数指定任意主机地址,部署时无需额外配置

0
Stars 标星
0
Forks 分支
0
Watchers 关注
0
Open Issues
JavaScript
主要语言
MIT
开源协议
40 KB
仓库大小
1 个月前
最后推送
一键安装扩展 / 插件指令
dsh plugin --profile web add github:FairyScript/dsh-arbitrary-host
git clone https://github.com/FairyScript/dsh-arbitrary-host.git
git clone git@github.com:FairyScript/dsh-arbitrary-host.git
README.md main

dsh web — arbitrary --host (non-invasive bundle)

dsh web originally restricts the bind host:

  1. dsh-web-app/lib/startup.js — rejects --host 0.0.0.0 outright (usage error).
  2. dsh-host-webserver/lib/index.js — the webServer config schema only
    accepts the literals 127.0.0.1 or 0.0.0.0, so no other host (e.g. a
    concrete LAN IP) can be configured.
  3. dsh-web-app/lib/index.js (resolveLanTrust) — the /api browser-trust
    fence auto-derives trusted authorities only for the 0.0.0.0 bind.

This bundle removes those restrictions: any --host value is accepted
verbatim
and handed to node:http server.listen, and the app refuses to
load from a non-secure browser origin instead of booting half-broken.

How it is wired (zero modification of shipped files)

The global dsh installation is untouched (wherever it lives — the installer
locates it dynamically, see below). Instead, a single bundle package plugin/
is registered as an extra bundle layer of the web profile through the
official mechanism:

dsh plugin --profile web add ./plugin

That runs pnpm add in the profile and reconcilePlugins appends
dsh-arbitrary-host to dsh.profile.bundles (now
[@deepseek-ai/dsh-base, @deepseek-ai/dsh-web-app, dsh-arbitrary-host]).
During local development the dependency is a pnpm link: — the plugin dir
stays the single source of truth, so edits are live (no reinstall for code
changes).

The bundle's cordis.patch.yml uses the include patch dialect:
the shipped web-startup / webserver / web-runtime / connection rows
are disabled and replacement rows are inserted under new ids. (The
dialect cannot change a row's name — "name mismatch, skipping" — so disable

  • insert is the only non-invasive way to swap an implementation.)

The connection replacement row is named with the bare package name
(name: 'dsh-arbitrary-host', like dsh-webui-auth): its main export is the
connection implementation and its ./client export is the restamped client
bundle — so a single package carries both the server and browser halves.

Update behavior

  • Global dsh updates never touch the profile, and nothing in the bundle
    edits a shipped file, so an update cannot "lose" these changes.
  • The replacements import the current shipped packages: webserver.js
    subclasses the shipped WebServer (only the host schema is widened),
    frontend-static.js reuses the shipped serveStatic, and glue.js
    reuses the shipped webAppInternals.resolveDistIndex. When the global
    packages update, the new code flows into the bundle automatically.
  • What can need attention after an update: if a shipped row id or service
    contract changes, the disabled ids / glue may need small adjustments
    (visible immediately in dsh web --dump-config). The plugin's
    node_modules symlink (below) must point at the current global install.

The changes

  • plugin/lib/startup.js — shipped startup minus the --host 0.0.0.0
    rejection (only the non-numeric --port check remains).
  • plugin/lib/webserver.jsWebServer subclass with
    host: z.string().required() (any host string reaches server.listen).
  • plugin/lib/glue.js — web-runtime glue mounting the gated
    frontend-static, with resolveLanTrust also deriving a concrete
    non-loopback IP-literal bind host as a trusted /api authority.
    Hostname binds stay caller-declared (--trusted-host); the fence's
    loopback and cross-site checks are untouched.
  • plugin/lib/frontend-static.jssecure-context gate: the Web shell
    depends on crypto.randomUUID, which browsers expose only in secure
    contexts (HTTPS, or localhost). Over plain HTTP on a LAN IP or hostname the
    app would boot half-broken (lists fail, "add workspace" throws
    crypto.randomUUID is not a function), so the fallback returns a
    self-contained 403 guidance page (Chinese/English) instead, pointing to:
    loopback on the host, an HTTPS reverse proxy (X-Forwarded-Proto: https
    passes the gate), or a browser "insecure origins treated as secure" flag.
    /api and /plugins routes are untouched, so scripts/curl keep working.
  • plugin/lib/connection.jsconnection row replacement (row name
    dsh-arbitrary-host)
    • The shipped @deepseek-ai/dsh-client-connection pins a set of methods
      (settings.*, credentials.*, agentPreset.*, host.pickDirectory,
      host.openPath, llm.discoverModels) to loopback only by design.
      Over a reverse-proxy domain these returned
      transport failure for /api/settings.describe: HTTP 403.
    • The replacement is the shipped transport verbatim (reuses the shipped
      HostConnectionService, toFetchHandler, fence, bridge, and WebSocket
      downlinks; the browser client bundle is a copy of the shipped one with
      only the registration id restamped, re-synced by the installer), with
      the loopback-only gate driven by a new row config
      privilegedMethods (default [] = none privileged).
    • With privilegedMethods: [] every method still passes the outer
      DNS-rebinding fence (trustedHosts), so only loopback or a declared
      --trusted-host authority
      reaches the configuration plane;
      un-declared hosts stay 403 (verified). Relaxing the plane is the
      deployment's deliberate choice — trustedHosts is explicitly not
      authentication. Re-lock specific methods by listing them in
      privilegedMethods in cordis.patch.yml.

Files

plugin/                  the single bundle package (pnpm link: dep of the web profile)
  package.json           name, exports map, dsh.bundle.patch + dsh.client manifest
  cordis.patch.yml       disable shipped rows + insert replacements
  lib/startup.js         web-startup replacement
  lib/webserver.js       WebServer subclass (relaxed host schema)
  lib/frontend-static.js gated SPA server (reuses shipped serveStatic)
  lib/glue.js            web-runtime replacement (LAN trust + gate mount)
  lib/connection.js      connection row replacement (row name = the package name)
  lib/client.js          restamped client bundle — GENERATED by install.mjs
                         (see below; deliberately not committed)
  node_modules -> global install   (lets the plugin's own imports resolve;
                                    local link: development only)
install.mjs              installer — registers the package via `dsh plugin` (idempotent, Node.js only)
uninstall.mjs            uninstaller — removes the package again (idempotent, Node.js only)

Why lib/client.js is generated, not committed

The connection row replaces the shipped @deepseek-ai/dsh-client-connection
row, so its browser-half bundle must carry a different registration id. The
bundle is a copy of the shipped client with only that id rewritten — ~10k
lines of vendored shipped code. Committing it would make the repo carry a
stale-by-construction copy (it must track every global dsh update), so the
installer regenerates it from the CURRENT global install on every run.
Consequence: a fully remote one-liner (dsh plugin add github:...) is not
possible for the connection row — clone the repo and run node install.mjs
instead (it performs the official dsh plugin add under the hood).

Setup

The installer is idempotent (Node.js only, no shell script — and no pnpm
requirement):

node install.mjs      # Node.js only — no shell script, pnpm optional
dsh web --dump-config | grep arbitrary-host   # confirm the rows

Registration prefers the official dsh plugin mechanism when pnpm is on
PATH (it also maintains the profile's pnpm-lock.yaml); when pnpm is
absent the installer falls back to an equivalent pure-Node registration
(profile manifest dependency + bundle layer + node_modules link — the same
state dsh plugin add produces; the next dsh plugin/pnpm run in the
profile reconciles the lockfile from the manifest). uninstall.mjs mirrors
this. The loader and the boot never read the pnpm lockfile, so both paths
produce a profile that boots identically (verified).

Both honor DSH_PROFILE (default web) and DSH_GLOBAL_NODE_MODULES
(override for the global node_modules). No machine-specific path is
hardcoded anywhere.

How the global dsh install is located

The installer needs the global node_modules that holds the shipped
@deepseek-ai packages (to sync the client bundle and to let the plugins'
imports resolve). It is found regardless of how dsh was installed, using
only Node built-ins (no sh, no npm/pnpm CLI probes — cross-platform):

  1. DSH_GLOBAL_NODE_MODULES, if set;
  2. the deployment the dsh binary on PATH resolves to — realpath the
    binary, then walk up probing both each directory and its node_modules
    (the second probe finds package-own dependency trees, e.g. the
    .../@deepseek-ai/dsh/node_modules layout mise/pnpm produce);
  3. the global root of the node running the installer
    (dirname(process.execPath)/../lib/node_modules and friends), which
    covers mise / nvm / npm layouts even when dsh is not on PATH;
  4. standard layouts: $BUN_INSTALL/install/global/node_modules, then
    npm-style prefix roots (best effort).

Only roots that actually hold the shipped
@deepseek-ai/dsh-client-connection/lib/client.js are accepted, and the
chosen root is probe-checked for compatibility: if its own
@deepseek-ai/dsh-host-apiproxy calls z.looseObject, its zod must export
it, or the installer aborts with a clear message. (This is the failure the
probe exists for: a wrong root — e.g. an unrelated tool's global install
carrying @deepseek-ai packages as transitive dependencies with a different
zod — makes the very first boot die with
z$1.looseObject is not a function when the connection row is imported.)

If none matches, it prints where it looked and how to set the
override.

Manual install (no scripts at all)

The dsh plugin command is the entire official mechanism; the only extra
step is the client-bundle sync, which needs the global path from which dsh:

# .../node_modules/@deepseek-ai/dsh/lib/bin.js -> up four levels to node_modules
GLOBAL="$(dirname "$(dirname "$(dirname "$(dirname "$(readlink -f "$(command -v dsh)")")")")")"
dsh plugin --profile web add ./plugin
cp "$GLOBAL/@deepseek-ai/dsh-client-connection/lib/client.js" plugin/lib/client.js
sed -i 's|id: "@deepseek-ai/dsh-client-connection"|id: "dsh-arbitrary-host"|' plugin/lib/client.js

($GLOBAL resolves to .../node_modules for bun/npm symlinked bins; for
other layouts set it to the root that contains @deepseek-ai.)

Uninstall

node uninstall.mjs    # idempotent; restores the shipped rows automatically

uninstall.mjs removes the package from the profile's
dependencies (dsh plugin ... remove reconciles dsh.profile.bundles), so
the shipped web-startup / webserver / web-runtime / connection rows
come back on the next boot, and clean the node_modules symlinks / sync
marker that the installer created inside the plugin dir. Your source files
are never touched.

Usage

dsh web --host 0.0.0.0            # all interfaces
dsh web --host 192.168.1.5        # one LAN address (auto-trusted for /api)
dsh web --host myhost.local --trusted-host myhost.local   # name bind: declare it
dsh web --port 0                  # OS-assigned port (unchanged)

Reverse proxy: forward the original Host (bare hostname) and set
X-Forwarded-Proto: https, and start dsh with
--trusted-host <public-hostname> — otherwise the /api browser-trust fence
rejects the proxied hostname (transport failure ... HTTP 403). The
dsh-arbitrary-host-connection row also relaxes the loopback-only
configuration plane (settings.*, credentials.*, agentPreset.*, ...) to
declared trusted authorities; un-declared hosts remain 403.

LAN access caveat: the plain-HTTP LAN origin shows the 403 guidance page
by design (see frontend-static.js).

License

MIT — see LICENSE in this directory.