mirror of
https://github.com/pomerium/pomerium.git
synced 2025-08-06 10:21:05 +02:00
ssh: stream management api (#5670)
## Summary This implements the StreamManagement API defined at https://github.com/pomerium/envoy-custom/blob/main/api/extensions/filters/network/ssh/ssh.proto#L46-L60. Policy evaluation and authorization logic is stubbed out here, and implemented in https://github.com/pomerium/pomerium/pull/5665. ## Related issues <!-- For example... - #159 --> ## User Explanation <!-- How would you explain this change to the user? If this change doesn't create any user-facing changes, you can leave this blank. If filled out, add the `docs` label --> ## Checklist - [ ] reference any related issues - [ ] updated unit tests - [ ] add appropriate label (`enhancement`, `bug`, `breaking`, `dependencies`, `ci`) - [ ] ready for review
This commit is contained in:
parent
c53aca0dd8
commit
b216b7a135
18 changed files with 4257 additions and 9 deletions
244
pkg/ssh/cli.go
Normal file
244
pkg/ssh/cli.go
Normal file
|
@ -0,0 +1,244 @@
|
|||
package ssh
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/pomerium/envoy-custom/api/extensions/filters/network/ssh"
|
||||
"github.com/pomerium/pomerium/config"
|
||||
)
|
||||
|
||||
type CLI struct {
|
||||
*cobra.Command
|
||||
tui *tea.Program
|
||||
ptyInfo *ssh.SSHDownstreamPTYInfo
|
||||
username string
|
||||
}
|
||||
|
||||
func NewCLI(
|
||||
cfg *config.Config,
|
||||
ctrl ChannelControlInterface,
|
||||
ptyInfo *ssh.SSHDownstreamPTYInfo,
|
||||
stdin io.Reader,
|
||||
stdout io.Writer,
|
||||
) *CLI {
|
||||
cmd := &cobra.Command{
|
||||
Use: "pomerium",
|
||||
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||
_, cmdIsInteractive := cmd.Annotations["interactive"]
|
||||
switch {
|
||||
case (ptyInfo == nil) && cmdIsInteractive:
|
||||
return fmt.Errorf("\x1b[31m'%s' is an interactive command and requires a TTY (try passing '-t' to ssh)\x1b[0m", cmd.Use)
|
||||
case (ptyInfo != nil) && !cmdIsInteractive:
|
||||
return fmt.Errorf("\x1b[31m'%s' is not an interactive command (try passing '-T' to ssh, or removing '-t')\x1b[0m\r", cmd.Use)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.CompletionOptions.DisableDefaultCmd = true
|
||||
cmd.SetIn(stdin)
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(stdout)
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
cli := &CLI{
|
||||
Command: cmd,
|
||||
tui: nil,
|
||||
ptyInfo: ptyInfo,
|
||||
username: *ctrl.Username(),
|
||||
}
|
||||
|
||||
if cfg.Options.IsRuntimeFlagSet(config.RuntimeFlagSSHRoutesPortal) {
|
||||
cli.AddPortalCommand(ctrl)
|
||||
}
|
||||
cli.AddLogoutCommand(ctrl)
|
||||
cli.AddWhoamiCommand(ctrl)
|
||||
|
||||
return cli
|
||||
}
|
||||
|
||||
func (cli *CLI) AddLogoutCommand(ctrl ChannelControlInterface) {
|
||||
cli.AddCommand(&cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "Log out",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
err := ctrl.DeleteSession(cmd.Context())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete session: %w\r", err)
|
||||
}
|
||||
_, _ = cmd.OutOrStdout().Write([]byte("Logged out successfully\r\n"))
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (cli *CLI) AddWhoamiCommand(ctrl ChannelControlInterface) {
|
||||
cli.AddCommand(&cobra.Command{
|
||||
Use: "whoami",
|
||||
Short: "Show details for the current session",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
s, err := ctrl.FormatSession(cmd.Context())
|
||||
if err != nil {
|
||||
return fmt.Errorf("couldn't fetch session: %w\r", err)
|
||||
}
|
||||
_, _ = cmd.OutOrStdout().Write(s)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ErrHandoff is a sentinel error to indicate that the command triggered a handoff,
|
||||
// and we should not automatically disconnect
|
||||
var ErrHandoff = errors.New("handoff")
|
||||
|
||||
func (cli *CLI) AddPortalCommand(ctrl ChannelControlInterface) {
|
||||
cli.AddCommand(&cobra.Command{
|
||||
Use: "portal",
|
||||
Short: "Interactive route portal",
|
||||
Annotations: map[string]string{
|
||||
"interactive": "",
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
var routes []string
|
||||
for r := range ctrl.AllSSHRoutes() {
|
||||
routes = append(routes, fmt.Sprintf("%s@%s", *ctrl.Username(), strings.TrimPrefix(r.From, "ssh://")))
|
||||
}
|
||||
items := []list.Item{}
|
||||
for _, route := range routes {
|
||||
items = append(items, item(route))
|
||||
}
|
||||
l := list.New(items, itemDelegate{}, int(cli.ptyInfo.WidthColumns-2), int(cli.ptyInfo.HeightRows-2))
|
||||
l.Title = "Connect to which server?"
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetFilteringEnabled(false)
|
||||
l.Styles.Title = titleStyle
|
||||
l.Styles.PaginationStyle = paginationStyle
|
||||
l.Styles.HelpStyle = helpStyle
|
||||
|
||||
cli.tui = tea.NewProgram(model{list: l},
|
||||
tea.WithInput(cmd.InOrStdin()),
|
||||
tea.WithOutput(cmd.OutOrStdout()),
|
||||
tea.WithAltScreen(),
|
||||
tea.WithContext(cmd.Context()),
|
||||
tea.WithEnvironment([]string{"TERM=" + cli.ptyInfo.TermEnv}),
|
||||
)
|
||||
|
||||
go cli.SendTeaMsg(tea.WindowSizeMsg{Width: int(cli.ptyInfo.WidthColumns), Height: int(cli.ptyInfo.HeightRows)})
|
||||
answer, err := cli.tui.Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if answer.(model).choice == "" {
|
||||
return nil // quit/ctrl+c
|
||||
}
|
||||
|
||||
username, hostname, _ := strings.Cut(answer.(model).choice, "@")
|
||||
// Perform authorize check for this route
|
||||
if username != cli.username {
|
||||
panic("bug: username mismatch")
|
||||
}
|
||||
if hostname == "" {
|
||||
panic("bug: hostname is empty")
|
||||
}
|
||||
|
||||
handoffMsg, err := ctrl.PrepareHandoff(cmd.Context(), hostname, cli.ptyInfo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctrl.SendControlAction(handoffMsg); err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrHandoff
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (cli *CLI) SendTeaMsg(msg tea.Msg) {
|
||||
if cli.tui != nil {
|
||||
cli.tui.Send(msg)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().MarginLeft(2)
|
||||
itemStyle = lipgloss.NewStyle().PaddingLeft(4)
|
||||
selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170"))
|
||||
paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4)
|
||||
helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1)
|
||||
)
|
||||
|
||||
type item string
|
||||
|
||||
func (i item) FilterValue() string { return "" }
|
||||
|
||||
type itemDelegate struct{}
|
||||
|
||||
func (d itemDelegate) Height() int { return 1 }
|
||||
func (d itemDelegate) Spacing() int { return 0 }
|
||||
func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
|
||||
func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
|
||||
i, ok := listItem.(item)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
str := fmt.Sprintf("%d. %s", index+1, i)
|
||||
|
||||
fn := itemStyle.Render
|
||||
if index == m.Index() {
|
||||
fn = func(s ...string) string {
|
||||
return selectedItemStyle.Render("> " + strings.Join(s, " "))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprint(w, fn(str))
|
||||
}
|
||||
|
||||
type model struct {
|
||||
list list.Model
|
||||
choice string
|
||||
quitting bool
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.list.SetWidth(msg.Width - 2)
|
||||
m.list.SetHeight(msg.Height - 2)
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch keypress := msg.String(); keypress {
|
||||
case "q", "ctrl+c":
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
|
||||
case "enter":
|
||||
i, ok := m.list.SelectedItem().(item)
|
||||
if ok {
|
||||
m.choice = string(i)
|
||||
}
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.list, cmd = m.list.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
return "\n" + m.list.View()
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue