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

Reply via email to