Skip to content
Open
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
4 changes: 2 additions & 2 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.23'
go-version: '1.26'

- name: download vendor
run: go mod vendor

- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: v1.60.1
version: v2.9.0
args: --timeout=5m --modules-download-mode=vendor ./...

- name: Test
Expand Down
88 changes: 83 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,74 @@ permissions:
namespaces:
- namespace: "projecta"
```

## TOTP Two-Factor Authentication (2FA)

SSHJump supports TOTP (Time-based One-Time Password) for two-factor authentication, adding an extra layer of security beyond SSH public key authentication.

### Generating a TOTP Secret

To enable TOTP for a user, first generate a secret:

```sh
./sshjump generate-totp
```

This will output a base32-encoded secret that should be added to your configuration file. The user will need to add this secret to their authenticator app (Google Authenticator, Authy, etc.).

### Configuring TOTP

Add the `totpSecret` field to the user's permission entry in your configuration:

```yaml
version: sshjump.inair.space/v1

permissions:
- username: "bob"
authorizedKey: "ssh-ed25519 AAAAAasasasasas bob@sponge.net"
totpSecret: "JBSWY3DPEHPK3PXP"
namespaces:
- namespace: "projecta"
containers:
- name: "nginx"
ports:
- 8080
```

### User Setup

After the administrator adds the TOTP secret to the config, the user should:

1. **Add the secret to their authenticator app:**
- Manually enter the secret provided by the administrator
- Or scan a QR code generated from the secret

2. **Connect via SSH:**
```sh
ssh -L8080:svc.projecta.nginx:8080 -p 2222 sshjump.example.com
```

3. **Enter the TOTP code:**
- If the user has TOTP configured, they will be prompted to enter their 6-digit code
- For interactive TUI mode: a prompt will appear before showing the resource list
- For direct port-forwarding: the user must first open an interactive session to verify TOTP

**Important:** Once TOTP is verified in a session, port-forwarding works for the duration of that session. If the session disconnects, TOTP must be re-verified.

### Interactive TOTP Verification

If using direct port-forwarding (not the TUI), users must first open an interactive session:

```sh
# First, verify TOTP in an interactive session
ssh -p 2222 sshjump.example.com
# Enter TOTP code when prompted
# Keep this session open

# Then, in another terminal, use port-forwarding
ssh -L8080:svc.projecta.nginx:8080 -p 2222 sshjump.example.com
```

## Tailscale

It's possible to join your tailnet by providing a ts auth key.
Expand All @@ -145,6 +213,16 @@ Pass the key in a file (from secret or configmaps) using the env variable `TS_AU

## Features

- **SSH Public Key Authentication** - Secure authentication using standard SSH keys
- **TOTP Two-Factor Authentication** - Optional 2FA using time-based one-time passwords
- **Dynamic Target Selection** - Interactive TUI for selecting Kubernetes resources
- **Static Port Forwarding** - Direct forwarding to specific pods and services
- **Kubernetes Integration** - Automatic discovery of pods and services
- **Namespace Restrictions** - Fine-grained access control per namespace
- **Config Hot Reload** - Configuration updates without restart
- **Prometheus Metrics** - Connection and tunnel metrics
- **Tailscale Support** - Join your tailnet for secure access


## End to End Testing

Expand Down Expand Up @@ -180,14 +258,14 @@ There is a `Dockerfile` to be used with Docker & Podman too.

## TODO

