Skip to content

Commit

Permalink
query api
Browse files Browse the repository at this point in the history
  • Loading branch information
akhenakh committed Sep 4, 2024
1 parent 2a71657 commit 1d15dec
Show file tree
Hide file tree
Showing 4 changed files with 202 additions and 38 deletions.
36 changes: 31 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 [email protected]"
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
Expand Down
18 changes: 12 additions & 6 deletions cmd/sshjump/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
105 changes: 90 additions & 15 deletions cmd/sshjump/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
81 changes: 69 additions & 12 deletions cmd/sshjump/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"io"
"log/slog"
"net"
"strconv"
"strings"
"sync"

"github.com/gliderlabs/ssh"
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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
}

Expand All @@ -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
Expand Down

0 comments on commit 1d15dec

Please sign in to comment.