Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions internal/manage/forwardedports_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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")
}
}

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)
}
}
110 changes: 103 additions & 7 deletions internal/manage/menu.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -258,6 +301,59 @@ 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)
updated := append([]string(nil), ports...)
updated = append(updated, entry)
if err := validatePortSpecs(updated); err != nil {
return nil, err
}
return updated, 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)
updated := append([]string(nil), ports...)
updated[index] = entry
if err := validatePortSpecs(updated); err != nil {
return nil, err
}
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 {
Expand Down
61 changes: 61 additions & 0 deletions internal/manage/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}