Skip to content
Merged
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
3 changes: 3 additions & 0 deletions cmd/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ func dumpConfig(cfg *config.Config) error {
if cfg.IsSet("up.prefer_local_routes") {
up["prefer_local_routes"] = cfg.GetBool("up.prefer_local_routes")
}
if cfg.IsSet("up.exit_node_takes_precedence") {
up["exit_node_takes_precedence"] = cfg.GetBool("up.exit_node_takes_precedence")
}
if len(up) > 0 {
out["up"] = up
}
Expand Down
234 changes: 234 additions & 0 deletions cmd/select/exitnode/exitnode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
package exitnode

import (
"fmt"
"os"
"strings"

"github.com/charmbracelet/huh"
"github.com/fosrl/cli/internal/api"
"github.com/fosrl/cli/internal/companion"
"github.com/fosrl/cli/internal/config"
"github.com/fosrl/cli/internal/logger"
"github.com/fosrl/cli/internal/olm"
"github.com/fosrl/cli/internal/utils"
"github.com/spf13/cobra"
)

type ExitNodeCmdOpts struct {
ExitNode string
}

// disableChoice is the menu value for the "disable gateway" option.
const disableChoice = -1

func ExitNodeCmd() *cobra.Command {
opts := ExitNodeCmdOpts{}

cmd := &cobra.Command{
Use: "exit-node",
Short: "Route all traffic through an exit node",
Long: `List the exit nodes in your organization and select one to route all
tunnel traffic (full tunnel) through the sites backing it.

While an exit node is active, a "None" option is shown to turn it off.

With a running client the change takes effect immediately. Without one, the
choice is saved and applied the next time you run 'pangolin up'.`,
Run: func(cmd *cobra.Command, args []string) {
if err := exitNodeMain(cmd, &opts); err != nil {
os.Exit(1)
}
},
}

cmd.Flags().StringVar(&opts.ExitNode, "exit-node", "", "Exit node `NICE-ID` to select")

return cmd
}

func exitNodeMain(cmd *cobra.Command, opts *ExitNodeCmdOpts) error {
if err := companion.GuardMutatingAuth(cmd.Context()); err != nil {
logger.Error("%v", err)
return err
}

// The client doesn't have to be running: the choice is saved to the config
// and applied by the next `pangolin up`. When it is running it is also
// applied live.
olmClient := olm.NewClient("")
running := olmClient.IsRunning()
status := &olm.StatusResponse{}
if running {
var err error
status, err = olmClient.GetStatus()
if err != nil {
logger.Error("Failed to get client status: %v", err)
return err
}
}

apiClient := api.FromContext(cmd.Context())
accountStore := config.AccountStoreFromContext(cmd.Context())

orgID, err := utils.ResolveOrgID(accountStore, "")
if err != nil {
logger.Error("%v", err)
return err
}

activeAccount, err := accountStore.ActiveAccount()
if err != nil {
logger.Error("%v", err)
return err
}

gateways, err := apiClient.ListGatewayResources(orgID)
if err != nil {
logger.Error("Failed to list exit nodes: %v", err)
return err
}

usable := gateways[:0]
for _, g := range gateways {
if g.Enabled && len(g.SiteIDs) > 0 {
usable = append(usable, g)
}
}
// The saved exit node, scoped to the account's current org.
savedResourceID := activeAccount.ExitNodeResourceID
// With a running client, the active exit node is the one it reports;
// otherwise it is the saved one, which the next start will apply.
isActive := func(g api.SiteResource) bool {
if running {
return status.GatewayActive && g.SiteResourceID == status.GatewaySiteResourceID
}
return savedResourceID != 0 && g.SiteResourceID == savedResourceID
}
// A saved exit node can outlive the active one (e.g. its sites weren't
// connected on startup), so offer to clear it either way.
hasGateway := status.GatewayActive || savedResourceID != 0
if len(usable) == 0 && !hasGateway {
err := fmt.Errorf("no exit nodes available in this organization")
logger.Error("%v", err)
return err
}

choice := disableChoice
if opts.ExitNode != "" {
choice = -2
for i, g := range usable {
if g.NiceID == opts.ExitNode {
choice = i
break
}
}
if choice == -2 {
err := fmt.Errorf("exit node '%s' not found or not available", opts.ExitNode)
logger.Error("%v", err)
return err
}
} else {
choice, err = selectExitNodeForm(usable, isActive, hasGateway)
if err != nil {
logger.Error("%v", err)
return err
}
}

if choice == disableChoice {
if running && status.GatewayActive {
if _, err := olmClient.DisableGateway(); err != nil {
logger.Error("Failed to disable exit node: %v", err)
return err
}
}
activeAccount.ClearExitNode()
if !saveExitNode(accountStore, activeAccount, running) {
return fmt.Errorf("failed to save exit node")
}
logger.Success("Exit node disabled")
return nil
}

selected := usable[choice]
if running {
if _, err := olmClient.SelectGateway(selected.SiteResourceID, selected.SiteIDs); err != nil {
logger.Error("Failed to select exit node: %v", err)
return err
}
}
activeAccount.SetExitNode(selected.SiteResourceID)
if !saveExitNode(accountStore, activeAccount, running) {
return fmt.Errorf("failed to save exit node")
}

if running {
logger.Success("Routing all traffic through exit node: %s", selected.Name)
} else {
logger.Success("Exit node %s saved; it will be used the next time you run 'pangolin up'", selected.Name)
}
return nil
}

// saveExitNode persists the exit node on the account so the next `pangolin up`
// re-applies it. With a running client the change is already live, so a save
// failure is only a warning; without one, saving is the whole point, so it is
// an error.
func saveExitNode(accountStore *config.AccountStore, account *config.Account, alreadyApplied bool) bool {
err := accountStore.UpdateActiveAccount(account)
if err == nil {
err = accountStore.Save()
}
if err != nil {
if alreadyApplied {
logger.Warning("Exit node applied but could not be saved for the next start: %v", err)
return true
}
logger.Error("Failed to save exit node: %v", err)
return false
}
return true
}

// selectExitNodeForm returns the index of the chosen gateway, or disableChoice.
func selectExitNodeForm(gateways []api.SiteResource, isActive func(api.SiteResource) bool, hasGateway bool) (int, error) {
options := make([]huh.Option[int], 0, len(gateways)+1)
if hasGateway {
options = append(options, huh.NewOption("None (disable exit node)", disableChoice))
}
for i, g := range gateways {
label := fmt.Sprintf("%s (%s)", g.Name, g.NiceID)
if len(g.SiteNames) > 0 {
label += " - " + strings.Join(g.SiteNames, ", ")
}
if isActive(g) {
label += " [active]"
}
options = append(options, huh.NewOption(label, i))
}

// huh starts the cursor on the option matching this value, so preselect
// "None" when it's offered.
selected := 0
if hasGateway {
selected = disableChoice
}
form := huh.NewForm(
huh.NewGroup(
// Value must come before Options: Options positions the scroll
// offset from the value bound at that moment, and Value doesn't
// reposition it afterwards, which would leave "None" hidden above
// the visible list.
huh.NewSelect[int]().
Title("Select an exit node").
Value(&selected).
Options(options...),
),
)
if err := form.Run(); err != nil {
return 0, fmt.Errorf("error selecting exit node: %w", err)
}

return selected, nil
}
2 changes: 2 additions & 0 deletions cmd/select/select.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package selectcmd

import (
"github.com/fosrl/cli/cmd/select/account"
"github.com/fosrl/cli/cmd/select/exitnode"
"github.com/fosrl/cli/cmd/select/org"
"github.com/spf13/cobra"
)
Expand All @@ -15,6 +16,7 @@ func SelectCmd() *cobra.Command {

cmd.AddCommand(account.AccountCmd())
cmd.AddCommand(org.OrgCmd())
cmd.AddCommand(exitnode.ExitNodeCmd())

return cmd
}
42 changes: 40 additions & 2 deletions cmd/status/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"sort"
"time"

"github.com/fosrl/cli/internal/logger"
Expand Down Expand Up @@ -76,21 +77,22 @@ func printJSON(status *olm.StatusResponse) error {
// printStatusTable prints the status information in a table format
func printStatusTable(status *olm.StatusResponse) {
// Print connection status
headers := []string{"AGENT", "VERSION", "STATUS", "ORG"}
headers := []string{"AGENT", "VERSION", "STATUS", "ORG", "EXIT NODE"}
rows := [][]string{
{
status.Agent,
status.Version,
formatStatus(status.Connected, status.Registered),
status.OrgID,
formatGateway(status),
},
}
utils.PrintTable(headers, rows)

// Print peers (and the exit node, if connected) if there are any
if len(status.PeerStatuses) > 0 || status.ExitNode != nil {
fmt.Println("")
peerHeaders := []string{"SITE", "ENDPOINT", "STATUS", "LAST SEEN", "CONNECTION"}
peerHeaders := []string{"SITE", "ENDPOINT", "STATUS", "LAST SEEN", "CONNECTION", "EXIT NODE"}
peerRows := [][]string{}

if status.ExitNode != nil {
Expand All @@ -101,10 +103,25 @@ func printStatusTable(status *olm.StatusResponse) {
formatStatus(status.ExitNode.Connected, true),
lastSeen,
"Direct",
"-",
})
}

gatewaySites := make(map[int]bool, len(status.GatewaySiteIDs))
if status.GatewayActive {
for _, id := range status.GatewaySiteIDs {
gatewaySites[id] = true
}
}

// Map iteration order is random; sort so the table is stable between runs.
peers := make([]*olm.OLMPeerStatus, 0, len(status.PeerStatuses))
for _, peer := range status.PeerStatuses {
peers = append(peers, peer)
}
sort.Slice(peers, func(i, j int) bool { return peers[i].SiteID < peers[j].SiteID })

for _, peer := range peers {
lastSeen := formatLastSeen(peer.LastSeen.Format(time.RFC3339))

peerRows = append(peerRows, []string{
Expand All @@ -113,6 +130,7 @@ func printStatusTable(status *olm.StatusResponse) {
formatStatus(peer.Connected, true), // Peers don't have registered field, use true
lastSeen,
formatConnectionMode(peer.IsLocal, peer.IsRelay),
formatGatewayMember(gatewaySites[peer.SiteID]),
})

}
Expand All @@ -122,6 +140,26 @@ func printStatusTable(status *olm.StatusResponse) {
}
}

// formatGateway summarizes whether the client is routing all traffic through a
// gateway (exit node), and which site resource it was selected from.
func formatGateway(status *olm.StatusResponse) string {
if !status.GatewayActive {
return "Off"
}
if status.GatewaySiteResourceID != 0 {
return fmt.Sprintf("Active")
}
return "Active"
}

// formatGatewayMember marks the sites currently in use as the gateway.
func formatGatewayMember(isGateway bool) string {
if isGateway {
return "Yes"
}
return "-"
}

// formatConnectionMode summarizes how a peer is currently connected. Local and relay are
// mutually exclusive; when neither applies the peer is connected directly to its public
// endpoint.
Expand Down
Loading
Loading