Changelog

Every release, newest first, with the user-visible changes in each. Read the top entry before upgrading: anything that requires you to act before you upgrade is called out there as a breaking change.

Version 0.5.0 (Apr 26, 2026)

Breaking changes — read before upgrading:

* Breaking change: --origin port matching is now strict. A portless allow-list
  entry used to match ANY port — --origin=trusted.com accepted
  http://trusted.com:1337 as readily as http://trusted.com, vouching for
  whatever else happens to listen on that host's other ports. It now matches
  only the scheme's default port (80 for http, 443 for https; both, if the
  entry carries no scheme). Affected origins get HTTP 403. To migrate: if a
  portless entry relied on the implicit any-port match, append ":*" to it.

      before: --origin=trusted.com          (accepted trusted.com on ANY port)
      after:  --origin=trusted.com:*         (still accepts trusted.com on ANY port)
      or:    --origin=trusted.com            (now accepts default ports only: 80/443)

  Entries with an explicit port are unchanged.

* Breaking change: --sslca without --ssl is now rejected at startup with
  exit code 1. It used to start successfully and serve plain HTTP, with no
  TLS and no client-certificate verification — an operator who believed
  mutual TLS was active got a server that was neither encrypted nor
  authenticated, and nothing said so (issue #477). To migrate: add --ssl
  with --sslcert and --sslkey alongside --sslca to run mutual TLS, or drop
  --sslca if TLS isn't wanted on this listener.

Everything else:

* The examples/c# and examples/f# directories are now examples/csharp and
  examples/fsharp. A "#" starts the fragment in a URL, so any link to a
  file inside them truncated at the directory name and never reached the
  file. If you had a bookmark or a clone-relative path to either, update
  it; the contents are unchanged.

* Fix: --redirport threw away the path and query of every request it
  redirected. The Location header was built by appending a literal "/" to
  the canonical scheme, host and port, so http://example.com/docs/page.html?q=1
  arrived at https://example.com/ -- a link into the site landed on the
  front page, and the redirect was only useful for people who had typed the
  bare hostname. The Location now carries the path and query the client
  actually asked for. Operators need not act before upgrading; anything
  relying on the old behaviour was relying on links being broken.

  The origin is still built the same way -- the client's own Host header
  with only the scheme and port rewritten -- and the client-controlled path
  and query are not concatenated onto it. They are resolved against it as
  an RFC 3986 reference with no authority of its own, which is what keeps
  the target on this server: a path beginning "//" stays a path instead of
  becoming a protocol-relative URL, dot segments resolve against the root
  rather than climbing above it, and percent-encoding is preserved rather
  than decoded or doubled, so an encoded CR/LF, NUL or "%2F" reaches the
  header still encoded. Request lines carrying a raw CR, LF or space are
  rejected with 400 by Go's request parser before the redirect handler
  runs, as they were before.

* Fix: --staticdir no longer serves the source code of the scripts
  websocketd was told to execute. A file inside the --dir tree or inside
  the --cgidir tree is now refused with 404 however the URL reaches it,
  including through a symlink from elsewhere in the static tree.

  Both flags were affected. --dir, the more commonly used of the two, was
  the worse case: with --staticdir=/PAGE --dir=/PAGE/scripts, a plain GET
  of /scripts/hello.sh carries no Upgrade header, so it never reached the
  WebSocket handler at all and fell through to the static handler, which
  returned the script as application/x-sh -- credentials, internal
  hostnames and all. With --staticdir=/PAGE --cgidir=/PAGE/cgi-bin
  (issue #453), a request the CGI handler declined fell through the same
  way and disclosed whatever the script contained. Scripts configured to
  be executed are not fallback static content.

  The one exception, for both flags, is a --staticdir at or inside the
  script directory (the --dir=. --staticdir=. demo layout), where the
  exclusion would leave the static handler nothing at all to serve; that
  layout is unchanged.

  No operator action is needed to upgrade. If you were relying on
  --staticdir to hand out the text of files inside your --dir or
  --cgidir, move those files out of the script directory.

* Fix: --staticdir served dotfiles and dot-directories (.git/config,
  .env, .ssh/id_rsa, ...) verbatim, and auto-generated a directory
  listing for any directory with no index.html (issue #476). Both are now
  refused with 404. The one exception is /.well-known/, which RFC 8615
  reserves for URIs meant to be served publicly (ACME's
  /.well-known/acme-challenge/<token>, security.txt, and similar); it is
  still served, matched on that exact first path segment only, so a
  further dotfile nested inside it (or a directory that merely starts
  with the same name) is still refused.

  The listing refusal at first let a directory through as long as
  "<dir>/index.html" opened. net/http additionally requires that name not
  to be a directory itself, and falls back to listing when it is, so a
  directory literally named index.html (holding its own index.html) was
  still listed. The index must now be a regular file, which makes the
  listing branch unreachable by construction rather than by an assumption
  about net/http's control flow.

  If you relied on a directory listing to browse a --staticdir tree with
  no index.html, that listing no longer appears; add an index.html to
  that directory, or serve it another way. --staticdir does not otherwise
  change: ordinary files, dot-free directories with an index.html, and
  the existing symlink-escape protection all behave as before, and no new
  flag was added.

* Fix: --cgidir did not work when the CGI directory sat inside the
  --staticdir tree, the natural layout for a self-contained site
  (issue #453). With --staticdir=/PAGE --cgidir=/PAGE/cgi-bin, the URL a
  browser forms for a script is /cgi-bin/hello.sh; that was mapped into
  the CGI directory whole, looked for /PAGE/cgi-bin/cgi-bin/hello.sh,
  missed, and fell through to the static handler (see the disclosure fix
  above). When the CGI directory is inside the static directory, its
  position there is now also a URL prefix for CGI, so /cgi-bin/hello.sh
  runs /PAGE/cgi-bin/hello.sh. The prefix is derived from the two flags;
  no new flag, and nothing changes when the directories are unrelated.
  The existing direct mapping (/hello.sh runs /PAGE/cgi-bin/hello.sh) was
  never affected, still applies, and still wins any ambiguity.

  Which directory sits inside which is decided by resolving both and
  comparing file identity, not by comparing the two flag values as text.
  The text comparison was really testing whether the operator had spelled
  the two flags consistently: a release symlink named by one and not the
  other -- "--staticdir=/srv/current --cgidir=/srv/releases/v1/cgi-bin"
  where /srv/current points at /srv/releases/v1 -- or two spellings
  differing only in case on a case-folding filesystem, made one nested
  pair look like two unrelated trees. No prefix was derived, so GET
  /cgi-bin/hello.sh reached neither handler.

  A --cgidir that merely becomes reachable from the static tree through a
  symlink is still not treated as being inside it, and is still refused
  at that URL. The derived prefix is resolved once at first use rather
  than per request, so a deployment symlink flipped while websocketd is
  running does not re-route live requests; restart to pick up a new
  layout.

* Fix: --staticdir and --cgidir now decide whether a file is inside the
  configured directory by asking the filesystem, not by comparing
  resolved paths as text. The old check ran both sides through
  filepath.EvalSymlinks and compared the results with a string prefix.
  That was wrong in two directions:

  - A relative --staticdir or --cgidir was never put in the same frame of
    reference as the resolved request path, so the comparison was
    meaningless rather than merely strict. `websocketd --staticdir=. ...`
    -- the obvious thing to type when you are already in the directory
    you want to serve -- answered 404 to every request and served
    nothing; --cgidir=. ran no scripts. More seriously, --staticdir=..
    and --cgidir=.. failed OPEN: a symlink inside the served directory
    pointing out of it was followed, disclosing a file outside a relative
    --staticdir and executing one outside a relative --cgidir, where the
    same tree served through an absolute path correctly refused both.
    Both sides are now made absolute before symlinks are resolved, and
    every spelling of a directory -- ".", "./", "..", "sub", "./sub",
    absolute -- enforces the same boundary and serves the same files.

  - EvalSymlinks resolves symlinks but does not canonicalize the spelling
    it leaves behind, so a file genuinely inside the configured directory
    could share no string prefix with it and be 404ed. On a case-folding
    filesystem (macOS, Windows) a symlink whose target says
    "<base>/page/sub/x.txt" under "--staticdir=<base>/PAGE" is one such
    path; a directory reached through more than one mountpoint is
    another. websocketd now falls back to asking the filesystem which
    directory each path names -- the same os.SameFile identity test the
    exec-directory exclusion has always used -- when, and only when, the
    text comparison would have refused.

  Every escape that was refused before is still refused: symlinks out of
  the tree, "..", %2e%2e, name-prefix siblings, dotfiles and directory
  listings. --dir was never affected; it has always been resolved to an
  absolute path at startup. Per-request cost on the common path is
  unchanged; the identity walk runs only on paths that were previously
  404ed.

* New: a full documentation site at websocketd.com/docs (issue #467),
  built with Hugo from docsite/ in this repo, organised on the four
  Diataxis modes: start/ teaches (install, one tutorial), how-to/ gets a
  job done (six language fixes, nine deployment recipes, five problem
  patterns), reference/ states facts (every flag, environment variable
  and exit code, the dev console contract, platform support, and the
  shipped examples), and understanding/ explains why (the process model,
  message framing, output buffering, process lifecycle, the CGI
  environment, the security model, and the design decisions behind them).
  A FAQ signposts the ten most-asked questions to the pages that answer
  them, and the changelog you are reading is rendered from this file.
  /llms.txt and /llms-full.txt are published for LLM readers; the latter
  is generated at build time from the same pages the site renders. The
  CLI flag reference and the man page's flag table are generated from
  the same flag definitions --help reads (tools/gendocs), checked in CI
  so they cannot drift the way the man page previously had (see the flag
  documentation fix below). The GitHub wiki is retired as the
  documentation surface; README.md, website/index.html, and every
  examples/*/README.md point at the new site instead. The writing
  standard the site is held to is in docsite/STYLE.md and its
  information architecture in docsite/PLAN.md.

  Fixed in the site during this release:

  - the "Engineering Blueprint" theme never actually rendered. A
    documentation comment in blueprint.css wrote the glob --pt-*/--pb-*;
    the */ inside it closed a CSS block comment three lines early
    (comments don't nest), and the parser then read the rest of the
    sentence as an invalid selector, silently dropping the :root rule
    that defines every design token. The deployed site served unstyled
    Times with overlapping lines in every browser, in both color
    schemes, since the site first went live. That masked several further
    bugs, also fixed: fonts 404ing under the /docs path prefix, markdown
    images and tables with no styling at all, every mermaid diagram
    cropped to a ~40px sliver by text-box-trim on its containing <pre>,
    a header height that put the whole document 6px off its own baseline
    grid, a call-to-action button measuring 2.23:1 contrast in dark mode,
    and a mobile nav toggle marked hidden (making the drawer unopenable
    from a keyboard).

  - the link-check CI job could never pass. baseURL is
    websocketd.com/docs, so built pages link to /docs/..., while
    docsite/public holds those pages at its own root; resolving them
    against docsite/public therefore failed for every internal link on
    the site. Measured on an unmodified tree: 1734 errors, 5 OK. The job
    now assembles _site/docs the way the deploy job does, and the site
    reports zero dead links.

  - llms-full.txt was never ordered by the site map. Its ordering loop
    compared site-root paths such as /start/ against .RelPermalink,
    which under this baseURL reads /docs/start/, so nothing matched and
    every page fell through to the unordered tail.

  - section index pages listed their contents alphabetically and showed
    no descriptions, because the template sorted by title and read a
    front-matter field (summary) that no page sets. They now sort by
    weight and show each page's description.

  - start/install.md described websocketd as not having a current tagged
    release and pointed readers at building from source instead. It is
    now a normal install page: pre-built binaries lead, pointing at the
    GitHub releases page.

  - accuracy issues found by an adversarial reference-check pass:
    --sameorigin's Origin/Host comparison rejects every upgrade behind a
    typical TLS-terminating reverse proxy, and the auth-via-reverse-proxy
    cookbook recipe recommended exactly that combination (switched to
    --origin); the CGI environment page implied --cgidir shares the
    WebSocket connection's variable contract when it does not
    (net/http/cgi builds its own; SCRIPT_NAME, PATH_INFO,
    PATH_TRANSLATED, UNIQUE_ID, and AUTH_TYPE/REMOTE_USER/REMOTE_IDENT
    all differ); the process-lifecycle diagram had never rendered (same
    class of mermaid label-syntax error as above, on a different page);
    and the teardown-ladder text understated which signals reach a
    wrapped program's whole process group.

* Fix: two errors in the examples' README files. Five of them
  (examples/csharp, examples/fsharp, examples/java, examples/lua,
  examples/nodejs) linked their install step to
  docs.websocketd.com/start/install/, a subdomain that was planned but
  never registered -- the site is served at websocketd.com/docs -- so
  the links were dead on arrival; they now point at the real page. The
  link check in CI runs offline over the built Hugo output, so it never
  saw these files; a repo-wide test now fails if the dead host reappears
  in a link anywhere outside docs-archive/. Separately,
  examples/java/README.md instructed running the Echo and Count scripts
  from examples/java/ directly; they live one directory down, in Echo/
  and Count/ respectively.

* New: websocketd.com's homepage is rebuilt around what the tool is
  rather than around a slogan. The first screen now carries a headline
  that names the job ("Turn any program into a WebSocket server"), a
  two-sentence explanation, the count.sh example and the command that
  serves it, and two buttons — Start the tutorial and Download — both of
  which are inside the fold at a 1440x900 desktop and a 390x844 phone.
  The old page put its first call to action 793px down and never showed
  one at all on a phone. It shares the docs site's stylesheet and font
  files rather than a copy of them: index.html links /docs/css/blueprint.css
  and /docs/fonts/fonts.css, which the Pages workflow already publishes
  in the same artifact, so the eleven colour tokens, the two self-hosted
  typefaces and the 8px baseline grid have one home. The tutorial
  content that used to live on the homepage in eleven languages, and the
  platform/architecture download matrix, are gone; both are on
  websocketd.com/docs now, and the page names no version number and no
  archive filename, so it no longer goes stale on the day a release is
  cut. The page also declares <meta charset="utf-8"> for the first time
  — its em-dashes were correct UTF-8 all along, but any host that does
  not send a charset header rendered them as mojibake, GitHub Pages
  having happened to save it. The plan behind the rebuild, including the
  measurements of the old page and of three comparable project sites, is
  in docs-archive/REDESIGN_PLAN.md.

  Removed with the rebuild: the homepage's runtime dependencies on Google
  Fonts and two cdnjs assets (normalize 3.0.1, Font Awesome 4.2.0), the
  vendored Prism syntax highlighter, home.js, the six feature icons and
  bluebg.jpg — the rebuild removed their last consumer, and the docs
  site's self-hosted fonts replace the first. The 2013-era Google
  Analytics ga.js snippet goes with them: it pointed at a Universal
  Analytics property, which Google shut down in 2023, so it had not
  recorded a visit in years. Plausible is unaffected.

  Also new: the Pages workflow's link check now covers the homepage as
  well as the docs site, and runs on pull requests that touch website/**.
  It did not before: the check assembled only _site/docs, so
  website/index.html was never in the tree lychee walked and a
  homepage-only change got no CI at all. Measured on this tree, the old
  assembly reports 0 errors even with a misspelled /docs/ deep link
  sitting in the homepage; the full assembly fails with exit 2 and names
  the path.

* Fix: the flag documentation corrected wherever it disagreed with what
  websocketd does. The man page's flag table and the docs site's CLI flag
  reference are now generated from the same flag definitions --help reads
  (tools/gendocs) and diffed in CI, so this class of drift cannot recur
  silently.

  - --closems's usage string said "Time to start sending signals (0
    never)". Both halves were wrong. websocketd always escalates through
    stdin close, SIGINT, SIGTERM and SIGKILL when a client disconnects;
    --closems adds time to each of the first three waits and does not gate
    the escalation, and the final wait before the SIGKILL sweep does not
    take the addition at all. A reader following the old text would have
    concluded that the default disables termination signalling. help.go
    already described this correctly, so --help and the generated man page
    disagreed.

  - --passenv's help text said "Does not work for Windows since all the
    variables are kept there". The child's environment is built identically
    on every platform (config.go's buildParentEnv, env.go and launcher.go
    have no OS branch), and internal/cliflags carries a real Windows
    default. The text now states the property users actually get wrong:
    --passenv replaces the default list rather than adding to it, so naming
    one variable leaves the child with no PATH.

  - --devconsole's usage string named only --staticdir among the flags it
    cannot be combined with, though websocketd also rejects it with
    --cgidir, both with exit code 4.

  - --pingms and --sslca were undocumented in --help output entirely.

  - the man page was missing 9 flags (--maxforks, --closems, --pingms,
    --sslca, --redirport, --binary, --header*), and later --maxframesize
    and --anyorigin; stated the old --maxforks default (0, unlimited)
    where the real default has been 1024 since the 2026-08-17 hardening
    pass; gave --reverselookup's default as true rather than false; and
    carried the wrong man section and version metadata.

  - tools/gendocs rendered --passenv's default verbatim from flag.DefValue,
    which is derived from runtime.GOOS, so release/websocketd.man and
    docsite/content/reference/cli-flags.md differed depending on which OS
    ran the generator (the committed files carried macOS's
    PATH,DYLD_LIBRARY_PATH) and the docsdrift CI job -- which regenerates
    on Linux and diffs -- could never pass. The generated default is now
    the platform-independent "platform-dependent", with the per-OS values
    stated in the flag's prose note.

* New: the --devconsole development console has been rebuilt (issue #466).
  It is now a split inspector - a frame list with timestamps, direction,
  byte sizes and previews, beside a detail pane showing one frame's opcode,
  size, round-trip time and pretty/raw/hex views. Enter connects, Enter
  sends (Shift+Enter for a newline), Up and Down walk back and forth
  through what you sent before, and lifecycle events (open, close with code
  and reason, errors) appear inline in the transcript. While the field is
  in history recall both keys keep working wherever the cursor sits;
  outside it they stay free for ordinary multi-line editing. Binary frames
  are shown as length plus a hex/ASCII dump instead of a mangled string.
  Light and dark themes both follow the system by default, with an override
  that persists. The transcript is capped by a ring buffer, so a chatty
  command no longer grows the page until the tab dies. Still a single
  embedded file with no dependencies, no build step and no network requests

  This replaces the old console outright, and with it the earlier
  modernization of that console's embedded JS (var -> const/let, function
  expressions -> arrows) and the scoping bug it fixed, where a stray
  semicolon left currentSendHistoryPosition and sendHistoryRollback as
  accidental implicit globals

  qa/browser is a new Go module -- kept separate from the root module so
  its chromedp dependency never raises the minimum Go version needed to
  build the shipped binary -- driving the rebuilt console in real headless
  Chrome: connecting, sending, receiving, selecting frames, toggling theme,
  and confirming hostile frame content is never parsed as HTML

* Security: the --devconsole response is hardened. It now carries a strict
  Content-Security-Policy (default-src 'none'; the page's own inline script
  and style pinned by SHA-256 hashes computed from the embedded content;
  connect-src limited to ws:/wss:; frame-ancestors 'none') and
  X-Content-Type-Options: nosniff. The console is routinely pointed at
  servers the user does not control and renders their text; it previously
  had no security headers at all.

  The page also no longer interpolates anything from the request. The Host-
  and request-target-derived WebSocket address it used to substitute into
  the page was a reflected XSS, reachable when a crafted request path was
  sent to an exposed dev console; it was first fixed by HTML-escaping the
  value, and is now gone entirely. It was already dead weight -- the
  console fills that field from location.href in the browser, so the
  server-side value was never seen. The response body is now a constant,
  which removes the reflected-injection class structurally instead of
  escaping around it, and carries a strong ETag, so a reload is answered
  with a 304

* Fix: rejected WebSocket upgrades no longer make the HTTP server log a
  spurious "superfluous response.WriteHeader" error. gorilla's upgrade had
  already written the client's 403/400; the handler wrote the response a
  second time. Client-visible behavior is unchanged

* Change: per-connection ids (the UNIQUE_ID environment variable) are now
  crypto-random hex instead of a timestamp; a UnixNano id was guessable and
  coarse enough to collide under bursts

* Security: websocketd now prints a prominent startup warning when no origin
  policy is configured (--sameorigin/--origin). Without a policy, any web page
  open in any browser that can reach the server can connect and drive the
  commands it serves - browsers do not restrict cross-origin WebSocket
  connections. A new --anyorigin flag explicitly opts in to the current
  permissive behavior and silences the warning. Announced behavior change
  for a future release: websocketd will then default to --sameorigin, and
  setups that rely on accepting any origin should pass --anyorigin

* New: --socketmode=0700-style flag pins the Unix socket file's permissions
  instead of leaving them to the process umask, which under permissive umasks
  (0 is common in daemon contexts) left the socket connectable by any local
  user. The mode is applied immediately after binding

* Fix: --maxframesize rejects negative values at startup instead of silently
  running unlimited. The read limit is only applied for positive values, so
  a negative value quietly removed the very DoS protection the flag exists
  for; use 0 when unlimited is really wanted

* Security: control characters are escaped in the log output. Child stderr is
  relayed into the log verbatim, so a wrapped program echoing remote input
  on stderr could otherwise inject terminal control sequences (screen
  clearing, title/OSC changes) or forge additional log lines with embedded
  newlines into whatever consumes websocketd's log
* Fix: --address with a bracketed IPv6 literal now works together with
  --redirport. The redirect listener's address was derived by splitting on
  the first colon, which lands inside "[::1]:port"; the resulting malformed
  address failed to bind and — any listener error being fatal — took down
  every listener at startup. The redirect Location header is now also built
  with correct IPv6 bracketing
* Security: a stderr write larger than the internal 4KB read buffer with no
  trailing newline no longer wedges the wrapped process. Both stderr pumps
  treated the reader's buffer-full condition as fatal and stopped draining,
  so the process blocked forever on its next stderr write once the OS pipe
  filled — remotely triggerable by anyone who can reach the WebSocket, with
  a single small message, whenever the wrapped program echoes input on
  stderr. Long stderr lines are now relayed (and logged) as consecutive
  chunks instead
* Fix: session teardown now signals the wrapped process's whole process
  group (and delivers a final SIGKILL to anything left in it once the direct
  child is gone). Previously only the direct child was signaled, so a script
  could spawn background children that survived the connection — and, by
  holding the inherited pipes, kept the session and its --maxforks slot
  occupied after the wrapped process exited. Scripts that want a child to
  outlive the session must start it in its own session (setsid)
* Security: a client-supplied "Proxy" request header is no longer passed to the
  child process as HTTP_PROXY. Because many HTTP clients honour that variable, a
  remote caller could otherwise redirect the backend's outbound traffic through
  a proxy they control with a single request (httpoxy, CVE-2016-5385). The
  WebSocket environment now drops it, matching net/http/cgi
* The published release archives now cover Linux (amd64/386/arm/arm64),
  macOS (amd64/arm64) and Windows (amd64/386). darwin_arm64 is new: only
  darwin_amd64 was built before, so Mac users on M-series hardware ran it
  under Rosetta 2, despite the QA plan having covered macOS ARM64
  (BUILD-015) all along. There is no darwin_386 build -- Go dropped that
  target, and the last 32-bit-only Macs predate 2007. FreeBSD, OpenBSD and
  Solaris binaries are no longer shipped: they went unused, and the
  download links on websocketd.com still pointed at v0.3.0. That affects
  prebuilt artifacts only -- the source still compiles for those systems
  with a plain `go build`, and their --passenv defaults are unchanged

* The websocketd.com links in --help, the dev console, the man page and the
  package metadata now use https. The site enforces HTTPS, so the old http
  URLs only served a redirect
* --unixsocket no longer starts when another server is already listening on
  the socket path; it now fails with "socket ... is already in use by a
  running server", matching what a TCP listener does when its port is taken.
  Previously it unlinked any socket file at the path and bound over it, which
  left the running server alive but permanently unreachable, with no error on
  either side. Note this turns a start that used to appear to succeed into a
  hard failure — a restart script that launches the replacement before
  stopping the incumbent must now stop it first. A stale socket file from an
  unclean shutdown is still removed automatically, as before
* Security: --maxforks now defaults to 1024 instead of unlimited, a runaway
  backstop so an unconfigured, network-facing deployment cannot be fork-bombed
  by opening connections. High-concurrency deployments should raise it; set 0
  for the old unlimited behavior. It gates only WS upgrades and CGI execs, not
  static/redirect requests
* Security: when --ssl is set, websocketd now warns at startup about any
  scheme-less --origin entry, which also accepts insecure http origins; prefix
  "https://" to require TLS. Match behavior is unchanged
* Security: HTTPS/mutual-TLS servers now pin a minimum TLS version of 1.2
  explicitly instead of relying on the Go default
* Security: HTTP servers now set ReadHeaderTimeout (10s) so a client can no
  longer hold a connection open by dribbling request headers (slowloris);
  the --redirport redirect server, which only emits tiny responses, also gets
  full Read/Write/Idle timeouts
* Security: added --maxframesize to bound inbound WebSocket message size,
  defaulting to 1 MiB (0 disables). Previously a single client could stream
  an unbounded frame that websocketd buffered whole in memory (DoS). Clients
  that legitimately send larger frames must raise or disable the limit
* Security: symlink escapes are confined for all three directory flags.
  The --staticdir file server no longer follows a symlink that points out
  of the static directory (Go's default http.FileServer does), which could
  disclose arbitrary file contents. --cgidir request paths are normalized
  and boundary-checked before a CGI script is run, and scripts reached
  through a symlink out of the directory are refused (thanks @truxton for
  the report). --dir gained the same boundary check in script directory
  mode. Links that stay inside the configured directory still resolve

* CI: GitHub Actions runs the tests across 4 platforms (Linux x86/ARM64,
  macOS ARM64, Windows), on current stable Go on all of them -- recent
  macOS runners reject binaries built by the EOL Go 1.21 toolchain --
  with one Linux job still validating the go.mod minimum version. The
  matrix no longer cancels the remaining platforms when one fails
  (fail-fast: false), so a flake on one runner cannot hide every other
  platform's result. The lint job runs four linters (go vet, gofmt,
  staticcheck, gosec) with staticcheck and gosec pinned, and the tests
  run under the race detector (-race); adding the gofmt check reformatted
  three files that had drifted. CLAUDE.md claimed no linter was
  configured; that claim is corrected.

* Test infrastructure: the unit test count went from 7 to 217, alongside
  a new integration suite of 111 tests covering core WebSocket, process
  management, CLI flags, HTTP routing, security (origin/env
  isolation/injection), CGI env vars, edge cases and performance. Its
  harness is cross-platform, using ephemeral ports and a standalone
  testcmd binary (no shell dependency). qa/plans/ documents 321 manual
  and automated test cases across 14 categories.

  Harness and test fixes along the way:

  - a flaky integration test (TestENV008_UniqueID, and any other test
    could fail with an unexplained "connection reset by peer"). The
    harness treated "something is listening on the port" as proof its own
    server had started, so when the ephemeral port was taken between
    being picked and being bound, readiness was satisfied by the process
    holding it while websocketd had already exited. Readiness now proves
    identity by looking for a unique probe request in the server's own
    access log, a lost port race is retried on a fresh port, and a server
    that exits during startup reports its own log instead of timing out.

  - the harness now captures websocketd's stdout and stderr as separate
    streams with per-stream assertions (the log, including relayed child
    stderr, goes to stdout); the dead-connection test polls the access
    log instead of sleeping 1.5s, and the child-stderr relay test
    actually asserts.

  - the Windows-only CGI path test had been failing CI since the --cgidir
    confinement landed. It asserted that a backslash dot segment is
    refused, but filepath.ToSlash folds "..\" into "../" before the path
    is cleaned, so such a request lands inside the CGI directory exactly
    like a "../" one does. No behavior change and no security impact --
    the resolved paths were always contained; only the test's expectation
    was wrong.

  - TestSEC011 gave a false positive when the suite runs as root, having
    asserted on the substring "root" instead of on evidence of command
    execution.

* Renamed the default branch from master to main. The Benchmarks workflow
  only triggered on master, so it stopped running entirely after the
  rename; it now triggers on main. Documentation links that pointed at
  github.com/joewalnes/websocketd/tree/master/... (which 404 now that the
  branch is gone) were updated to tree/main/...
* Modernized JS in examples/nodejs and examples/html and the README
  tutorial snippet (var -> const, function expressions -> arrows,
  string concatenation -> template literals); behavior unchanged (#465)
* Benchmarks: a k6 performance benchmarking system (bench/) with 7
  scenarios, HTML reports and CI regression detection. Each scenario runs
  3 times and reports the median (bench/run.sh --runs=N), cutting
  shared-CI-runner noise that was producing false-positive regression
  alerts; the alert threshold rose from 15% to 25% to match. Benchmarks
  CI had been failing on every run: k6 is now installed from a pinned
  GitHub release instead of a flaky apt keyserver, verified against the
  release's published checksums, and regression alerts are advisory
  comments rather than a hard PR gate. bench/run.sh no longer silently
  discards k6 failures (the exit code was read after a pipe through sed);
  failed scenarios now fail the run. The CI Integration docs, which still
  claimed regressions block merges, were corrected to match.

* Added --passstderr to forward STDERR to WebSocket clients, tagged
  alongside STDOUT as JSON ({"stream":"stdout"|"stderr","data":"..."});
  STDERR is still logged server-side either way. Mutually exclusive with
  --binary (#459, thanks @Formatted)
* Added --unixsocket=PATH to listen on a Unix domain socket, in addition to
  or instead of --address/--port; a leftover socket file from an unclean
  shutdown is removed automatically (#435, thanks @matvore)
* Added PowerShell examples (count, greeter, dump-env) alongside the existing
  VBScript/JScript ones — cross-platform via PowerShell Core (#423, thanks
  @kshahar)
* Release tooling repaired: release/Makefile now builds 0.5.x (was stuck
  on 0.4.x), packages are labeled BSD-2-Clause instead of MIT, the man
  page is installed as websocketd.1.gz (was websocket.1.gz), and
  packaging recipes use bash (brace expansion broke under dash). Release
  version derivation uses HTTPS instead of SSH for git ls-remote, so no
  SSH setup is needed for a public repo. The vendored Go 1.11.5/1.15.7
  download is gone from both Makefiles; neither could build the go 1.21
  module, so builds use the system Go toolchain.

* Fixed goroutine leak: endpoint readers parked on the output channel send
  were never unblocked after the relay stopped (up to 10MB held per broken
  binary-mode connection); Terminate now signals readers to exit, with
  regression tests covering both the process and WebSocket endpoints
* Fixed Terminate's process-wait goroutine leaking if SIGKILL never reaps
* Code cleanup and internal refactoring: ServeHTTP decomposed into
  focused handler methods (serveWebSocket, serveCGI, serveStatic,
  serveDevConsole) and parseCommandLine into testable validation
  functions; the signal escalation loop in Terminate() extracted (it was
  a 4x repeated pattern); the regex ServeHTTP compiled per HTTP request
  now compiled once at init, and the dev console's template license
  substitution cached at init (was per-request); always-false
  resolveCommand return value and a dead io.EOF check removed;
  noteForkCompleted's imbalance branch now actually logs; server-reject
  channel buffered for all senders; redirect handler reuses its computed
  host index; missing license header added to handler.go; deprecated
  ioutil.ReadAll replaced with io.ReadAll; unchecked errors flagged by
  gosec static analysis handled (#418); config tests use t.Setenv; and a
  round of renames to Go conventions -- snake_case methods to camelCase,
  error vars to ErrScriptNotFound and ErrForkNotAllowed, env.go's
  replacers, get_help_message and NewLevel, and the noteForkCompled typo
* Fixed readFrames processing unexpected message types instead of skipping them
* Fixed pipe file descriptor leak on partial launch failure
* Fixed high-severity integer overflow vulnerability by upgrading gorilla/websocket v1.4.0 → v1.5.3
* Fixed nil pointer panic when WebSocket connection breaks during send (#342)
* Fixed --header flag to apply to all responses, not just WebSocket upgrades
* Fixed binary mode pipe deadlock for payloads >64KB (Send() now non-blocking)
* Fixed GATEWAY_INTERFACE to standard CGI/1.1 per RFC 3875
* Fixed data race on canonicalHostname (now computed once at server startup)
* Fixed buffer underflow in origin whitelist matching for short origin strings
* Fixed CGI handler not passing parent environment variables to scripts
* Fixed panic in GetURLInfo replaced with error return
* Removed dead link in README (#417)
* Updated minimum Go version from 1.15 to 1.21
* Split PipeEndpoints into two independent goroutines for proper backpressure
* WebSocketEndpoint.Terminate now closes connection (clean shutdown on process exit)
* Added --pingms flag for WebSocket ping/pong dead connection detection (#456)
* Added --sslca flag for mutual TLS client certificate verification (#413)
* Replaced fork tracking panic with graceful handling
Version 0.4.1 (Jan 24, 2021)

* Minor changes only
* Updated to Go 1.15.7

Version 0.3.1  (Jan 28, 2019)

* Minor improvements to websocketd itself
* Use of go modules, gorilla websockets set to 1.4.0
* Binaries build code switched to 1.11.5 (improving underlying protocol handlers)

Version 0.3.0  (??, 2017)

* Migration of underlying websocket server to Gorilla Websocket lib.
* Binaries build code switched to 1.9.2

Version 0.2.12  (Feb 17, 2016)

* Update of underlying go standard libraries change how SSL works. SSL3 is no longer supported.
* Support of commands that do not provide text IO (using them as binary websocket frames)
* Minor changes in examples and --help output 

Version 0.2.11  (Jul 1, 2015)

* PATH env variable is now passed to process by default
* new --header* flags could generate custom HTTP headers for all websocketd-generated answers
* fixed bug causing process to hang when WebSockets client disconnect is detected
* minor changes for console app (default url building logic and tab char printing)
* multiple changes of examples.


Version 0.2.10  (Feb 16, 2015)

* fixes for null-origin situations (#75, #96)
* better bash examples (#103)
* changelog and checksums for released files (#101, #105)


Version 0.2.9  (May 19, 2014)

* ability to listen multiple IP addresses (#40, #43)
* proper support for TLS (#17)
* resource limits enforcement (a.k.a. maxforks feature, #46)
* passenv option to limit environment variables visible by running commands (#4)
* fix for problem of closing upgraded websocket connection when script is not found (#29)
* websocket origin restrictions via command line option (#20)
* minor update for help flag behavior
* minor fix for devconsole

Version 0.2.8  (Jan 11, 2014)

* ...