pomerium/authorize/ssh_grpc.go
Joe Kralicky ff26890bf4
wip
2025-03-19 18:20:13 +00:00

916 lines
26 KiB
Go

package authorize
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"io"
"net/url"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
"github.com/klauspost/compress/zstd"
extensions_ssh "github.com/pomerium/envoy-custom/api/extensions/filters/network/ssh"
extensions_session_recording "github.com/pomerium/envoy-custom/api/extensions/filters/network/ssh/filters/session_recording"
"github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/internal/log"
"github.com/pomerium/pomerium/internal/sessions"
"github.com/pomerium/pomerium/pkg/identity"
"github.com/pomerium/pomerium/pkg/identity/oauth"
gossh "golang.org/x/crypto/ssh"
"golang.org/x/sync/errgroup"
"google.golang.org/grpc"
"google.golang.org/protobuf/encoding/protodelim"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
"google.golang.org/protobuf/types/known/wrapperspb"
)
type StreamState struct {
Username string
Hostname string
PublicKey []byte
MethodsAuthenticated []string
}
func (a *Authorize) RecordingFinalized(
stream grpc.ClientStreamingServer[extensions_session_recording.RecordingData, emptypb.Empty],
) error {
msg, err := stream.Recv()
if err != nil {
return err
}
md := msg.GetMetadata()
if md == nil {
return fmt.Errorf("first message did not contain metadata")
}
log.Ctx(stream.Context()).Info().Str("info", protojson.Format(md)).Msg("new recording")
var recording []byte
READ:
for {
msg, err := stream.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return err
}
switch data := msg.Data.(type) {
case *extensions_session_recording.RecordingData_Chunk:
recording = append(recording, data.Chunk...)
case *extensions_session_recording.RecordingData_Checksum:
actual := sha256.Sum256(recording)
if actual != [32]byte(data.Checksum) {
return fmt.Errorf("checksum mismatch")
}
break READ
}
}
r, err := zstd.NewReader(bytes.NewReader(recording))
if err != nil {
return fmt.Errorf("failed to create zstd reader: %w", err)
}
switch md.Format {
case extensions_session_recording.Format_AsciicastFormat:
log.Ctx(stream.Context()).Info().Int("compressed_size", len(recording)).Msg("asciicast recording received")
case extensions_session_recording.Format_RawFormat:
reader := bufio.NewReader(r)
var header extensions_session_recording.Header
if err := protodelim.UnmarshalFrom(reader, &header); err != nil {
return fmt.Errorf("failed to unmarshal header: %w", err)
}
var packets []*extensions_session_recording.Packet
for {
var packet extensions_session_recording.Packet
err := protodelim.UnmarshalFrom(reader, &packet)
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return fmt.Errorf("failed to unmarshal packet: %w", err)
}
packets = append(packets, &packet)
}
log.Ctx(stream.Context()).Info().Int("compressed_size", len(recording)).Int("packet_count", len(packets)).Msg("recording received")
}
return nil
}
var activeStreamIds sync.Map
func (a *Authorize) ManageStream(
server extensions_ssh.StreamManagement_ManageStreamServer,
) error {
recvC := make(chan *extensions_ssh.ClientMessage, 32)
sendC := make(chan *extensions_ssh.ServerMessage, 32)
eg, ctx := errgroup.WithContext(server.Context())
eg.Go(func() error {
defer close(recvC)
for {
req, err := server.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
recvC <- req
}
})
eg.Go(func() error {
for {
select {
case <-ctx.Done():
return nil
case msg := <-sendC:
if err := server.Send(msg); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
}
}
})
var state StreamState
deviceAuthSuccess := &atomic.Bool{}
deviceAuthDone := make(chan struct{})
errC := make(chan error, 1)
a.activeStreamsMu.Lock()
a.activeStreams = append(a.activeStreams, errC)
a.activeStreamsMu.Unlock()
for {
select {
case err := <-errC:
return err
case req, ok := <-recvC:
if !ok {
return nil
}
switch req := req.Message.(type) {
case *extensions_ssh.ClientMessage_Event:
switch event := req.Event.Event.(type) {
case *extensions_ssh.StreamEvent_DownstreamConnected:
fmt.Println("downstream connected")
_ = event
case *extensions_ssh.StreamEvent_UpstreamConnected:
fmt.Printf("upstream connected: %d\n", event.UpstreamConnected.GetStreamId())
activeStreamIds.Store(event.UpstreamConnected.GetStreamId(), state)
defer activeStreamIds.Delete(event.UpstreamConnected.GetStreamId())
case nil:
}
case *extensions_ssh.ClientMessage_AuthRequest:
authReq := req.AuthRequest
fmt.Println("auth request")
if state.Username == "" {
state.Username = authReq.Username
}
if state.Hostname == "" {
state.Hostname = authReq.Hostname
}
switch authReq.AuthMethod {
case "publickey":
methodReq, _ := authReq.MethodRequest.UnmarshalNew()
pubkeyReq, ok := methodReq.(*extensions_ssh.PublicKeyMethodRequest)
if !ok {
return fmt.Errorf("client sent invalid auth request message")
}
//
// validate public key here
//
state.MethodsAuthenticated = append(state.MethodsAuthenticated, "publickey")
state.PublicKey = pubkeyReq.PublicKey
if authReq.Username == "" && authReq.Hostname == "" {
resp := extensions_ssh.ServerMessage{
Message: &extensions_ssh.ServerMessage_AuthResponse{
AuthResponse: &extensions_ssh.AuthenticationResponse{
Response: &extensions_ssh.AuthenticationResponse_Allow{
Allow: &extensions_ssh.AllowResponse{
Username: state.Username,
Target: &extensions_ssh.AllowResponse_Internal{
Internal: &extensions_ssh.InternalTarget{},
},
},
},
},
},
}
sendC <- &resp
continue
} else if authReq.Username == "_mirror" && authReq.Hostname == "" {
resp := extensions_ssh.ServerMessage{
Message: &extensions_ssh.ServerMessage_AuthResponse{
AuthResponse: &extensions_ssh.AuthenticationResponse{
Response: &extensions_ssh.AuthenticationResponse_Allow{
Allow: &extensions_ssh.AllowResponse{
Username: state.Username,
Target: &extensions_ssh.AllowResponse_Internal{
Internal: &extensions_ssh.InternalTarget{},
},
},
},
},
},
}
// id, _ := strconv.ParseUint(authReq.Hostname, 10, 64)
// resp := extensions_ssh.ServerMessage{
// Message: &extensions_ssh.ServerMessage_AuthResponse{
// AuthResponse: &extensions_ssh.AuthenticationResponse{
// Response: &extensions_ssh.AuthenticationResponse_Allow{
// Allow: &extensions_ssh.AllowResponse{
// Target: &extensions_ssh.AllowResponse_MirrorSession{
// MirrorSession: &extensions_ssh.MirrorSessionTarget{
// SourceId: id,
// Mode: extensions_ssh.MirrorSessionTarget_ReadWrite,
// },
// },
// },
// },
// },
// },
// }
sendC <- &resp
continue
} else if authReq.Username != "" && authReq.Hostname == "" {
resp := extensions_ssh.ServerMessage{
Message: &extensions_ssh.ServerMessage_AuthResponse{
AuthResponse: &extensions_ssh.AuthenticationResponse{
Response: &extensions_ssh.AuthenticationResponse_Allow{
Allow: &extensions_ssh.AllowResponse{
Username: state.Username,
Target: &extensions_ssh.AllowResponse_Internal{
Internal: &extensions_ssh.InternalTarget{},
},
},
},
},
},
}
sendC <- &resp
continue
}
if !slices.Contains(state.MethodsAuthenticated, "keyboard-interactive") {
resp := extensions_ssh.ServerMessage{
Message: &extensions_ssh.ServerMessage_AuthResponse{
AuthResponse: &extensions_ssh.AuthenticationResponse{
Response: &extensions_ssh.AuthenticationResponse_Deny{
Deny: &extensions_ssh.DenyResponse{
Partial: true,
Methods: []string{"keyboard-interactive"},
},
},
},
},
}
sendC <- &resp
}
case "keyboard-interactive":
opts := a.currentOptions.Load()
var route *config.Policy
for r := range opts.GetAllPolicies() {
if r.From == "ssh://"+strings.TrimSuffix(strings.Join([]string{state.Hostname, opts.SSHHostname}, "."), ".") {
route = r
break
}
}
if route == nil {
return fmt.Errorf("invalid route")
}
// sessionState := a.state.Load()
idp, err := opts.GetIdentityProviderForPolicy(route)
if err != nil {
return err
}
authenticator, err := identity.NewAuthenticator(ctx, a.tracerProvider, oauth.Options{
RedirectURL: &url.URL{},
ProviderName: idp.GetType(),
ProviderURL: idp.GetUrl(),
ClientID: idp.GetClientId(),
ClientSecret: idp.GetClientSecret(),
Scopes: idp.GetScopes(),
AuthCodeOptions: idp.GetRequestParams(),
DeviceAuthClientType: idp.GetDeviceAuthClientType(),
})
if err != nil {
return err
}
deviceAuthResp, err := authenticator.DeviceAuth(ctx)
if err != nil {
return err
}
infoReq := extensions_ssh.KeyboardInteractiveInfoPrompts{
Name: "Sign in with " + idp.GetType(),
Instruction: deviceAuthResp.VerificationURIComplete,
Prompts: []*extensions_ssh.KeyboardInteractiveInfoPrompts_Prompt{
// {},
},
}
infoReqAny, _ := anypb.New(&infoReq)
resp := extensions_ssh.ServerMessage{
Message: &extensions_ssh.ServerMessage_AuthResponse{
AuthResponse: &extensions_ssh.AuthenticationResponse{
Response: &extensions_ssh.AuthenticationResponse_InfoRequest{
InfoRequest: &extensions_ssh.InfoRequest{
Method: "keyboard-interactive",
Request: infoReqAny,
},
},
},
},
}
sendC <- &resp
go func() {
var claims identity.SessionClaims
token, err := authenticator.DeviceAccessToken(ctx, deviceAuthResp, &claims)
if err != nil {
errC <- err
return
}
s := sessions.NewState(idp.Id)
err = claims.Claims.Claims(&s)
if err != nil {
errC <- fmt.Errorf("error unmarshaling session state: %w", err)
return
}
fmt.Println(token)
deviceAuthSuccess.Store(true)
close(deviceAuthDone)
}()
}
case *extensions_ssh.ClientMessage_InfoResponse:
resp := req.InfoResponse
if resp.Method == "keyboard-interactive" {
r, _ := resp.Response.UnmarshalNew()
respInfo, ok := r.(*extensions_ssh.KeyboardInteractiveInfoPromptResponses)
if ok {
fmt.Println(respInfo.Responses)
}
}
select {
case <-deviceAuthDone:
case <-ctx.Done():
}
if deviceAuthSuccess.Load() {
state.MethodsAuthenticated = append(state.MethodsAuthenticated, "keyboard-interactive")
} else {
resp := extensions_ssh.ServerMessage{
Message: &extensions_ssh.ServerMessage_AuthResponse{
AuthResponse: &extensions_ssh.AuthenticationResponse{
Response: &extensions_ssh.AuthenticationResponse_Deny{
Deny: &extensions_ssh.DenyResponse{},
},
},
},
}
sendC <- &resp
// retryReq := extensions_ssh.KeyboardInteractiveInfoPrompts{
// Name: "",
// Instruction: "Login not successful yet, try again",
// Prompts: []*extensions_ssh.KeyboardInteractiveInfoPrompts_Prompt{
// // {},
// },
// }
// infoReqAny, _ := anypb.New(&retryReq)
// resp := extensions_ssh.ServerMessage{
// Message: &extensions_ssh.ServerMessage_AuthResponse{
// AuthResponse: &extensions_ssh.AuthenticationResponse{
// Response: &extensions_ssh.AuthenticationResponse_InfoRequest{
// InfoRequest: &extensions_ssh.InfoRequest{
// Method: "keyboard-interactive",
// Request: infoReqAny,
// },
// },
// },
// },
// }
// sendC <- &resp
continue
}
if slices.Contains(state.MethodsAuthenticated, "publickey") {
pkData, _ := anypb.New(&extensions_ssh.PublicKeyAllowResponse{
PublicKey: state.PublicKey,
Permissions: &extensions_ssh.Permissions{
PermitPortForwarding: true,
PermitAgentForwarding: true,
PermitX11Forwarding: true,
PermitPty: true,
PermitUserRc: true,
ValidBefore: timestamppb.New(time.Now().Add(-1 * time.Minute)),
ValidAfter: timestamppb.New(time.Now().Add(12 * time.Hour)),
},
})
sessionRecordingExt, _ := anypb.New(&extensions_session_recording.UpstreamTargetExtensionConfig{
RecordingName: fmt.Sprintf("session-%s-at-%s-%d.cast", state.Username, state.Hostname, time.Now().UnixNano()),
Format: extensions_session_recording.Format_AsciicastFormat,
})
authResponse := extensions_ssh.ServerMessage{
Message: &extensions_ssh.ServerMessage_AuthResponse{
AuthResponse: &extensions_ssh.AuthenticationResponse{
Response: &extensions_ssh.AuthenticationResponse_Allow{
Allow: &extensions_ssh.AllowResponse{
Username: state.Username,
Target: &extensions_ssh.AllowResponse_Upstream{
Upstream: &extensions_ssh.UpstreamTarget{
Hostname: state.Hostname,
AllowedMethods: []*extensions_ssh.AllowedMethod{
{
Method: "publickey",
MethodData: pkData,
},
{
Method: "keyboard-interactive",
},
},
Extensions: []*corev3.TypedExtensionConfig{
{
TypedConfig: sessionRecordingExt,
},
},
},
},
},
},
},
},
}
sendC <- &authResponse
} else {
resp := extensions_ssh.ServerMessage{
Message: &extensions_ssh.ServerMessage_AuthResponse{
AuthResponse: &extensions_ssh.AuthenticationResponse{
Response: &extensions_ssh.AuthenticationResponse_Deny{
Deny: &extensions_ssh.DenyResponse{
Partial: true,
Methods: []string{"publickey"},
},
},
},
},
}
sendC <- &resp
}
case nil:
}
}
}
return eg.Wait()
}
// See RFC 4254, section 5.1.
const msgChannelOpen = 90
type channelOpenMsg struct {
ChanType string `sshtype:"90"`
PeersID uint32
PeersWindow uint32
MaxPacketSize uint32
TypeSpecificData []byte `ssh:"rest"`
}
const (
msgChannelExtendedData = 95
msgChannelData = 94
)
// Used for debug print outs of packets.
type channelDataMsg struct {
PeersID uint32 `sshtype:"94"`
Length uint32
Rest []byte `ssh:"rest"`
}
// See RFC 4254, section 5.1.
const msgChannelOpenConfirm = 91
type channelOpenConfirmMsg struct {
PeersID uint32 `sshtype:"91"`
MyID uint32
MyWindow uint32
MaxPacketSize uint32
TypeSpecificData []byte `ssh:"rest"`
}
const msgChannelRequest = 98
type channelRequestMsg struct {
PeersID uint32 `sshtype:"98"`
Request string
WantReply bool
RequestSpecificData []byte `ssh:"rest"`
}
type channelOpenDirectMsg struct {
DestAddr string
DestPort uint32
SrcAddr string
SrcPort uint32
}
// See RFC 4254, section 5.4.
const msgChannelSuccess = 99
type channelRequestSuccessMsg struct {
PeersID uint32 `sshtype:"99"`
}
// See RFC 4254, section 5.4.
const msgChannelFailure = 100
type channelRequestFailureMsg struct {
PeersID uint32 `sshtype:"100"`
}
// See RFC 4254, section 5.3
const msgChannelClose = 97
type channelCloseMsg struct {
PeersID uint32 `sshtype:"97"`
}
// See RFC 4254, section 5.3
const msgChannelEOF = 96
type channelEOFMsg struct {
PeersID uint32 `sshtype:"96"`
}
func (a *Authorize) ServeChannel(
server extensions_ssh.StreamManagement_ServeChannelServer,
) error {
var program *tea.Program
inputR, inputW := io.Pipe()
outputR, outputW := io.Pipe()
var peerId uint32
var downstreamChannelInfo *extensions_ssh.SSHDownstreamChannelInfo
var downstreamPtyInfo *extensions_ssh.SSHDownstreamPTYInfo
for {
channelMsg, err := server.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
rawMsg := channelMsg.GetRawBytes().GetValue()
switch rawMsg[0] {
case msgChannelOpen:
var msg channelOpenMsg
gossh.Unmarshal(rawMsg, &msg)
var confirm channelOpenConfirmMsg
peerId = msg.PeersID
confirm.PeersID = peerId
confirm.MyID = 1
confirm.MyWindow = msg.PeersWindow
confirm.MaxPacketSize = msg.MaxPacketSize
downstreamChannelInfo = &extensions_ssh.SSHDownstreamChannelInfo{
ChannelType: msg.ChanType,
DownstreamChannelId: confirm.PeersID,
InternalUpstreamChannelId: confirm.MyID,
InitialWindowSize: confirm.MyWindow,
MaxPacketSize: confirm.MaxPacketSize,
}
switch msg.ChanType {
case "session":
if err := server.Send(&extensions_ssh.ChannelMessage{
Message: &extensions_ssh.ChannelMessage_RawBytes{
RawBytes: &wrapperspb.BytesValue{
Value: gossh.Marshal(confirm),
},
},
}); err != nil {
return err
}
case "direct-tcpip":
var subMsg channelOpenDirectMsg
if err := gossh.Unmarshal(msg.TypeSpecificData, &subMsg); err != nil {
return err
}
handOff, _ := anypb.New(&extensions_ssh.SSHChannelControlAction{
Action: &extensions_ssh.SSHChannelControlAction_HandOff{
HandOff: &extensions_ssh.SSHChannelControlAction_HandOffUpstream{
DownstreamChannelInfo: downstreamChannelInfo,
UpstreamAuth: &extensions_ssh.AllowResponse{
Target: &extensions_ssh.AllowResponse_Upstream{
Upstream: &extensions_ssh.UpstreamTarget{
Hostname: subMsg.DestAddr,
DirectTcpip: true,
},
},
},
},
},
})
if err := server.Send(&extensions_ssh.ChannelMessage{
Message: &extensions_ssh.ChannelMessage_ChannelControl{
ChannelControl: &extensions_ssh.ChannelControl{
Protocol: "ssh",
ControlAction: handOff,
},
},
}); err != nil {
return err
}
}
case msgChannelRequest:
var msg channelRequestMsg
gossh.Unmarshal(rawMsg, &msg)
switch msg.Request {
case "pty-req":
opts := a.currentOptions.Load()
var routes []string
for r := range opts.GetAllPolicies() {
if strings.HasPrefix(r.From, "ssh://") {
routes = append(routes, fmt.Sprintf("ubuntu@%s", strings.TrimSuffix(strings.TrimPrefix(r.From, "ssh://"), "."+opts.SSHHostname)))
}
}
req := parsePtyReq(msg.RequestSpecificData)
items := []list.Item{}
for _, route := range routes {
items = append(items, item(route))
}
activeStreamIds.Range(func(key, value any) bool {
items = append(items, item(fmt.Sprintf("[demo] mirror session: %v", key)))
return true
})
downstreamPtyInfo = &extensions_ssh.SSHDownstreamPTYInfo{
TermEnv: req.TermEnv,
WidthColumns: req.Width,
HeightRows: req.Height,
WidthPx: req.WidthPx,
HeightPx: req.HeightPx,
Modes: req.Modes,
}
const defaultWidth = 20
l := list.New(items, itemDelegate{}, defaultWidth, listHeight)
l.Title = "Connect to which server?"
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
l.Styles.Title = titleStyle
l.Styles.PaginationStyle = paginationStyle
l.Styles.HelpStyle = helpStyle
program = tea.NewProgram(model{list: l},
tea.WithInput(inputR),
tea.WithOutput(outputW),
tea.WithAltScreen(),
tea.WithContext(server.Context()),
tea.WithEnvironment([]string{"TERM=" + req.TermEnv}),
)
go func() {
answer, err := program.Run()
if err != nil {
return
}
var handOff *anypb.Any
if strings.HasPrefix(answer.(model).choice, "[demo] mirror session: ") {
id, err := strconv.ParseUint(strings.TrimPrefix(answer.(model).choice, "[demo] mirror session: "), 10, 64)
if err != nil {
panic(err)
}
handOff, _ = anypb.New(&extensions_ssh.SSHChannelControlAction{
Action: &extensions_ssh.SSHChannelControlAction_HandOff{
HandOff: &extensions_ssh.SSHChannelControlAction_HandOffUpstream{
DownstreamChannelInfo: downstreamChannelInfo,
DownstreamPtyInfo: downstreamPtyInfo,
UpstreamAuth: &extensions_ssh.AllowResponse{
Target: &extensions_ssh.AllowResponse_MirrorSession{
MirrorSession: &extensions_ssh.MirrorSessionTarget{
SourceId: id,
Mode: extensions_ssh.MirrorSessionTarget_ReadWrite,
},
},
},
},
},
})
} else {
username, hostname, _ := strings.Cut(answer.(model).choice, "@")
sessionRecordingExt, _ := anypb.New(&extensions_session_recording.UpstreamTargetExtensionConfig{
RecordingName: fmt.Sprintf("session-%s-at-%s-%d.cast", username, hostname, time.Now().UnixNano()),
Format: extensions_session_recording.Format_AsciicastFormat,
})
handOff, _ = anypb.New(&extensions_ssh.SSHChannelControlAction{
Action: &extensions_ssh.SSHChannelControlAction_HandOff{
HandOff: &extensions_ssh.SSHChannelControlAction_HandOffUpstream{
DownstreamChannelInfo: downstreamChannelInfo,
DownstreamPtyInfo: downstreamPtyInfo,
UpstreamAuth: &extensions_ssh.AllowResponse{
Username: username,
Target: &extensions_ssh.AllowResponse_Upstream{
Upstream: &extensions_ssh.UpstreamTarget{
AllowMirrorConnections: true,
Hostname: hostname,
Extensions: []*corev3.TypedExtensionConfig{
{
TypedConfig: sessionRecordingExt,
},
},
},
},
},
},
},
})
}
if err := server.Send(&extensions_ssh.ChannelMessage{
Message: &extensions_ssh.ChannelMessage_ChannelControl{
ChannelControl: &extensions_ssh.ChannelControl{
Protocol: "ssh",
ControlAction: handOff,
},
},
}); err != nil {
return
}
}()
go func() {
var buf [4096]byte
for {
n, err := outputR.Read(buf[:])
if err != nil {
return
}
if err := server.Send(&extensions_ssh.ChannelMessage{
Message: &extensions_ssh.ChannelMessage_RawBytes{
RawBytes: &wrapperspb.BytesValue{
Value: gossh.Marshal(channelDataMsg{
PeersID: peerId,
Length: uint32(n),
Rest: buf[:n],
}),
},
},
}); err != nil {
return
}
}
}()
program.Send(tea.WindowSizeMsg{Width: int(req.Width), Height: int(req.Height)})
if err := server.Send(&extensions_ssh.ChannelMessage{
Message: &extensions_ssh.ChannelMessage_RawBytes{
RawBytes: &wrapperspb.BytesValue{
Value: gossh.Marshal(channelRequestSuccessMsg{
PeersID: peerId,
}),
},
},
}); err != nil {
return err
}
}
case msgChannelData:
var msg channelDataMsg
gossh.Unmarshal(rawMsg, &msg)
if program != nil {
inputW.Write(msg.Rest)
}
case msgChannelClose:
var msg channelDataMsg
gossh.Unmarshal(rawMsg, &msg)
default:
panic("unhandled message: " + fmt.Sprint(rawMsg[1]))
}
}
}
type ptyReq struct {
TermEnv string
Width, Height uint32
WidthPx, HeightPx uint32
Modes []byte
}
func parsePtyReq(reqData []byte) ptyReq {
termEnvLen := binary.BigEndian.Uint32(reqData)
reqData = reqData[4:]
termEnv := string(reqData[:termEnvLen])
reqData = reqData[termEnvLen:]
return ptyReq{
TermEnv: termEnv,
Width: binary.BigEndian.Uint32(reqData),
Height: binary.BigEndian.Uint32(reqData[4:]),
WidthPx: binary.BigEndian.Uint32(reqData[8:]),
HeightPx: binary.BigEndian.Uint32(reqData[12:]),
Modes: reqData[16:],
}
}
const listHeight = 14
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)
quitTextStyle = lipgloss.NewStyle().Margin(1, 0, 2, 4)
)
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)
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()
}