Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package goshs for openSUSE:Factory checked in at 2026-07-06 12:29:07 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/goshs (Old) and /work/SRC/openSUSE:Factory/.goshs.new.1982 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "goshs" Mon Jul 6 12:29:07 2026 rev:14 rq:1363711 version:2.1.4 Changes: -------- --- /work/SRC/openSUSE:Factory/goshs/goshs.changes 2026-06-27 18:07:58.995798759 +0200 +++ /work/SRC/openSUSE:Factory/.goshs.new.1982/goshs.changes 2026-07-06 12:30:49.188239610 +0200 @@ -1,0 +2,38 @@ +Fri Jul 3 14:21:39 UTC 2026 - Martin Hauke <[email protected]> + +- Update to version 2.1.4 + Security + * WebDAV --no-delete bypass via MOVE/COPY (GHSA-hq33-8jgp-8qq3) + Under -w --no-delete (and --upload-only), the WebDAV MOVE verb + still removed the source file — a rename deletes it from its + original path — and, with Overwrite: T, destroyed an existing + destination; COPY onto an existing file did the same via an + implicit delete. The mode flags are now enforced on these + verbs: MOVE is rejected whenever deletion is disabled, and a + COPY that would overwrite an existing file is blocked, while a + plain COPY to a new path stays allowed. + --read-only continues to block all of them. + * SFTP authentication bypass with a single credential + (GHSA-rjrw-mjq6-hpmm) + SFTP only installed its password handler when both a username + and a password were configured, so setting only one left the + server accepting unauthenticated logins. Authentication is now + enforced whenever either credential is set. + New Features + * Clipboard copy in the TUI generator - The --tui reverse-shell + generator can now copy the selected payload straight to your + - clipboard with y/c. It works both locally (xclip/xsel, + wl-copy, pbcopy, clip) and over SSH via OSC 52, filling both + the system clipboard and the X11 primary selection (Ctrl+V and + middle-click / Shift+Insert). The generator tab was also + restructured into a stacked layout so multi-line output can be + cleanly mouse-selected without also grabbing the menu entries. + Bug Fixes + * Fatal port-bind errors under --tui — Every listening protocol + is now bound before the TUI dashboard takes over the terminal, + so a port conflict (or any bind error) is reported cleanly and + is fatal up front — instead of being swallowed by a serving + goroutine, which under --tui left the terminal in raw mode + needing a reset (and was silently dropped entirely for FTP). + +------------------------------------------------------------------- Old: ---- goshs-2.1.3.tar.gz New: ---- goshs-2.1.4.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ goshs.spec ++++++ --- /var/tmp/diff_new_pack.9fqn92/_old 2026-07-06 12:30:50.052269597 +0200 +++ /var/tmp/diff_new_pack.9fqn92/_new 2026-07-06 12:30:50.056269735 +0200 @@ -16,7 +16,7 @@ # Name: goshs -Version: 2.1.3 +Version: 2.1.4 Release: 0 Summary: A simple HTTP server License: MIT ++++++ goshs-2.1.3.tar.gz -> goshs-2.1.4.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/.claude/rules/architecture.md new/goshs-2.1.4/.claude/rules/architecture.md --- old/goshs-2.1.3/.claude/rules/architecture.md 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/.claude/rules/architecture.md 2026-07-03 09:19:56.000000000 +0200 @@ -27,8 +27,21 @@ ## Feature / protocol surface All of these are toggled via CLI flags / config and launched together by -`server.StartAll(opts)` in `server/server.go` (fire-and-forget goroutines, one per -enabled protocol; no per-protocol stop handles). `main.go` orchestrates startup; +`server.StartAll(opts)` in `server/server.go` (serving goroutines, one per enabled +protocol; no per-protocol stop handles). **Every listening protocol is bound +synchronously first:** each server exposes a `Bind()` method that acquires its +socket(s) and stores them on the struct, and `StartAll` calls `Bind()` before +`go …Start()`, returning `(*Servers, error)`. So a port conflict (or any bind +error) surfaces to `main.go` and is fatal *before* the `--tui` dashboard takes +over the terminal — instead of a serving goroutine calling `logger.Fatalf` → +`os.Exit` behind Bubble Tea's back (which under `--tui` discarded the message and +left the terminal in raw mode needing `reset`) or, for FTP, silently swallowing +the error in the launching goroutine. Each `Start()` still binds lazily if `Bind()` +was not called first, so direct callers keep working. Coverage: `httpserver` +(`FileServer.Bind(what)`, HTTP + WebDAV), `dnsserver` (UDP+TCP, served via +`ActivateAndServe`), `smtpserver`/`sftpserver` (library `Serve(l)`), `ftpserver` +(ftplib `Listen()`+`Serve()`), `smbserver`/`ldapserver` (hand-rolled +`net.Listen`), `tftpserver` (`net.ListenPacket`). `main.go` orchestrates startup; `sanity` does flag validation + `FurtherProcessing` (e.g. parsing `--tpl-var`). | Capability | Package | Notes | @@ -76,7 +89,11 @@ 3. `example/goshs.json.example` — add the JSON key(s) with default value(s). (Verify with `go run . -P`.) 4. For a new server: create the `xserver/` package (`New…Server(opts, …)` + - `Start()`), then launch it in `server.StartAll` (`if opts.X { … go srv.Start() }`). + `Bind() error` + `Start()`). `Bind()` acquires the socket(s) and stores them on + the struct; `Start()` serves them (and binds lazily if `Bind()` wasn't called). + Launch it in `server.StartAll` as `if opts.X { if err := srv.Bind(); err != nil + { return nil, err }; go srv.Start() }` so port conflicts are fatal *before* the + `--tui` dashboard grabs the terminal (see the StartAll bind note above). 5. `sanity/checks.go` — if it's a noisy/listening server, disable it in the invisible-mode block (and update that block's log message). 6. `utils/utils.go` `RegisterZeroconfMDNS(...)` — add a param + a @@ -139,9 +156,47 @@ - Colors use the Nord palette constants (`nord4`, `nord7`, …) — do not hardcode ANSI. - Generator pane: `tui/generator.go` + the `generator*` methods in `tui.go` (`handleGeneratorKey`, `generatorView`, `generatorList`, `generatorOutput`). - Keys: ↑↓/jk select, g/G first/last, i LHOST, p LPORT, n cycle encoding, q quit. - Deliberately no copy-to-clipboard (accepted limitation in TUI mode). -- Helpers: `trunc` (guards n<=0), `hardWrap` (safe for width>=1), `padRight`, `padLines`. + Keys: ↑↓/jk select, g/G first/last, i LHOST, p LPORT, n cycle encoding, + y/c copy, q quit. Layout is **stacked vertically** (list on top, output below) + so the output rows span full width and stay cleanly mouse-selectable. +- Clipboard copy (y/c): `copyToClipboard` writes via **two complementary paths**, + because neither works everywhere: + - **Native tool (`nativeCopy`)** — shells out to `xclip`/`xsel` (X11), + `wl-copy` (Wayland), `pbcopy` (macOS) or `clip` (Windows). On Linux/BSD it + only runs when a local display is present (`$DISPLAY`/`$WAYLAND_DISPLAY`). + This is the path that works **locally**: many local emulators + (gnome-terminal, konsole, plain xterm) do **not** honour OSC 52, so the + escape sequence alone copies nothing there. On X11/Wayland it fills **both** + the CLIPBOARD selection (Ctrl+V) and the PRIMARY selection (middle-click / + Shift+Insert) — two independent writer processes, so the OSC 52 + "two-sequences-break-each-other" caveat does **not** apply here. Best-effort, + returns bool; a plain SSH session has no display so it no-ops. + - **OSC 52** — `osc52Seq` builds a sequence for a given buffer; `copyToClipboard` + stages **two** back-to-back in `m.clipSeq` — system clipboard (`c`) **and** X11 + PRIMARY (`p`) — so both Ctrl+V and middle-click / Shift+Insert paste work over + SSH. `View()` appends the pair to the frame exactly once then clears it + (race-free way to reach the terminal under Bubble Tea v1, which has no + `SetClipboard`). This is the path that reaches the operator **over SSH**, where + the terminal is remote. **Multiplexers:** under **screen** the sequence is + wrapped in screen's DCS passthrough (`$TERM` prefix `screen`, and not also in + tmux). Under **tmux** it is emitted **plain** (no `osc52.Tmux()` wrap): tmux's + passthrough needs `allow-passthrough on` (off by default, security-gated), + whereas the far more common `set-clipboard on` makes tmux natively intercept a + plain OSC 52, set its buffer and relay it out — so `set-clipboard on` is the + documented tmux requirement (its default `external` blocks in-pane apps). + Verified on operator terminals: bare kitty over SSH, screen, and tmux with + `set-clipboard on` all copy both selections; gnome-terminal (no OSC 52) no-ops. + (Historical note: a prior session found dual `c`+`p` sequences "broke copying" + and reverted to `c` only; on operator retest that was a false negative — kitty + over SSH accepts both — so the second sequence is back. If a terminal regresses + on the pair, that history is why.) + Both fire on every copy; each is a no-op where it does not apply, so local and + remote operation are both covered. OSC 52 support is still best-effort (iTerm2, + kitty, WezTerm, foot, recent xterm); the render path is verified — a real + bubbletea program (with a window size) does emit the bytes. Dependency: + `github.com/aymanbagabas/go-osc52/v2`. +- Helpers: `trunc` (guards n<=0), `hardWrap` (safe for width>=1), `padRight`, + `padLines`, `sepRow` (horizontal divider for the stacked generator). --- diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/.github/workflows/codeql-analysis.yml new/goshs-2.1.4/.github/workflows/codeql-analysis.yml --- old/goshs-2.1.3/.github/workflows/codeql-analysis.yml 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/.github/workflows/codeql-analysis.yml 2026-07-03 09:19:56.000000000 +0200 @@ -44,7 +44,7 @@ # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3 + uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -55,7 +55,7 @@ # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3 + uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -69,4 +69,4 @@ # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3 + uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v3 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/.github/workflows/release.yml new/goshs-2.1.4/.github/workflows/release.yml --- old/goshs-2.1.3/.github/workflows/release.yml 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/.github/workflows/release.yml 2026-07-03 09:19:56.000000000 +0200 @@ -29,7 +29,7 @@ choco --version - name: Run GoReleaser - uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: distribution: goreleaser version: latest diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/README.md new/goshs-2.1.4/README.md --- old/goshs-2.1.3/README.md 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/README.md 2026-07-03 09:19:56.000000000 +0200 @@ -150,6 +150,8 @@ <td align="center"><a href="https://github.com/offset"><img src="https://github.com/offset.png?size=50" width="50" height="50"></a></td> <td align="center"><a href="https://github.com/black-shadow-007"><img src="https://github.com/black-shadow-007.png?size=50" width="50" height="50"></a></td> <td align="center"><a href="https://github.com/anir0y"><img src="https://github.com/anir0y.png?size=50" width="50" height="50"></a></td> + <td align="center"><a href="https://github.com/yukikamome316"><img src="https://github.com/yukikamome316.png?size=50" width="50" height="50"></a></td> + <td align="center"><a href="https://github.com/tonghuaroot"><img src="https://github.com/tonghuaroot.png?size=50" width="50" height="50"></a></td> <td align="center"><a href="https://github.com/wooseokdotkim">wooseokdotkim</a></td> <td align="center"><a href="https://github.com/Guilhem7">Guilhem7</a></td> </tr></table> diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/dnsserver/server.go new/goshs-2.1.4/dnsserver/server.go --- old/goshs-2.1.3/dnsserver/server.go 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/dnsserver/server.go 2026-07-03 09:19:56.000000000 +0200 @@ -21,6 +21,9 @@ Hub *ws.Hub Silent bool WebHook *webhook.Webhook + + udpConn net.PacketConn // bound by Bind, served by Start + tcpLn net.Listener // bound by Bind, served by Start } func NewDNSServer(opts *options.Options, hub *ws.Hub, wh *webhook.Webhook) *DNSServer { @@ -85,12 +88,35 @@ _ = w.WriteMsg(m) } -func (d *DNSServer) Start() { +// Bind acquires the UDP and TCP sockets so a port conflict is reported to the +// caller synchronously instead of a serving goroutine swallowing it. +func (d *DNSServer) Bind() error { addr := net.JoinHostPort(d.IP, strconv.Itoa(d.Port)) - udpServer := &dns.Server{Addr: addr, Net: "udp", Handler: dns.HandlerFunc(d.handler)} - tcpServer := &dns.Server{Addr: addr, Net: "tcp", Handler: dns.HandlerFunc(d.handler)} + pc, err := net.ListenPacket("udp", addr) + if err != nil { + return fmt.Errorf("DNS: failed to listen on udp %s: %w", addr, err) + } + ln, err := net.Listen("tcp", addr) + if err != nil { + _ = pc.Close() + return fmt.Errorf("DNS: failed to listen on tcp %s: %w", addr, err) + } + d.udpConn = pc + d.tcpLn = ln + return nil +} + +func (d *DNSServer) Start() { + // Bind lazily if a caller did not already do so via Bind. + if d.udpConn == nil || d.tcpLn == nil { + if err := d.Bind(); err != nil { + logger.Fatalf("%+v", err) + } + } + udpServer := &dns.Server{PacketConn: d.udpConn, Net: "udp", Handler: dns.HandlerFunc(d.handler)} + tcpServer := &dns.Server{Listener: d.tcpLn, Net: "tcp", Handler: dns.HandlerFunc(d.handler)} logger.Infof("DNS server listening on udp/tcp %s:%d", d.IP, d.Port) - go func() { _ = udpServer.ListenAndServe() }() - go func() { _ = tcpServer.ListenAndServe() }() + go func() { _ = udpServer.ActivateAndServe() }() + go func() { _ = tcpServer.ActivateAndServe() }() } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/ftpserver/ftpserver.go new/goshs-2.1.4/ftpserver/ftpserver.go --- old/goshs-2.1.3/ftpserver/ftpserver.go 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/ftpserver/ftpserver.go 2026-07-03 09:19:56.000000000 +0200 @@ -25,6 +25,8 @@ NoDelete bool Webhook webhook.Webhook Whitelist *httpserver.Whitelist + + srv *ftplib.FtpServer // bound by Bind, served by Start } func NewFTPServer(opts *options.Options, wl *httpserver.Whitelist, wh webhook.Webhook) *FTPServer { @@ -41,11 +43,29 @@ } } -func (s *FTPServer) Start() error { +// Bind acquires the listening socket so a port conflict is reported to the +// caller synchronously. Previously the bind error from ListenAndServe was +// discarded by the launching goroutine, so a port clash silently disabled FTP +// with no message. +func (s *FTPServer) Bind() error { driver := &mainDriver{srv: s} srv := ftplib.NewFtpServer(driver) + if err := srv.Listen(); err != nil { + return fmt.Errorf("FTP: failed to listen on %s:%d: %w", s.IP, s.Port, err) + } + s.srv = srv + return nil +} + +func (s *FTPServer) Start() error { + // Bind lazily if a caller did not already do so via Bind. + if s.srv == nil { + if err := s.Bind(); err != nil { + return err + } + } logger.Infof("Starting FTP server on %s:%d", s.IP, s.Port) - return srv.ListenAndServe() + return s.srv.Serve() } func (s *FTPServer) HandleWebhookSend(action, path, ip string, blocked bool) { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/go.mod new/goshs-2.1.4/go.mod --- old/goshs-2.1.3/go.mod 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/go.mod 2026-07-03 09:19:56.000000000 +0200 @@ -3,6 +3,7 @@ go 1.25.0 require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 github.com/charmbracelet/bubbletea v1.3.4 github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 @@ -27,10 +28,10 @@ github.com/stretchr/testify v1.11.1 github.com/studio-b12/gowebdav v0.10.0 github.com/testcontainers/testcontainers-go v0.37.0 - golang.org/x/crypto v0.50.0 - golang.org/x/net v0.53.0 - golang.org/x/term v0.42.0 - golang.org/x/text v0.36.0 + golang.org/x/crypto v0.51.0 + golang.org/x/net v0.55.0 + golang.org/x/term v0.43.0 + golang.org/x/text v0.37.0 software.sslmate.com/src/go-pkcs12 v0.7.1 ) @@ -40,7 +41,6 @@ github.com/Microsoft/go-winio v0.6.2 // indirect github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -112,7 +112,7 @@ go.opentelemetry.io/proto/otlp v1.6.0 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/tools v0.44.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/go.sum new/goshs-2.1.4/go.sum --- old/goshs-2.1.3/go.sum 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/go.sum 2026-07-03 09:19:56.000000000 +0200 @@ -259,8 +259,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= @@ -286,8 +286,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -319,8 +319,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -330,8 +330,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -341,8 +341,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/goshsversion/version.go new/goshs-2.1.4/goshsversion/version.go --- old/goshs-2.1.3/goshsversion/version.go 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/goshsversion/version.go 2026-07-03 09:19:56.000000000 +0200 @@ -1,3 +1,3 @@ package goshsversion -var GoshsVersion = "v2.1.3" +var GoshsVersion = "v2.1.4" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/httpserver/server.go new/goshs-2.1.4/httpserver/server.go --- old/goshs-2.1.3/httpserver/server.go 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/httpserver/server.go 2026-07-03 09:19:56.000000000 +0200 @@ -233,34 +233,8 @@ }, } - // Enforce mode flags on WebDAV verbs - wdGuard := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodPut, "MKCOL", "MOVE", "COPY": - if fs.ReadOnly { - http.Error(w, "read-only", http.StatusForbidden) - return - } - case http.MethodDelete: - if fs.ReadOnly || fs.UploadOnly || fs.NoDelete { - http.Error(w, "delete disabled", http.StatusForbidden) - return - } - case http.MethodGet, http.MethodHead: - if fs.UploadOnly { - http.Error(w, "upload-only", http.StatusForbidden) - return - } - } - // Enforce the .goshs ACL on the addressed resource (proper 401 with - // a challenge), then hand the request to the ACL-aware FileSystem via - // the context so recursive PROPFIND walks stay filtered too. - if !fs.webdavEnforceACL(w, r) { - return - } - ctx := context.WithValue(r.Context(), webdavCtxKey{}, r) - wdHandler.ServeHTTP(w, r.WithContext(ctx)) - }) + // Enforce mode flags and the .goshs ACL on WebDAV verbs. + wdGuard := fs.webdavGuard(wdHandler) // Check Basic Auth and use middleware if fs.User != "" || fs.Pass != "" { @@ -389,18 +363,35 @@ } } -// Start will start the file server -func (fs *FileServer) Start(what string) { - // Setup routing with gorilla/mux +// Bind sets up routing and binds the TCP listener, returning any error (most +// commonly "address already in use") instead of exiting. Callers can invoke it +// synchronously before Start so a bind failure surfaces on the main goroutine — +// this matters under --tui, where a Fatalf from the serving goroutine would exit +// the process behind Bubble Tea's back, leaving no message and a corrupted +// terminal. Start binds lazily if Bind was not called first. +func (fs *FileServer) Bind(what string) error { mux := NewCustomMux() - addr := fs.SetupMux(mux, what) - // construct and bind listener listener, err := net.Listen("tcp", addr) if err != nil { - logger.Fatalf("Error binding to listener '%s': %+v", addr, err) + return fmt.Errorf("error binding to listener '%s': %w", addr, err) + } + fs.mux = mux + fs.listener = listener + return nil +} + +// Start will start the file server +func (fs *FileServer) Start(what string) { + // Bind lazily if a caller did not already do so via Bind. + if fs.listener == nil { + if err := fs.Bind(what); err != nil { + logger.Fatalf("%+v", err) + } } + mux := fs.mux + listener := fs.listener defer func() { if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { logger.Errorf("error closing tcp listener: %+v", err) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/httpserver/structs.go new/goshs-2.1.4/httpserver/structs.go --- old/goshs-2.1.3/httpserver/structs.go 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/httpserver/structs.go 2026-07-03 09:19:56.000000000 +0200 @@ -2,6 +2,7 @@ import ( "html/template" + "net" "net/http" "sync" "time" @@ -100,6 +101,8 @@ authFailMu sync.Mutex httpServer *http.Server sharedLinksMu sync.RWMutex + mux *CustomMux // set by Bind, consumed by Start + listener net.Listener // pre-bound by Bind so port errors surface synchronously } type authFailEntry struct { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/httpserver/webdav_acl.go new/goshs-2.1.4/httpserver/webdav_acl.go --- old/goshs-2.1.3/httpserver/webdav_acl.go 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/httpserver/webdav_acl.go 2026-07-03 09:19:56.000000000 +0200 @@ -3,6 +3,7 @@ import ( "context" "net/http" + "net/url" "os" "path" "path/filepath" @@ -27,6 +28,83 @@ return webdavACLFileSystem{srv: fs, root: webdav.Dir(fs.Webroot)} } +// webdavGuard wraps the webdav handler with mode-flag enforcement (read-only / +// upload-only / no-delete) and the .goshs ACL. It is the choke point for the +// "webdav" mux and is exercised directly in tests. +// +// The verb gating reflects what golang.org/x/net/webdav actually does on disk: +// - MOVE always Rename()s the source (removing it from its original path) and, +// with Overwrite:T, RemoveAll()s an existing destination first. Both are +// deletions, so MOVE is blocked whenever deletion is disabled. +// - COPY to a fresh path only creates, but COPY with Overwrite (the library +// default unless the header is "F") onto an existing destination RemoveAll()s +// it first — also a deletion. That single case is gated behind the delete +// flags; a non-overwriting COPY stays allowed. +func (fs *FileServer) webdavGuard(next http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPut, "MKCOL": + if fs.ReadOnly { + http.Error(w, "read-only", http.StatusForbidden) + return + } + case "COPY": + if fs.ReadOnly { + http.Error(w, "read-only", http.StatusForbidden) + return + } + if (fs.UploadOnly || fs.NoDelete) && r.Header.Get("Overwrite") != "F" && fs.webdavDestExists(r) { + http.Error(w, "overwrite disabled", http.StatusForbidden) + return + } + case "MOVE": + if fs.ReadOnly || fs.UploadOnly || fs.NoDelete { + http.Error(w, "move disabled", http.StatusForbidden) + return + } + case http.MethodDelete: + if fs.ReadOnly || fs.UploadOnly || fs.NoDelete { + http.Error(w, "delete disabled", http.StatusForbidden) + return + } + case http.MethodGet, http.MethodHead: + if fs.UploadOnly { + http.Error(w, "upload-only", http.StatusForbidden) + return + } + } + // Enforce the .goshs ACL on the addressed resource (proper 401 with a + // challenge), then hand the request to the ACL-aware FileSystem via the + // context so recursive PROPFIND walks stay filtered too. + if !fs.webdavEnforceACL(w, r) { + return + } + ctx := context.WithValue(r.Context(), webdavCtxKey{}, r) + next.ServeHTTP(w, r.WithContext(ctx)) + } +} + +// webdavDestExists reports whether the Destination header of a COPY/MOVE +// request already points at an existing resource inside the webroot. It is used +// to distinguish an overwriting COPY (which deletes the destination) from a +// harmless copy to a new path. +func (fs *FileServer) webdavDestExists(r *http.Request) bool { + hdr := r.Header.Get("Destination") + if hdr == "" { + return false + } + u, err := url.Parse(hdr) + if err != nil { + return false + } + abs, err := sanitizePath(fs.Webroot, u.Path) + if err != nil { + return false + } + _, err = os.Stat(abs) + return err == nil +} + // webdavEnforceACL applies the .goshs ACL to the directly-addressed WebDAV // resource, mirroring the HTTP server (sendFile/doDir). It is the choke point // in wdGuard and, unlike the FileSystem layer below, can return a proper 401 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/httpserver/webdav_acl_test.go new/goshs-2.1.4/httpserver/webdav_acl_test.go --- old/goshs-2.1.3/httpserver/webdav_acl_test.go 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/httpserver/webdav_acl_test.go 2026-07-03 09:19:56.000000000 +0200 @@ -1,7 +1,6 @@ package httpserver import ( - "context" "fmt" "net/http" "net/http/httptest" @@ -22,13 +21,7 @@ FileSystem: fs.newWebdavFileSystem(), LockSystem: webdav.NewMemLS(), } - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !fs.webdavEnforceACL(w, r) { - return - } - ctx := context.WithValue(r.Context(), webdavCtxKey{}, r) - wd.ServeHTTP(w, r.WithContext(ctx)) - }) + return fs.webdavGuard(wd) } // webdavACLTree builds a webroot with a public file and a password-protected diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/httpserver/webdav_modeflags_test.go new/goshs-2.1.4/httpserver/webdav_modeflags_test.go --- old/goshs-2.1.3/httpserver/webdav_modeflags_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/goshs-2.1.4/httpserver/webdav_modeflags_test.go 2026-07-03 09:19:56.000000000 +0200 @@ -0,0 +1,149 @@ +package httpserver + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// webdavModeTree builds a webroot with a file to protect (secret.txt) and an +// existing file a MOVE/COPY could clobber (victim.txt). +func webdavModeTree(t *testing.T) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("TOP-SECRET"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "victim.txt"), []byte("VICTIM"), 0644)) + return dir +} + +// davReq issues a WebDAV request (optionally with Destination/Overwrite headers) +// against the mode-flag-enforcing handler. +func davReq(t *testing.T, h http.Handler, method, target, dest, overwrite string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest(method, target, nil) + if dest != "" { + r.Header.Set("Destination", "http://example.com"+dest) + } + if overwrite != "" { + r.Header.Set("Overwrite", overwrite) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +func fileContent(t *testing.T, dir, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(dir, name)) + require.NoError(t, err) + return string(b) +} + +func fileMissing(t *testing.T, dir, name string) bool { + t.Helper() + _, err := os.Stat(filepath.Join(dir, name)) + return os.IsNotExist(err) +} + +// MOVE renames (deletes) the source, so --no-delete must block it. Regression +// for the residual GHSA gap where MOVE bypassed --no-delete. +func TestWebdav_NoDelete_BlocksMove(t *testing.T) { + dir := webdavModeTree(t) + fs, cleanup := newTestFileServer(t, dir) + defer cleanup() + fs.NoDelete = true + h := newWebdavTestHandler(fs) + + w := davReq(t, h, "MOVE", "/secret.txt", "/gone.txt", "") + require.Equal(t, http.StatusForbidden, w.Code) + require.Equal(t, "TOP-SECRET", fileContent(t, dir, "secret.txt"), "source must survive a blocked MOVE") + require.True(t, fileMissing(t, dir, "gone.txt"), "destination must not be created") +} + +// MOVE with Overwrite:T also RemoveAll()s the destination — --no-delete must +// block it before the victim is destroyed. +func TestWebdav_NoDelete_BlocksMoveOverwrite(t *testing.T) { + dir := webdavModeTree(t) + fs, cleanup := newTestFileServer(t, dir) + defer cleanup() + fs.NoDelete = true + h := newWebdavTestHandler(fs) + + w := davReq(t, h, "MOVE", "/secret.txt", "/victim.txt", "T") + require.Equal(t, http.StatusForbidden, w.Code) + require.Equal(t, "VICTIM", fileContent(t, dir, "victim.txt"), "existing file must not be clobbered") + require.Equal(t, "TOP-SECRET", fileContent(t, dir, "secret.txt")) +} + +// COPY with Overwrite onto an existing destination RemoveAll()s it first, so +// --no-delete must block that case. +func TestWebdav_NoDelete_BlocksCopyOverwrite(t *testing.T) { + dir := webdavModeTree(t) + fs, cleanup := newTestFileServer(t, dir) + defer cleanup() + fs.NoDelete = true + h := newWebdavTestHandler(fs) + + w := davReq(t, h, "COPY", "/secret.txt", "/victim.txt", "T") + require.Equal(t, http.StatusForbidden, w.Code) + require.Equal(t, "VICTIM", fileContent(t, dir, "victim.txt"), "existing file must not be clobbered") +} + +// A COPY to a fresh path deletes nothing and must stay allowed under +// --no-delete, even though the Overwrite header defaults to on. +func TestWebdav_NoDelete_AllowsCopyToNewPath(t *testing.T) { + dir := webdavModeTree(t) + fs, cleanup := newTestFileServer(t, dir) + defer cleanup() + fs.NoDelete = true + h := newWebdavTestHandler(fs) + + w := davReq(t, h, "COPY", "/secret.txt", "/copy.txt", "") + require.Less(t, w.Code, http.StatusBadRequest, "non-overwriting COPY must be allowed") + require.Equal(t, "TOP-SECRET", fileContent(t, dir, "secret.txt"), "source must be untouched") + require.Equal(t, "TOP-SECRET", fileContent(t, dir, "copy.txt")) +} + +// --upload-only forbids deletion too, so MOVE must be blocked there as well. +func TestWebdav_UploadOnly_BlocksMove(t *testing.T) { + dir := webdavModeTree(t) + fs, cleanup := newTestFileServer(t, dir) + defer cleanup() + fs.UploadOnly = true + h := newWebdavTestHandler(fs) + + w := davReq(t, h, "MOVE", "/secret.txt", "/gone.txt", "") + require.Equal(t, http.StatusForbidden, w.Code) + require.Equal(t, "TOP-SECRET", fileContent(t, dir, "secret.txt")) +} + +// --read-only blocks every mutating verb, including MOVE and COPY. +func TestWebdav_ReadOnly_BlocksMoveAndCopy(t *testing.T) { + dir := webdavModeTree(t) + fs, cleanup := newTestFileServer(t, dir) + defer cleanup() + fs.ReadOnly = true + h := newWebdavTestHandler(fs) + + require.Equal(t, http.StatusForbidden, davReq(t, h, "MOVE", "/secret.txt", "/gone.txt", "").Code) + require.Equal(t, http.StatusForbidden, davReq(t, h, "COPY", "/secret.txt", "/copy.txt", "").Code) + require.Equal(t, "TOP-SECRET", fileContent(t, dir, "secret.txt")) +} + +// Control: with no mode flags a MOVE works as usual — the source is renamed away +// and the destination created. Confirms the guard does not over-block. +func TestWebdav_Default_AllowsMove(t *testing.T) { + dir := webdavModeTree(t) + fs, cleanup := newTestFileServer(t, dir) + defer cleanup() + h := newWebdavTestHandler(fs) + + w := davReq(t, h, "MOVE", "/secret.txt", "/gone.txt", "") + require.Less(t, w.Code, http.StatusBadRequest) + require.True(t, fileMissing(t, dir, "secret.txt"), "MOVE should remove the source") + require.Equal(t, "TOP-SECRET", fileContent(t, dir, "gone.txt")) +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/ldapserver/server.go new/goshs-2.1.4/ldapserver/server.go --- old/goshs-2.1.3/ldapserver/server.go 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/ldapserver/server.go 2026-07-03 09:19:56.000000000 +0200 @@ -25,6 +25,8 @@ SelfSigned bool MyCert string MyKey string + + ln net.Listener // bound by Bind, served by Start } func NewLDAPServer(opts *options.Options, hub *ws.Hub, wh *webhook.Webhook) *LDAPServer { @@ -85,19 +87,34 @@ } } -func (s *LDAPServer) Start() { +// Bind acquires the listening socket (TLS-wrapped when LDAPS) so a port conflict +// is reported to the caller synchronously instead of a serving goroutine +// swallowing it. +func (s *LDAPServer) Bind() error { addr := fmt.Sprintf("%s:%d", s.IP, s.Port) ln, err := net.Listen("tcp", addr) if err != nil { - logger.Fatalf("LDAP server failed to listen on %s: %v", addr, err) - return + return fmt.Errorf("LDAP server failed to listen on %s: %w", addr, err) } - if s.SSL { ln = tls.NewListener(ln, s.buildTLSConfig()) - logger.Infof("LDAPS server listening on %s", addr) + } + s.ln = ln + return nil +} + +func (s *LDAPServer) Start() { + // Bind lazily if a caller did not already do so via Bind. + if s.ln == nil { + if err := s.Bind(); err != nil { + logger.Fatalf("%+v", err) + } + } + + if s.SSL { + logger.Infof("LDAPS server listening on %s:%d", s.IP, s.Port) } else { - logger.Infof("LDAP server listening on %s", addr) + logger.Infof("LDAP server listening on %s:%d", s.IP, s.Port) } if s.JNDIEnabled { @@ -105,7 +122,7 @@ } for { - conn, err := ln.Accept() + conn, err := s.ln.Accept() if err != nil { logger.Errorf("LDAP accept error: %v", err) continue diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/main.go new/goshs-2.1.4/main.go --- old/goshs-2.1.3/main.go 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/main.go 2026-07-03 09:19:56.000000000 +0200 @@ -68,8 +68,14 @@ logger.PrintBanner(goshsversion.GoshsVersion) } - // Start all servers - srv := server.StartAll(opts) + // Start all servers. A bind failure (e.g. the HTTP port is already in use) + // is returned here, before the TUI takes over the terminal, so the message + // is printed and we exit cleanly instead of the dashboard vanishing without + // a trace and leaving the terminal in raw mode. + srv, err := server.StartAll(opts) + if err != nil { + logger.Fatalf("Failed to start: %+v", err) + } // Arm the self-destruct timer if --ttl was set. A zero TTL leaves ttlC nil, // which blocks forever in the select below. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/packaging/rpm/goshs.spec new/goshs-2.1.4/packaging/rpm/goshs.spec --- old/goshs-2.1.3/packaging/rpm/goshs.spec 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/packaging/rpm/goshs.spec 2026-07-03 09:19:56.000000000 +0200 @@ -1,5 +1,5 @@ Name: goshs -Version: 2.1.3 +Version: 2.1.4 Release: 1%{?dist} Summary: Beyond Python's http.server — single-binary file server for pentesters @@ -46,6 +46,8 @@ %{_bindir}/%{name} %changelog +* Fri Jul 03 2026 Patrick Hener <[email protected]> - 2.1.4-1 +- Add new version v2.1.4 * Thu Jun 25 2026 Patrick Hener <[email protected]> - 2.1.3-1 - Add new version v2.1.3 * Tue Jun 23 2026 Patrick Hener <[email protected]> - 2.1.2-1 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/server/server.go new/goshs-2.1.4/server/server.go --- old/goshs-2.1.3/server/server.go 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/server/server.go 2026-07-03 09:19:56.000000000 +0200 @@ -36,7 +36,7 @@ Clipboard *clipboard.Clipboard } -func StartAll(opts *options.Options) *Servers { +func StartAll(opts *options.Options) (*Servers, error) { // Init clipboard and hub clip := clipboard.New() hub := ws.NewHub(clip, opts.CLI) @@ -45,8 +45,13 @@ // Whitelist and Webhook wl, wh := registerWhitelistWebhook(opts) - // http + // http — bind synchronously so a port conflict is reported to the caller + // (and, under --tui, before the dashboard takes over the terminal) instead + // of the serving goroutine calling Fatalf behind its back. httpSrv := httpserver.NewHttpServer(opts, hub, clip, wl, *wh) + if err := httpSrv.Bind("web"); err != nil { + return nil, err + } go httpSrv.Start("web") // webdav @@ -54,41 +59,70 @@ if opts.WebDav { webdavSrv = httpserver.NewHttpServer(opts, hub, clip, wl, *wh) webdavSrv.WebdavPort = opts.WebDavPort + if err := webdavSrv.Bind("webdav"); err != nil { + return nil, err + } go webdavSrv.Start("webdav") } + // Every listening protocol below is bound synchronously via its Bind method + // before its serving goroutine starts, so a port conflict (or any bind + // error) surfaces here — and, under --tui, before the dashboard takes over + // the terminal — instead of a goroutine calling Fatalf or silently dropping + // the error. if opts.DNS { dnsSrv := dnsserver.NewDNSServer(opts, hub, wh) + if err := dnsSrv.Bind(); err != nil { + return nil, err + } go dnsSrv.Start() } if opts.SMTP { smtpServer := smtpserver.NewSMTP(opts, hub, wh) + if err := smtpServer.Bind(); err != nil { + return nil, err + } go smtpServer.Start() } if opts.SMB { smbServer := smbserver.NewSMBServer(opts, hub, wh) + if err := smbServer.Bind(); err != nil { + return nil, err + } go smbServer.Start() } if opts.LDAP { ldapSrv := ldapserver.NewLDAPServer(opts, hub, wh) + if err := ldapSrv.Bind(); err != nil { + return nil, err + } go ldapSrv.Start() } if opts.FTP { if opts.FTPSFTPMode { sftpSrv := sftpserver.NewSFTPServer(opts, wl, *wh) - go sftpSrv.Start() + if err := sftpSrv.Bind(); err != nil { + return nil, err + } + go func() { _ = sftpSrv.Start() }() } else { ftpSrv := ftpserver.NewFTPServer(opts, wl, *wh) - go ftpSrv.Start() + if err := ftpSrv.Bind(); err != nil { + return nil, err + } + go func() { _ = ftpSrv.Start() }() } } if opts.TFTP { tftpSrv := tftpserver.NewTFTPServer(opts, wl, *wh) + if err := tftpSrv.Bind(); err != nil { + return nil, err + } go func() { _ = tftpSrv.Start() }() } @@ -117,7 +151,7 @@ Catcher: httpSrv.CatcherMgr, TunnelURL: func() string { return httpSrv.TunnelURL }, Clipboard: clip, - } + }, nil } func registerWhitelistWebhook(opts *options.Options) (wl *httpserver.Whitelist, wh *webhook.Webhook) { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/sftpserver/sftpserver.go new/goshs-2.1.4/sftpserver/sftpserver.go --- old/goshs-2.1.3/sftpserver/sftpserver.go 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/sftpserver/sftpserver.go 2026-07-03 09:19:56.000000000 +0200 @@ -30,6 +30,8 @@ HostKeyFile string Webhook webhook.Webhook Whitelist *httpserver.Whitelist + + ln net.Listener // bound by Bind, served by Start } func NewSFTPServer(opts *options.Options, wl *httpserver.Whitelist, webhook webhook.Webhook) *SFTPServer { @@ -48,6 +50,17 @@ } } +// Bind acquires the listening socket so a port conflict is reported to the +// caller synchronously instead of a serving goroutine swallowing it. +func (s *SFTPServer) Bind() error { + ln, err := net.Listen("tcp", net.JoinHostPort(s.IP, strconv.Itoa(s.Port))) + if err != nil { + return fmt.Errorf("SFTP: failed to listen on %s:%d: %w", s.IP, s.Port, err) + } + s.ln = ln + return nil +} + // Start initializes and starts the SFTP server func (s *SFTPServer) Start() error { var err error @@ -82,7 +95,7 @@ sshServer.HostSigners = []ssh.Signer{private} } - if s.Username != "" && s.Password != "" { + if s.Username != "" || s.Password != "" { sshServer.PasswordHandler = func(ctx ssh.Context, password string) bool { return subtle.ConstantTimeCompare([]byte(ctx.User()), []byte(s.Username)) == 1 && subtle.ConstantTimeCompare([]byte(password), []byte(s.Password)) == 1 } @@ -140,10 +153,15 @@ }, } - logger.Infof("Starting SFTP server on port %s:%d", s.IP, s.Port) - logger.Fatal(sshServer.ListenAndServe()) + // Bind lazily if a caller did not already do so via Bind. + if s.ln == nil { + if err := s.Bind(); err != nil { + return err + } + } - return nil + logger.Infof("Starting SFTP server on port %s:%d", s.IP, s.Port) + return sshServer.Serve(s.ln) } func (s *SFTPServer) HandleWebhookSend(event string, r *sftp.Request, ip string, blocked bool) { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/smbserver/server.go new/goshs-2.1.4/smbserver/server.go --- old/goshs-2.1.3/smbserver/server.go 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/smbserver/server.go 2026-07-03 09:19:56.000000000 +0200 @@ -38,6 +38,8 @@ Hub *ws.Hub WebHook *webhook.Webhook + ln net.Listener // bound by Bind, served by Start + serverGUID [16]byte // random, set once at Start nextSessionID uint64 // server-wide session ID counter (atomic) @@ -81,20 +83,33 @@ } } +// Bind acquires the listening socket so a port conflict is reported to the +// caller synchronously instead of a serving goroutine swallowing it. +func (s *SMBServer) Bind() error { + addr := net.JoinHostPort(s.IP, strconv.Itoa(s.Port)) + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("SMB: failed to listen on %s: %w", addr, err) + } + s.ln = ln + return nil +} + func (s *SMBServer) Start() { if _, err := rand.Read(s.serverGUID[:]); err != nil { logger.Fatalf("SMB: failed to generate server GUID: %v", err) } - addr := net.JoinHostPort(s.IP, strconv.Itoa(s.Port)) - ln, err := net.Listen("tcp", addr) - if err != nil { - logger.Fatalf("SMB: failed to listen on %s: %v", addr, err) + // Bind lazily if a caller did not already do so via Bind. + if s.ln == nil { + if err := s.Bind(); err != nil { + logger.Fatalf("%+v", err) + } } - logger.Infof("SMB server listening on %s (\\\\%s\\%s) — hash capture active", addr, s.IP, s.ShareName) + logger.Infof("SMB server listening on %s (\\\\%s\\%s) — hash capture active", s.ln.Addr(), s.IP, s.ShareName) for { - conn, err := ln.Accept() + conn, err := s.ln.Accept() if err != nil { continue } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/smtpserver/server.go new/goshs-2.1.4/smtpserver/server.go --- old/goshs-2.1.3/smtpserver/server.go 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/smtpserver/server.go 2026-07-03 09:19:56.000000000 +0200 @@ -1,6 +1,7 @@ package smtpserver import ( + "fmt" "net" "strconv" "time" @@ -19,6 +20,8 @@ Hub *ws.Hub WebHook *webhook.Webhook Domain string + + ln net.Listener // bound by Bind, served by Start } func NewSMTP(opts *options.Options, hub *ws.Hub, wh *webhook.Webhook) *SMTPServer { @@ -31,7 +34,25 @@ } } +// Bind acquires the listening socket so a port conflict is reported to the +// caller synchronously instead of a serving goroutine swallowing it. +func (srv *SMTPServer) Bind() error { + addr := net.JoinHostPort(srv.IP, strconv.Itoa(srv.Port)) + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("SMTP: failed to listen on %s: %w", addr, err) + } + srv.ln = ln + return nil +} + func (srv *SMTPServer) Start() { + // Bind lazily if a caller did not already do so via Bind. + if srv.ln == nil { + if err := srv.Bind(); err != nil { + logger.Fatalf("%+v", err) + } + } be := &Backend{Hub: srv.Hub, WebHook: srv.WebHook} s := smtp.NewServer(be) addr := net.JoinHostPort(srv.IP, strconv.Itoa(srv.Port)) @@ -43,6 +64,6 @@ } else { logger.Infof("SMTP catch-all listening on %s (open relay)", addr) } - go func() { _ = s.ListenAndServe() }() + go func() { _ = s.Serve(srv.ln) }() go smtpattach.PurgeLoop(1 * time.Hour) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/tftpserver/tftpserver.go new/goshs-2.1.4/tftpserver/tftpserver.go --- old/goshs-2.1.3/tftpserver/tftpserver.go 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/tftpserver/tftpserver.go 2026-07-03 09:19:56.000000000 +0200 @@ -69,6 +69,8 @@ UploadOnly bool Webhook webhook.Webhook Whitelist *httpserver.Whitelist + + pc net.PacketConn // bound by Bind, served by Start } // NewTFTPServer builds a TFTPServer from the parsed options. Writes land in the @@ -93,18 +95,31 @@ // Start binds the main UDP socket and dispatches each incoming request to its // own goroutine. It blocks; callers run it in a goroutine like the other // protocol servers. -func (s *TFTPServer) Start() error { +// Bind acquires the main UDP socket so a port conflict is reported to the caller +// synchronously instead of the serving goroutine swallowing it. +func (s *TFTPServer) Bind() error { addr := net.JoinHostPort(s.IP, strconv.Itoa(s.Port)) pc, err := net.ListenPacket("udp", addr) if err != nil { - logger.Errorf("[TFTP] failed to bind %s: %v", addr, err) - return err + return fmt.Errorf("[TFTP] failed to bind %s: %w", addr, err) + } + s.pc = pc + return nil +} + +func (s *TFTPServer) Start() error { + // Bind lazily if a caller did not already do so via Bind. + if s.pc == nil { + if err := s.Bind(); err != nil { + logger.Errorf("%+v", err) + return err + } } logger.Infof("Starting TFTP server on %s:%d", s.IP, s.Port) buf := make([]byte, 65535) for { - n, client, err := pc.ReadFrom(buf) + n, client, err := s.pc.ReadFrom(buf) if err != nil { logger.Warnf("[TFTP] read error on main socket: %v", err) continue diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/tui/generator_test.go new/goshs-2.1.4/tui/generator_test.go --- old/goshs-2.1.3/tui/generator_test.go 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/tui/generator_test.go 2026-07-03 09:19:56.000000000 +0200 @@ -141,6 +141,53 @@ } } +func TestGeneratorKeyCopyStagesOSC52(t *testing.T) { + m := &model{genIP: "10.9.8.7", genPort: "1337", genSel: 0} + cmd := generateCommand(shellDB[0].tmpl, m.genIP, m.genPort, m.genEnc) + + if _, handled := m.handleGeneratorKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}); !handled { + t.Fatal("y should be handled by the generator") + } + if m.clipSeq == "" { + t.Fatal("y should stage an OSC 52 sequence in clipSeq") + } + // The OSC 52 payload is the base64 of the command; it appears verbatim even + // when wrapped for tmux/screen, so this holds regardless of the test env. We + // copy to both the system clipboard ('c') and the X11 PRIMARY ('p') selection, + // so the payload appears twice — once per sequence. + want := base64.StdEncoding.EncodeToString([]byte(cmd)) + if n := strings.Count(m.clipSeq, want); n != 2 { + t.Fatalf("clipSeq should carry the command twice, once per selection (want 2, got %d)\nseq: %q", n, m.clipSeq) + } + if !strings.Contains(m.clipSeq, ";c;") || !strings.Contains(m.clipSeq, ";p;") { + t.Fatalf("clipSeq should target both the system (;c;) and primary (;p;) buffers\nseq: %q", m.clipSeq) + } + if m.flash == "" { + t.Fatal("copy should set a flash message") + } +} + +func TestViewEmitsClipboardSequenceOnce(t *testing.T) { + opts := &options.Options{IP: "0.0.0.0", Port: 8000, Webroot: "/srv"} + m := newModel(opts, nil, nil, nil, nil, nil, nil) + m.width, m.height, m.active = 100, 40, paneGenerator + m.genIP, m.genPort, m.genSel = "10.9.8.7", "1337", 0 + m.handleGeneratorKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}) + seq := m.clipSeq + if seq == "" { + t.Fatal("c should stage a clipboard sequence") + } + if first := m.View(); !strings.Contains(first, seq) { + t.Fatal("first View after copy must emit the OSC 52 sequence") + } + if m.clipSeq != "" { + t.Fatal("View must clear clipSeq after emitting it") + } + if second := m.View(); strings.Contains(second, seq) { + t.Fatal("second View must not re-emit the sequence") + } +} + func TestGeneratorKeyLeavesPaneSwitchingAlone(t *testing.T) { m := &model{} if _, handled := m.handleGeneratorKey(tea.KeyMsg{Type: tea.KeyTab}); handled { @@ -162,6 +209,38 @@ } } +// TestGeneratorViewStacksListAboveOutput guards the layout that makes the +// generated command cleanly selectable: the payload list and the output must be +// stacked vertically, never sharing a physical row. If they shared a row, the +// terminal's rectangular mouse selection would grab list text alongside the +// command. +func TestGeneratorViewStacksListAboveOutput(t *testing.T) { + m := &model{width: 100, genIP: "10.9.8.7", genPort: "1337", genSel: 0} + lines := strings.Split(m.generatorView(20), "\n") + + listRow, outRow := -1, -1 + for i, ln := range lines { + if strings.Contains(ln, "▶") { + listRow = i + } + if strings.Contains(ln, "nc -lvnp 1337") { + outRow = i + } + if strings.Contains(ln, "▶") && strings.Contains(ln, "nc -lvnp 1337") { + t.Fatalf("list and output share row %d: %q", i, ln) + } + } + if listRow == -1 { + t.Fatal("no selected payload row (▶) found") + } + if outRow == -1 { + t.Fatal("no listener output row found") + } + if listRow >= outRow { + t.Fatalf("list row %d should be above output row %d", listRow, outRow) + } +} + func TestGeneratorTabHasNoCount(t *testing.T) { opts := &options.Options{IP: "0.0.0.0", Port: 8000, Webroot: "/srv"} m := newModel(opts, nil, nil, nil, nil, nil, nil) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/goshs-2.1.3/tui/tui.go new/goshs-2.1.4/tui/tui.go --- old/goshs-2.1.3/tui/tui.go 2026-06-25 15:38:49.000000000 +0200 +++ new/goshs-2.1.4/tui/tui.go 2026-07-03 09:19:56.000000000 +0200 @@ -12,12 +12,15 @@ "fmt" "net" "os" + "os/exec" "path/filepath" + "runtime" "sort" "strconv" "strings" "time" + "github.com/aymanbagabas/go-osc52/v2" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -150,6 +153,8 @@ flash string // transient status message (e.g. export result) flashExpiry time.Time // when the flash message should clear + clipSeq string // OSC 52 escape sequence to emit on the next render (one-shot clipboard copy), empty when nothing pending + inputActive bool // single-line text input mode is open inputBuf string // text typed so far in input mode inputPrompt string // label shown in the status bar while typing @@ -1207,6 +1212,104 @@ m.flashExpiry = time.Now().Add(4 * time.Second) } +// copyToClipboard writes text to the operator's clipboard by two complementary +// paths, because neither works everywhere: +// +// - nativeCopy shells out to a local clipboard tool (xclip/xsel/wl-copy/ +// pbcopy/clip). This is the path that works when goshs runs on the same +// machine as the terminal — many local emulators (gnome-terminal, konsole, +// plain xterm) do NOT honour OSC 52, so the escape sequence alone copies +// nothing there. +// - An OSC 52 escape sequence staged on the next render (see View). This is +// the path that works when the TUI is driven over SSH/tmux on a target box +// with no local display: the operator's terminal emulator receives the +// sequence and updates their real clipboard. +// +// Doing both covers local and remote operation; each is a no-op where it does +// not apply. Support is still best-effort, so the flash says "copied" even +// though we cannot read the clipboard back to confirm. +func (m *model) copyToClipboard(text, label string) { + // nativeCopy already fills both the CLIPBOARD and PRIMARY selections locally. + nativeCopy(text) + // For the OSC 52 path (the one that reaches the operator over SSH), emit the + // system CLIPBOARD ('c') sequence and, right after it, the X11 PRIMARY ('p') + // sequence — so middle-click / Shift+Insert paste also works remotely, not + // just Ctrl+V. Concatenated into a single clipSeq so View() flushes them + // together on one frame. + m.clipSeq = osc52Seq(text, osc52.SystemClipboard) + osc52Seq(text, osc52.PrimaryClipboard) + m.setFlash(label) +} + +// nativeCopy best-effort writes text to the operator's OS clipboard by shelling +// out to a platform clipboard tool. On X11/Wayland it fills BOTH the CLIPBOARD +// selection (Ctrl+V) and the PRIMARY selection (middle-click / Shift+Insert), so +// a paste works regardless of which convention the operator uses; unlike OSC 52, +// these are independent processes writing straight to the display server, so +// there is no two-sequences-break-each-other problem. On Linux/BSD it only runs +// when a local display is present (DISPLAY / WAYLAND_DISPLAY), so it stays a +// no-op over a plain SSH session where the OSC 52 path in copyToClipboard is what +// reaches the operator instead. Returns false when no suitable tool/display is +// available or every command failed. +func nativeCopy(text string) bool { + // Each entry is a command + args; multiple entries target multiple + // selections (e.g. clipboard + primary on X11). + var cmds [][]string + switch runtime.GOOS { + case "darwin": + cmds = [][]string{{"pbcopy"}} // macOS has a single clipboard + case "windows": + cmds = [][]string{{"clip"}} // Windows has a single clipboard + default: // linux, *bsd + switch { + case os.Getenv("WAYLAND_DISPLAY") != "": + if _, err := exec.LookPath("wl-copy"); err == nil { + cmds = [][]string{{"wl-copy"}, {"wl-copy", "--primary"}} + } + case os.Getenv("DISPLAY") != "": + if _, err := exec.LookPath("xclip"); err == nil { + cmds = [][]string{ + {"xclip", "-selection", "clipboard"}, + {"xclip", "-selection", "primary"}, + } + } else if _, err := exec.LookPath("xsel"); err == nil { + cmds = [][]string{ + {"xsel", "--clipboard", "--input"}, + {"xsel", "--primary", "--input"}, + } + } + } + } + ok := false + for _, c := range cmds { + cmd := exec.Command(c[0], c[1:]...) + cmd.Stdin = strings.NewReader(text) + if cmd.Run() == nil { + ok = true + } + } + return ok +} + +// osc52Seq builds a single OSC 52 copy sequence for the given clipboard buffer. +// +// Under GNU screen it wraps the sequence in screen's DCS passthrough so it +// reaches the outer terminal (screen passes DCS through permissively). +// +// Under tmux it does NOT wrap. tmux's own DCS-passthrough (osc52.Tmux()) requires +// `allow-passthrough on`, which is off by default and security-gated; relying on +// it left copies silently dropped for operators who had the far more common +// `set-clipboard on` set instead. So we emit a plain OSC 52 and let tmux forward +// it: with `set-clipboard on` tmux intercepts the app's sequence, sets its buffer +// and relays it to the outer terminal. (tmux's default `set-clipboard external` +// blocks in-pane apps, so `set-clipboard on` is the documented requirement.) +func osc52Seq(text string, buf osc52.Clipboard) string { + seq := osc52.New(text).Clipboard(buf) + if strings.HasPrefix(os.Getenv("TERM"), "screen") && os.Getenv("TMUX") == "" { + seq = seq.Screen() + } + return seq.String() +} + // --- view ------------------------------------------------------------------- // Nord palette (https://www.nordtheme.com) — the same theme the web UI uses, @@ -1290,6 +1393,9 @@ }) case "n": m.genEnc = m.genEnc.next() + case "y", "c": + // Copy the filled command to the operator's clipboard via OSC 52. + m.copyToClipboard(generateCommand(shellDB[m.genSel].tmpl, m.genIP, m.genPort, m.genEnc), "copied command to clipboard") case "enter", "e": // The form has no detail view and no per-pane log to export; swallow // these so ⏎ does not toggle detail and e does not write an empty file. @@ -1299,27 +1405,34 @@ return nil, true } -// generatorView renders the GENERATOR pane: a selectable payload list on the -// left and the editable LHOST/LPORT/encoding fields plus the filled command and -// listener line on the right, in exactly h lines. +// generatorView renders the GENERATOR pane: a selectable payload list on top +// and the editable LHOST/LPORT/encoding fields plus the filled command and +// listener line below, in exactly h lines. The two are stacked (not columned) +// so the output rows span the full width and can be cleanly mouse-selected +// without the terminal's rectangular selection also grabbing the payload list. func (m *model) generatorView(h int) string { if len(shellDB) == 0 { return padLines(nil, h) } - leftW := 26 - if max := m.width - 20; leftW > max { - leftW = max - } - if leftW < 10 { - leftW = 10 - } - rightW := m.width - leftW - 1 - if rightW < 1 { - rightW = 1 - } - left := m.generatorList(leftW, h) - right := m.generatorOutput(rightW, h) - return lipgloss.JoinHorizontal(lipgloss.Top, left, sepColumn(h), right) + // Cap the list height so the output (long PowerShell payloads especially) + // gets the bulk of the pane; the list windows around the selection so a + // short height just scrolls. + listH := h / 2 + if listH > 10 { + listH = 10 + } + if listH < 3 { + listH = 3 + } + sepH := 1 + outH := h - listH - sepH + if outH < 1 { + // Pane too short to stack; give everything to the list. + return m.generatorList(m.width, h) + } + list := m.generatorList(m.width, listH) + out := m.generatorOutput(m.width, outH) + return lipgloss.JoinVertical(lipgloss.Left, list, sepRow(m.width), out) } // generatorList renders the scrollable list of payload names, windowed so the @@ -1374,14 +1487,12 @@ return padLines(lines, h) } -// sepColumn renders a 1-column vertical divider h lines tall. -func sepColumn(h int) string { - line := dimStyle.Render("│") - lines := make([]string, h) - for i := range lines { - lines[i] = line +// sepRow renders a 1-line horizontal divider w columns wide. +func sepRow(w int) string { + if w < 1 { + w = 1 } - return strings.Join(lines, "\n") + return dimStyle.Render(strings.Repeat("─", w)) } func (m *model) View() string { @@ -1397,7 +1508,17 @@ bodyH = 1 } body := m.bodyView(bodyH) - return strings.Join([]string{banner, tabs, body, status}, "\n") + frame := strings.Join([]string{banner, tabs, body, status}, "\n") + // Flush a pending clipboard copy by appending its OSC 52 sequence to the + // frame exactly once. OSC 52 does not move the cursor or occupy cells, so it + // is invisible in the rendered output; embedding it in the frame is the + // race-free way to reach the terminal under Bubble Tea v1 (which, unlike v2, + // has no SetClipboard). Cleared here so it is emitted a single time. + if m.clipSeq != "" { + frame += m.clipSeq + m.clipSeq = "" + } + return frame } // banner renders the goshs logo at the top. On short terminals it collapses to @@ -1615,7 +1736,7 @@ } switch m.active { case paneGenerator: - return "⇄ Tab/←→ panes · ↑↓ shell · i LHOST · p LPORT · n encoding · q quit" + return "⇄ Tab/←→ panes · ↑↓ shell · i LHOST · p LPORT · n encoding · y/c copy · q quit" case paneClipboard: return "⇄ Tab/←→ panes · ↑↓ scroll · ⏎ view · a add · d delete · C clear · e export · q quit" case paneShells: ++++++ vendor.tar.gz ++++++ /work/SRC/openSUSE:Factory/goshs/vendor.tar.gz /work/SRC/openSUSE:Factory/.goshs.new.1982/vendor.tar.gz differ: char 23, line 1
