From 03aebf338d68a9a1de097c19d9a3da9bb5a39ef3 Mon Sep 17 00:00:00 2001 From: etm Date: Thu, 6 Aug 2026 16:05:32 +0330 Subject: [PATCH 1/2] feat: edit forwarded ports individually --- internal/manage/forwardedports_test.go | 62 ++++++++++++++ internal/manage/menu.go | 109 +++++++++++++++++++++++-- 2 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 internal/manage/forwardedports_test.go diff --git a/internal/manage/forwardedports_test.go b/internal/manage/forwardedports_test.go new file mode 100644 index 0000000..4cb74bc --- /dev/null +++ b/internal/manage/forwardedports_test.go @@ -0,0 +1,62 @@ +package manage + +import ( + "reflect" + "testing" +) + +func TestAddForwardedPortKeepsExistingEntries(t *testing.T) { + original := []string{"443", "8080=127.0.0.1:8080"} + got, err := addForwardedPort(original, " 8443 ") + if err != nil { + t.Fatal(err) + } + want := []string{"443", "8080=127.0.0.1:8080", "8443"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ports = %v, want %v", got, want) + } + if original[0] != "443" || len(original) != 2 { + t.Fatalf("input was modified: %v", original) + } +} + +func TestReplaceForwardedPortChangesOnlySelectedEntry(t *testing.T) { + original := []string{"443", "8080", "9000-9010"} + got, err := replaceForwardedPort(original, 1, "8081=10.0.0.2:80") + if err != nil { + t.Fatal(err) + } + want := []string{"443", "8081=10.0.0.2:80", "9000-9010"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ports = %v, want %v", got, want) + } + if original[1] != "8080" { + t.Fatalf("input was modified: %v", original) + } +} + +func TestRemoveForwardedPortKeepsRemainingEntries(t *testing.T) { + got, err := removeForwardedPort([]string{"443", "8080", "8443"}, 1) + if err != nil { + t.Fatal(err) + } + want := []string{"443", "8443"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ports = %v, want %v", got, want) + } +} + +func TestForwardedPortEditsRejectInvalidInput(t *testing.T) { + if _, err := addForwardedPort([]string{"443"}, "not-a-port"); err == nil { + t.Fatal("adding an invalid port succeeded") + } + if _, err := replaceForwardedPort([]string{"443"}, 2, "8080"); err == nil { + t.Fatal("replacing an out-of-range port succeeded") + } + if _, err := removeForwardedPort([]string{"443"}, 0); err == nil { + t.Fatal("removing the last port succeeded") + } + if _, err := replaceForwardedPorts(" , "); err == nil { + t.Fatal("replacing the list with an empty value succeeded") + } +} diff --git a/internal/manage/menu.go b/internal/manage/menu.go index 6fafae0..c173c3b 100644 --- a/internal/manage/menu.go +++ b/internal/manage/menu.go @@ -237,18 +237,61 @@ func changeTunnelPort(name string, spec TunnelSpec) { tui.PressEnter() } -// changeForwardedPorts prompts for and applies a new forwarded-ports list. +// changeForwardedPorts lets the operator change one forwarded-port entry at a +// time. Keeping the full-list replacement as an advanced option is useful for +// bulk changes, but ordinary edits should not require retyping every port. func changeForwardedPorts(name string, spec TunnelSpec) { + ports := VisiblePorts(spec.Ports, spec.Token) fmt.Println() - tui.Info("Current: " + strings.Join(VisiblePorts(spec.Ports, spec.Token), ", ")) - tui.Warn("Enter the FULL new list (comma separated, e.g. 443,8080 or 443=1.1.1.1:443).") - raw := tui.Prompt("New forwarded ports: ") - ports := parsePorts(raw) - if len(ports) == 0 { - tui.Error("No valid ports entered.") + tui.Info("Current: " + strings.Join(ports, ", ")) + + options := []tui.Option{ + {Title: "Add a port", Desc: "keep all current entries and append one"}, + } + if len(ports) > 0 { + options = append(options, tui.Option{Title: "Edit a port", Desc: "change one entry without retyping the others"}) + } + if len(ports) > 1 { + options = append(options, tui.Option{Title: "Remove a port", Desc: "delete one entry and keep the rest"}) + } + options = append(options, tui.Option{Title: "Replace the full list", Desc: "bulk edit with a comma-separated list"}) + + choice := tui.ChooseOpt("What do you want to change?", options) + if choice < 0 { + return + } + + var err error + switch options[choice].Title { + case "Add a port": + entry := tui.Prompt("New port or mapping: ") + ports, err = addForwardedPort(ports, entry) + case "Edit a port": + idx := chooseForwardedPort("Select the port to edit:", ports) + if idx < 0 { + return + } + entry := tui.PromptDefault("Port or mapping", ports[idx]) + if entry == ports[idx] { + return + } + ports, err = replaceForwardedPort(ports, idx, entry) + case "Remove a port": + idx := chooseForwardedPort("Select the port to remove:", ports) + if idx < 0 || !tui.Confirm("Remove "+ports[idx]+"?", false) { + return + } + ports, err = removeForwardedPort(ports, idx) + case "Replace the full list": + tui.Warn("Enter the FULL new list (comma separated, e.g. 443,8080 or 443=1.1.1.1:443).") + ports, err = replaceForwardedPorts(tui.Prompt("New forwarded ports: ")) + } + if err != nil { + tui.Error("Invalid entry: " + err.Error()) tui.PressEnter() return } + if err := EditTunnel(name, "", "", ports); err != nil { tui.Error("Failed: " + err.Error()) tui.PressEnter() @@ -258,6 +301,58 @@ func changeForwardedPorts(name string, spec TunnelSpec) { tui.PressEnter() } +func chooseForwardedPort(title string, ports []string) int { + options := make([]tui.Option, len(ports)) + for i, port := range ports { + options[i] = tui.Option{Title: port} + } + return tui.ChooseOpt(title, options) +} + +func addForwardedPort(ports []string, entry string) ([]string, error) { + entry = strings.TrimSpace(entry) + if err := validatePortSpecs([]string{entry}); err != nil { + return nil, err + } + updated := append([]string(nil), ports...) + return append(updated, entry), nil +} + +func replaceForwardedPort(ports []string, index int, entry string) ([]string, error) { + if index < 0 || index >= len(ports) { + return nil, fmt.Errorf("port selection is out of range") + } + entry = strings.TrimSpace(entry) + if err := validatePortSpecs([]string{entry}); err != nil { + return nil, err + } + updated := append([]string(nil), ports...) + updated[index] = entry + return updated, nil +} + +func removeForwardedPort(ports []string, index int) ([]string, error) { + if len(ports) <= 1 { + return nil, fmt.Errorf("at least one forwarded port is required") + } + if index < 0 || index >= len(ports) { + return nil, fmt.Errorf("port selection is out of range") + } + updated := append([]string(nil), ports[:index]...) + return append(updated, ports[index+1:]...), nil +} + +func replaceForwardedPorts(raw string) ([]string, error) { + ports := parsePorts(raw) + if len(ports) == 0 { + return nil, fmt.Errorf("at least one forwarded port is required") + } + if err := validatePortSpecs(ports); err != nil { + return nil, err + } + return ports, nil +} + // fallbackSummary renders the backup-address list for the Edit header. func fallbackSummary(addrs []string) string { if len(addrs) == 0 { From 66547ae6b308233d8710f9ceaac127e5ccd17b2d Mon Sep 17 00:00:00 2001 From: etm Date: Thu, 6 Aug 2026 16:43:43 +0330 Subject: [PATCH 2/2] fix: reject overlapping forwarded port listeners --- internal/manage/forwardedports_test.go | 29 ++++++++++++ internal/manage/menu.go | 13 +++--- internal/manage/validate.go | 61 ++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 6 deletions(-) diff --git a/internal/manage/forwardedports_test.go b/internal/manage/forwardedports_test.go index 4cb74bc..34ba9fe 100644 --- a/internal/manage/forwardedports_test.go +++ b/internal/manage/forwardedports_test.go @@ -60,3 +60,32 @@ func TestForwardedPortEditsRejectInvalidInput(t *testing.T) { t.Fatal("replacing the list with an empty value succeeded") } } + +func TestForwardedPortEditsRejectDuplicateListeners(t *testing.T) { + if _, err := addForwardedPort([]string{"443=127.0.0.1:443"}, "443=127.0.0.1:8443"); err == nil { + t.Fatal("adding the same exposed port with a different backend succeeded") + } + if _, err := replaceForwardedPort([]string{"443", "8080"}, 1, "443"); err == nil { + t.Fatal("editing an entry onto an existing exposed port succeeded") + } +} + +func TestForwardedPortValidationFindsOverlappingListeners(t *testing.T) { + for _, ports := range [][]string{ + {"443", "443"}, + {"400-450", "425"}, + {"443", "127.0.0.1:443=127.0.0.1:8443"}, + {"[::]:443=127.0.0.1:443", "192.0.2.1:443=127.0.0.1:8443"}, + } { + if err := validatePortSpecs(ports); err == nil { + t.Errorf("overlapping listeners were accepted: %v", ports) + } + } + + if err := validatePortSpecs([]string{ + "127.0.0.1:443=127.0.0.1:8443", + "192.0.2.1:443=127.0.0.1:9443", + }); err != nil { + t.Fatalf("distinct listen addresses were rejected: %v", err) + } +} diff --git a/internal/manage/menu.go b/internal/manage/menu.go index c173c3b..811d37a 100644 --- a/internal/manage/menu.go +++ b/internal/manage/menu.go @@ -311,11 +311,12 @@ func chooseForwardedPort(title string, ports []string) int { func addForwardedPort(ports []string, entry string) ([]string, error) { entry = strings.TrimSpace(entry) - if err := validatePortSpecs([]string{entry}); err != nil { + updated := append([]string(nil), ports...) + updated = append(updated, entry) + if err := validatePortSpecs(updated); err != nil { return nil, err } - updated := append([]string(nil), ports...) - return append(updated, entry), nil + return updated, nil } func replaceForwardedPort(ports []string, index int, entry string) ([]string, error) { @@ -323,11 +324,11 @@ func replaceForwardedPort(ports []string, index int, entry string) ([]string, er return nil, fmt.Errorf("port selection is out of range") } entry = strings.TrimSpace(entry) - if err := validatePortSpecs([]string{entry}); err != nil { - return nil, err - } updated := append([]string(nil), ports...) updated[index] = entry + if err := validatePortSpecs(updated); err != nil { + return nil, err + } return updated, nil } diff --git a/internal/manage/validate.go b/internal/manage/validate.go index 9e030a0..c97d6a6 100644 --- a/internal/manage/validate.go +++ b/internal/manage/validate.go @@ -83,5 +83,66 @@ func validatePortSpecs(ports []string) error { return fmt.Errorf("invalid port entry %q — use forms like 443, 400-450, 443=1.1.1.1:443", strings.TrimSpace(p)) } } + return validateDistinctPortBindings(ports) +} + +type portBindings struct { + wildcard bool + hosts map[string]bool +} + +// validateDistinctPortBindings rejects entries that would try to listen on +// the same socket. Different destinations for one exposed port belong in the +// supported `backend1|backend2` form; listing the port twice starts two +// listeners, and the second one can only fail after the service restarts. +func validateDistinctPortBindings(ports []string) error { + seen := map[int]*portBindings{} + for _, spec := range ports { + local := strings.TrimSpace(strings.SplitN(spec, "=", 2)[0]) + host, first, last := "*", 0, 0 + + if h, p, err := net.SplitHostPort(local); err == nil { + host = normalizedListenHost(h) + first, _ = strconv.Atoi(p) + last = first + } else if strings.Contains(local, "-") { + bounds := strings.SplitN(local, "-", 2) + first, _ = strconv.Atoi(strings.TrimSpace(bounds[0])) + last, _ = strconv.Atoi(strings.TrimSpace(bounds[1])) + } else { + first, _ = strconv.Atoi(local) + last = first + } + + for port := first; port <= last; port++ { + binding := seen[port] + if binding == nil { + binding = &portBindings{hosts: map[string]bool{}} + seen[port] = binding + } + if host == "*" { + if binding.wildcard || len(binding.hosts) > 0 { + return fmt.Errorf("forwarded port %d is listed more than once", port) + } + binding.wildcard = true + continue + } + if binding.wildcard || binding.hosts[host] { + return fmt.Errorf("forwarded port %s:%d is listed more than once", host, port) + } + binding.hosts[host] = true + } + } return nil } + +func normalizedListenHost(host string) string { + host = strings.Trim(strings.TrimSpace(host), "[]") + if host == "" || host == "0.0.0.0" || host == "::" { + return "*" + } + if ip := net.ParseIP(host); ip != nil { + return ip.String() + } + return strings.ToLower(host) +}