From 1d15dec13fe55879fe66ed031551f3a6656ae137 Mon Sep 17 00:00:00 2001 From: Fabrice Aneche Date: Tue, 3 Sep 2024 22:56:50 -0400 Subject: [PATCH] query api --- README.md | 36 +++++++++++-- cmd/sshjump/config.go | 18 ++++--- cmd/sshjump/kubernetes.go | 105 ++++++++++++++++++++++++++++++++------ cmd/sshjump/server.go | 81 ++++++++++++++++++++++++----- 4 files changed, 202 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 8a94d79..f3c8fcf 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,16 @@ A Kubernetes port forwarder using SSH and a nice TUI. SSHJump uses SSH public key authentication to validate users and permissions. +![SSH Jump kangaroo logo](img/sshjump512.png?raw=true "SSH Jump logo") + ## Usage -Use SSH local forward to forward any port from the cluster: +Use SSH local forward to forward any ports from the cluster: ```sh ssh -L8080:nginx:8080 -p 2222 k8s.cluster.domain.tld ``` -If you are authorized sshjump will connect your localhost port 8080 to the first running container named `nginx`. +If you are authorized sshjump will connect your localhost port 8080 to the first running pod named `nginx`. ### Target Selection @@ -40,8 +42,8 @@ SSHJump is mainly intended to run from inside a Kubernetes cluster but can be us If `KUBE_CONFIG_PATH` env variable is set to a `.kube/config` SSHJump will use it to connect the Kubernetes API. -![SSH Jump kangaroo logo](img/sshjump512.png?raw=true "SSH Jump logo") -## Configuration + +## Config file Example configuration to allow the user `bob` to access `nginx` and `redis` in the `projecta` namespace. ```yaml @@ -56,13 +58,14 @@ permissions: - name: "nginx" ports: - 8080 + - 8888 services: - name: "redis" ports: - 6379 ``` -By default SSHJump will deny access to any namespaces if not explicetly mentioned in the `namespaces` list, to let a user access to everything (like in a dev env) use `allowAll: true` +By default SSHJump will deny access to any namespaces if not explicetly mentioned in the `namespaces` list, to let a user access to everything in any namespaces (like in a dev env) use `allowAll: true` ```yaml version: sshjump.inair.space/v1 @@ -73,9 +76,32 @@ permissions: allowAll: true ``` +To open access to a full namespace, just list the namespace without pod name. +```yaml +version: sshjump.inair.space/v1 + +permissions: +- username: "bob" + authorizedKey: "ssh-ed25519 AAAAAasasasasas bob@sponge.net" + namespaces: + - namespace: "projecta" +``` ## Features + + + +## Image Build + +This repo is using [`ko`](https://ko.build): +```sh +KO_DOCKER_REPO=ghcr.io/akhenakh/sshjump ko build ./cmd/sshjump +``` + +There is a `Dockerfile` to be used with Docker & Podman too. + + ## TODO - [ ] restrict access to a namespace diff --git a/cmd/sshjump/config.go b/cmd/sshjump/config.go index c07f5fa..d6c2a81 100644 --- a/cmd/sshjump/config.go +++ b/cmd/sshjump/config.go @@ -8,16 +8,22 @@ type SSHJumpConfig struct { Permissions []Permission `yaml:"permissions"` // Permissions is a list of users and their permissions. } -// Container represents a single application container. -type Container struct { - Name string `yaml:"name"` // Name matching containers - Ports []int `yaml:"ports"` // Ports is a list of ports allowed. +// Pod represents a Kubernetes pod (multiple containers). +type Pod struct { + Name string `yaml:"name"` // Name matching containers + Ports []int32 `yaml:"ports"` // Ports is a list of ports allowed. +} + +type Service struct { + Name string `yaml:"name"` // Name matching service + Ports []int32 `yaml:"ports"` // Ports is a list of ports allowed. } // Namespace represents a group of containers within a specific namespace. type Namespace struct { - Namespace string `yaml:"namespace"` // Namespace is the name of the namespace. - Containers []Container `yaml:"containers"` // Containers is a list of containers in the namespace. + Namespace string `yaml:"namespace"` // Namespace is the name of the namespace. + Pods []Pod `yaml:"pods"` // Pods is a list of Pods allowed in the namespace. + Services []Service `yaml:"services"` } // Permission represents the access permissions for a single user. diff --git a/cmd/sshjump/kubernetes.go b/cmd/sshjump/kubernetes.go index 48cd806..a0d65f8 100644 --- a/cmd/sshjump/kubernetes.go +++ b/cmd/sshjump/kubernetes.go @@ -7,58 +7,133 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -type containerPort struct { +type destPort struct { namespace string pod string container string + service string port int32 + addr string // the real addr to connect to } -// PortsForUser return a list of services the provided user is allowed to reach -func (srv *Server) PortsForUser(ctx context.Context, user string) ([]containerPort, error) { +type destPorts []destPort + +// KubernetesPortsForUser return a list of Kubernetes services/containers the provided user is allowed to reach +func (srv *Server) KubernetesPortsForUser(ctx context.Context, user string) (destPorts, error) { + var cps []destPort + // list all pods in all namespaces pods, err := srv.clientset.CoreV1().Pods("").List(ctx, metav1.ListOptions{}) if err != nil { return nil, fmt.Errorf("can't fetch pods list %w", err) } - var cps []containerPort - for _, pod := range pods.Items { for _, container := range pod.Spec.Containers { for _, port := range container.Ports { - cps = append(cps, containerPort{ + cps = append(cps, destPort{ namespace: pod.Namespace, pod: pod.Name, container: container.Name, port: port.ContainerPort, + addr: pod.Status.HostIP, }) } } } + // Get the list of services in the default namespace + services, err := srv.clientset.CoreV1().Services("").List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("can't fetch services list %w", err) + } + + // Iterate through the services and print their names and ports + for _, service := range services.Items { + for _, port := range service.Spec.Ports { + cps = append(cps, destPort{ + namespace: service.Namespace, + service: service.Name, + port: port.Port, + addr: service.Status.LoadBalancer.Ingress[0].IP, + }) + } + } + return cps, nil } -// filter cps using user permissions -func (srv *Server) allowed(cps []containerPort, user string) []containerPort { - userPerms, exists := srv.keys[user] +func (ps destPorts) MatchingService(name, namespace string, port int32) (string, bool) { + for _, ps := range ps { + if ps.namespace == namespace && ps.service == name && ps.port == port { + return fmt.Sprintf("%s:%d", ps.addr, ps.port), true + } + } + + return "", false +} + +func (ps destPorts) MatchingPod(name, namespace string, port int32) (string, bool) { + for _, ps := range ps { + if ps.namespace == namespace && ps.pod == name && ps.port == port { + return fmt.Sprintf("%s:%d", ps.addr, ps.port), true + } + } + + return "", false +} + +// filter list of ports using user permissions +func (srv *Server) allowed(ports destPorts, user string) []destPort { + userPerms, exists := srv.permissions[user] if !exists { // If the user doesn't exist in the permissions map, return an empty slice - return []containerPort{} + return []destPort{} } + // full access if userPerms.AllowAll { - return cps + return ports } - var allowed []containerPort + var allowed []destPort + + for _, port := range ports { + for _, userNs := range userPerms.Namespaces { + if userNs.Namespace == port.namespace { + // Namespace matches + if len(userNs.Pods) == 0 { + // full access to the namespace + allowed = append(allowed, port) + + continue + } - // TODO: check for matching namespace + // Now check for pods & services + // check against user perms + for _, uPod := range userNs.Pods { + for _, up := range uPod.Ports { + if addr, ok := ports.MatchingPod(uPod.Name, userNs.Namespace, up); ok { + port.addr = addr + allowed = append(allowed, port) - // for _, ns := range userPerms.Namespaces { + continue + } + } + } + for _, uService := range userNs.Services { + for _, up := range uService.Ports { + if addr, ok := ports.MatchingService(uService.Name, userNs.Namespace, up); ok { + port.addr = addr + allowed = append(allowed, port) - // } + continue + } + } + } + } + } + } return allowed } diff --git a/cmd/sshjump/server.go b/cmd/sshjump/server.go index 6aafa58..59cce51 100644 --- a/cmd/sshjump/server.go +++ b/cmd/sshjump/server.go @@ -5,7 +5,7 @@ import ( "io" "log/slog" "net" - "strconv" + "strings" "sync" "github.com/gliderlabs/ssh" @@ -17,8 +17,9 @@ type Server struct { logger *slog.Logger *ssh.Server clientset *kubernetes.Clientset - mu sync.Mutex - keys map[string]Permission + + mu sync.Mutex + permissions map[string]Permission } // direct-tcpip data struct as specified in RFC4254, Section 7.2 @@ -32,9 +33,9 @@ type localForwardChannelData struct { func NewServer(logger *slog.Logger, keys map[string]Permission, clientset *kubernetes.Clientset) *Server { s := &Server{ - logger: logger, - keys: keys, - clientset: clientset, + logger: logger, + permissions: keys, + clientset: clientset, } sshServer = &ssh.Server{ Handler: s.Handler, @@ -59,25 +60,37 @@ func NewServer(logger *slog.Logger, keys map[string]Permission, clientset *kuber } func (srv *Server) Handler(s ssh.Session) { - srv.logger.Info("login", "username", s.User(), "ip", s.RemoteAddr().String()) + srv.logger.Info("login", + "username", s.User(), + "ip", s.RemoteAddr().String(), + ) io.WriteString(s, fmt.Sprintf("user %s\n", s.User())) select {} } -func (srv *Server) PublicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool { +func (srv *Server) PermsForUser(user string) *Permission { srv.mu.Lock() defer srv.mu.Unlock() // looking for a matching username - perm, ok := srv.keys[ctx.User()] + perm, ok := srv.permissions[user] if !ok { + return nil + } + + return &perm +} + +func (srv *Server) PublicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool { + perms := srv.PermsForUser(ctx.User()) + if perms == nil { srv.logger.Warn("no such username", "username", ctx.User(), "ip", ctx.RemoteAddr().String()) return false } // validate key - if ssh.KeysEqual(key, perm.Key) { + if ssh.KeysEqual(key, perms.Key) { return true } @@ -94,15 +107,59 @@ func (srv *Server) DirectTCPIPHandler(s *ssh.Server, conn *gossh.ServerConn, new return } + srv.logger.Debug("tcp fwd request", "user", ctx.User(), "host", d.DestAddr, "port", d.DestPort) + if s.LocalPortForwardingCallback == nil || !s.LocalPortForwardingCallback(ctx, d.DestAddr, d.DestPort) { newChan.Reject(gossh.Prohibited, "port forwarding is disabled") return } - dest := net.JoinHostPort("192.168.160.2", strconv.FormatInt(int64(d.DestPort), 10)) + ports, err := srv.KubernetesPortsForUser(ctx, ctx.User()) + if err != nil { + newChan.Reject(gossh.ConnectionFailed, "error querying Kubernetes api: "+err.Error()) + + return + } + + var addr string + // test for service request + if strings.HasPrefix(d.DestAddr, "srv.") { + ds := strings.Split(d.DestAddr, ".") + if len(ds) != 4 { + newChan.Reject(gossh.ConnectionFailed, "invalid kubernetes destination") + + return + } + namespace := ds[1] + service := ds[2] + addr, ok := ports.MatchingService(service, namespace, int32(d.DestPort)) + if !ok { + newChan.Reject(gossh.ConnectionFailed, "kubernetes destination not authorized") + + return + } + addr = addr + } else { + + ds := strings.Split(d.DestAddr, ".") + if len(ds) != 3 { + newChan.Reject(gossh.ConnectionFailed, "invalid kubernetes destination") + + return + } + namespace := ds[0] + service := ds[1] + addr, ok := ports.MatchingService(service, namespace, int32(d.DestPort)) + if !ok { + newChan.Reject(gossh.ConnectionFailed, "kubernetes destination not authorized") + + return + } + addr = addr + } var dialer net.Dialer - dconn, err := dialer.DialContext(ctx, "tcp", dest) + dconn, err := dialer.DialContext(ctx, "tcp", addr) if err != nil { newChan.Reject(gossh.ConnectionFailed, err.Error()) return