- [ ] restrict access to a namespace
- [ ] restrict access to a pod
- [X] restrict access to a namespace
- [X] restrict access to a pod
- [ ] Jumphost ssh
- [ ] TUI
- [ ] OTP
- [X] TUI
- [X] OTP
- [X] logs
- [X] user tunnel connection metric
- [ ] allow/deny metrics
- [X] allow/deny metrics
- [X] reload config on changes
- [ ] config map example
- [X] kubernetes example
Expand Down
100 changes: 91 additions & 9 deletions cmd/sshjump/bubbletea.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/ssh"
Expand All @@ -19,20 +20,22 @@ import (
type state int

const (
stateLoading state = iota
stateTOTP state = iota
stateLoading
stateList
stateConnected
stateError
)

type model struct {
state state
list list.Model
spinner spinner.Model
user string
server *Server
err error
selected *portItem
state state
list list.Model
spinner spinner.Model
totpInput textinput.Model
user string
server *Server
err error
selected *portItem

// For static forwards
connectedTarget string
Expand All @@ -53,6 +56,7 @@ type model struct {
type portsLoadedMsg []list.Item
type errMsg error
type connectionEstablishedMsg string
type totpValidatedMsg bool

func (srv *Server) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
userConnections.WithLabelValues(s.User()).Inc()
Expand All @@ -71,15 +75,31 @@ func (srv *Server) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
Foreground(lipgloss.Color("230")).
Padding(0, 1)

// Initialize TOTP input
ti := textinput.New()
ti.Placeholder = "Enter 6-digit TOTP code"
ti.CharLimit = 6
ti.Width = 20
ti.EchoMode = textinput.EchoPassword
ti.Focus()

resolver := GetTargetResolver(s.Context())
statusChan := GetStatusChannel(s.Context())

// Determine initial state based on TOTP requirement
perms := srv.PermsForUser(s.User())
initialState := stateLoading
if NeedsTOTPVerification(s.Context(), perms) {
initialState = stateTOTP
}

m := model{
state: stateLoading,
state: initialState,
user: s.User(),
server: srv,
list: l,
spinner: sp,
totpInput: ti,
logger: srv.logger,
renderer: renderer,
resolver: resolver,
Expand Down Expand Up @@ -132,6 +152,11 @@ func waitForStatus(ch chan string) tea.Cmd {
}

func (m *model) Init() tea.Cmd {
// If TOTP is required, just blink the cursor
if m.state == stateTOTP {
return textinput.Blink
}

return tea.Batch(
m.spinner.Tick,
fetchPorts(m.user, m.server),
Expand All @@ -153,6 +178,20 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "q", "ctrl+c":
return m, tea.Quit
case "enter":
if m.state == stateTOTP {
// Validate TOTP code
code := m.totpInput.Value()
perms := m.server.PermsForUser(m.user)
if ValidateTOTP(perms.TOTPSecret, code) {
return m, func() tea.Msg {
return totpValidatedMsg(true)
}
} else {
m.totpInput.SetValue("")
m.err = fmt.Errorf("invalid TOTP code")
return m, nil
}
}
if m.state == stateList {
i, ok := m.list.SelectedItem().(portItem)
if ok {
Expand Down Expand Up @@ -196,13 +235,31 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state = stateConnected
return m, waitForStatus(m.statusChan)

case totpValidatedMsg:
if bool(msg) {
// Set TOTP as verified in resolver
m.resolver.mu.Lock()
m.resolver.TOTPVerified = true
m.resolver.mu.Unlock()
m.err = nil
m.state = stateLoading
return m, tea.Batch(
m.spinner.Tick,
fetchPorts(m.user, m.server),
waitForStatus(m.statusChan),
)
}

case errMsg:
m.err = msg
m.state = stateError
return m, nil
}

switch m.state {
case stateTOTP:
m.totpInput, cmd = m.totpInput.Update(msg)
cmds = append(cmds, cmd)
case stateLoading:
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
Expand All @@ -220,6 +277,31 @@ func (m *model) View() string {
}

switch m.state {
case stateTOTP:
title := m.renderer.NewStyle().
Bold(true).
Foreground(lipgloss.Color("205")).
Render("🔐 TOTP 2FA Required")

instructions := "Enter your 6-digit TOTP code to continue."
if m.err != nil {
instructions = m.renderer.NewStyle().
Foreground(lipgloss.Color("#FF0000")).
Render("❌ Invalid TOTP code. Please try again.")
}

return m.docStyle.Render(
lipgloss.JoinVertical(lipgloss.Left,
title,
"",
instructions,
"",
m.totpInput.View(),
"",
m.renderer.NewStyle().Foreground(lipgloss.Color("241")).Render("Press Enter to submit, Ctrl+C to quit"),
),
)

case stateLoading:
return m.docStyle.Render(fmt.Sprintf("%s Loading Kubernetes resources...", m.spinner.View()))

Expand Down
6 changes: 4 additions & 2 deletions cmd/sshjump/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ type Permission struct {
Username string `yaml:"username"` // Username is the SSH username for the user.
AuthorizedKey string `yaml:"key"` // AuthorizedKey is the authorized key for the user.
Key ssh.PublicKey `yaml:"-"`
Namespaces []Namespace `yaml:"namespaces"` // Namespaces is a list of namespaces the user has access to.
AllowAll bool `yaml:"allowAll"` // allow this user to connect to every detected ports
Namespaces []Namespace `yaml:"namespaces"` // Namespaces is a list of namespaces the user has access to.
AllowAll bool `yaml:"allowAll"` // allow this user to connect to every detected ports
TOTPSecret string `yaml:"totpSecret,omitempty"` // TOTPSecret is the base32 encoded TOTP secret for 2FA.
TOTPVerified bool `yaml:"-"` // TOTPVerified is set to true after successful TOTP verification (runtime only).
}
7 changes: 4 additions & 3 deletions cmd/sshjump/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ const (

// TargetResolver acts as a thread-safe store for the user's selection.
type TargetResolver struct {
mu sync.RWMutex
target string
Resolved chan struct{} // Closed when a target is selected
mu sync.RWMutex
target string
TOTPVerified bool // TOTPVerified is set to true after successful TOTP verification
Resolved chan struct{} // Closed when a target is selected
}

// GetTargetResolver returns the resolver state object for this session.
Expand Down
15 changes: 15 additions & 0 deletions cmd/sshjump/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,21 @@ func main() {
os.Exit(1)
}

// Handle TOTP generation command
if len(os.Args) > 1 && os.Args[1] == "generate-totp" {
secret, err := GenerateTOTPSecret()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating TOTP secret: %v\n", err)
os.Exit(1)
}
fmt.Printf("TOTP Secret (add to config as totpSecret): %s\n", secret)
fmt.Println("\nSetup instructions:")
fmt.Println("1. Add the secret to your user config as 'totpSecret'")
fmt.Println("2. Scan the QR code or manually enter the secret in your authenticator app")
fmt.Println("3. Connect via SSH and enter the TOTP code when prompted")
os.Exit(0)
}

logger := createLogger(envCfg)
keys, err := readPermission(logger, envCfg.ConfigPath)
if err != nil {
Expand Down
Loading