Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .github/workflows/k3s-ca.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
go-version: '1.26'
cache: true
- name: shellcheck
run: |
shellcheck -s sh -S warning pkg/templates/scripts/tn-k3s-ca.sh
- name: go test
run: |
go test ./test/k3s-ca/... ./cmd/...
go test ./test/k3s-ca/... ./cmd/... ./pkg/auth/...
(cd pkg/templates && go test ./...)

docker-matrix:
Expand All @@ -47,7 +47,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
go-version: '1.26'
cache: true
- name: run the harness
env:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
go-version: '1.26'
cache: true
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ Securely connect to your edge devices via Teknoir's proxy infrastructure.
```bash
tnctl ssh [device-name]
```
Or through the WireGuard (ZTNA) tunnel, which the platform can cut off at once if the device is
flagged compromised or fails its health check:
```bash
tnctl ssh --wireguard [device-name]
```
- **Port Forwarding**:
Forward a local port to the device or its network:
```bash
Expand Down
5 changes: 5 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"errors"
"fmt"
"os"
"path/filepath"
Expand Down Expand Up @@ -34,6 +35,10 @@ device management, and namespace-scoped operations.`,
func Execute() {
err := rootCmd.Execute()
if err != nil {
var ee *exitError
if errors.As(err, &ee) {
os.Exit(ee.code)
}
os.Exit(1)
}
}
Expand Down
87 changes: 62 additions & 25 deletions cmd/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,20 @@ import (
"github.com/spf13/cobra"
)

var (
sshWireGuard bool
sshTURNServer string
sshWGHubURL string
)

var sshCmd = &cobra.Command{
Use: "ssh [device-name]",
Short: "SSH into a device",
RunE: func(cmd *cobra.Command, args []string) error {
if !sshWireGuard && (sshTURNServer != "" || sshWGHubURL != "") {
return fmt.Errorf("--turn-server and --wg-hub-url require --wireguard")
}

cfg, err := config.Load()
if err != nil {
return err
Expand Down Expand Up @@ -62,29 +72,22 @@ var sshCmd = &cobra.Command{
return err
}

if !device.Spec.Subresources.Status.RemoteAccess.Active {
return fmt.Errorf("remote access is not enabled for device %s. Please enable it in the Teknoir Console", deviceName)
if sshWireGuard {
// The WireGuard path does not use the SSH tunnel's remote_access
// switch; access is decided by the hub (see ssh_wg.go).
// Errors from here on are runtime failures (a hub denial, ssh's exit
// status), not usage mistakes: don't print the usage text.
cmd.SilenceUsage = true
return runSSHWireGuard(domain, namespace, deviceName, device)
}

// Decode base64 username, password and private key
usernameBytes, err := base64.StdEncoding.DecodeString(device.Spec.Settings.Username)
if err != nil {
return fmt.Errorf("failed to decode username: %w", err)
}
username := string(usernameBytes)

var password string
if device.Spec.Settings.Password != "" {
passwordBytes, err := base64.StdEncoding.DecodeString(device.Spec.Settings.Password)
if err != nil {
return fmt.Errorf("failed to decode password: %w", err)
}
password = string(passwordBytes)
if !device.Spec.Subresources.Status.RemoteAccess.Active {
return fmt.Errorf("remote access is not enabled for device %s. Please enable it in the Teknoir Console", deviceName)
}

privateKey, err := base64.StdEncoding.DecodeString(device.Spec.Settings.RSAPrivate)
username, password, privateKey, err := decodeDeviceLogin(device)
if err != nil {
return fmt.Errorf("failed to decode RSA private key: %w", err)
return err
}

// Create secure temp file for private key
Expand Down Expand Up @@ -123,18 +126,52 @@ var sshCmd = &cobra.Command{
fmt.Printf("DEBUG: Executing command: %s %v\n", sshExec.Path, sshExec.Args)
}

// Print colorful message and password
fmt.Printf("\033[1;32m\n>>> You are now using a remote shell on device: %s\033[0m\n", deviceName)
if password != "" {
fmt.Printf("\033[1;36m>>> SSH Password: %s\033[0m\n\n", password)
} else {
fmt.Println()
}
printRemoteShellBanner(deviceName, password)

return sshExec.Run()
},
}

// decodeDeviceLogin decodes the base64 username, password and RSA private key
// from the device details. password is empty when the device has none.
func decodeDeviceLogin(device *api.BackstageDevice) (username, password string, privateKey []byte, err error) {
usernameBytes, err := base64.StdEncoding.DecodeString(device.Spec.Settings.Username)
if err != nil {
return "", "", nil, fmt.Errorf("failed to decode username: %w", err)
}
username = string(usernameBytes)

if device.Spec.Settings.Password != "" {
passwordBytes, err := base64.StdEncoding.DecodeString(device.Spec.Settings.Password)
if err != nil {
return "", "", nil, fmt.Errorf("failed to decode password: %w", err)
}
password = string(passwordBytes)
}

privateKey, err = base64.StdEncoding.DecodeString(device.Spec.Settings.RSAPrivate)
if err != nil {
return "", "", nil, fmt.Errorf("failed to decode RSA private key: %w", err)
}
return username, password, privateKey, nil
}

// printRemoteShellBanner prints the colorful message and the device password.
func printRemoteShellBanner(deviceName, password string) {
fmt.Printf("\033[1;32m\n>>> You are now using a remote shell on device: %s\033[0m\n", deviceName)
if password != "" {
fmt.Printf("\033[1;36m>>> SSH Password: %s\033[0m\n\n", password)
} else {
fmt.Println()
}
}

func init() {
rootCmd.AddCommand(sshCmd)
sshCmd.Flags().BoolVar(&sshWireGuard, "wireguard", false,
"connect through the WireGuard (ZTNA) tunnel instead of the SSH tunnel")
sshCmd.Flags().StringVar(&sshTURNServer, "turn-server", "",
"with --wireguard: TURN server host:port overriding the hub's (env TNCTL_WG_TURN_SERVER)")
sshCmd.Flags().StringVar(&sshWGHubURL, "wg-hub-url", "",
"with --wireguard: WireGuard hub URL (default https://wg.<domain>, env TNCTL_WG_HUB)")
}
187 changes: 187 additions & 0 deletions cmd/ssh_wg.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package cmd

import (
"errors"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"syscall"

"github.com/teknoir/cli/pkg/api"
"github.com/teknoir/cli/pkg/config"
wgapi "github.com/teknoir/wg-tunnel/pkg/api"
)

// proxyArgs are the arguments of the `tnctl wg-proxy` ProxyCommand.
type proxyArgs struct {
Exe string // absolute path of this tnctl executable
ConfigFile string // --config, only when the user set it
Domain string
Namespace string
Device string
Port int
KnownHostsFile string
TURNServer string // optional
HubURL string
}

var shellSafe = regexp.MustCompile(`^[A-Za-z0-9_@+=:,./-]+$`)

// quoteArg quotes one word for the shell ssh runs a ProxyCommand with: sh on
// unix, cmd.exe on windows. It does NOT escape '%' (see buildProxyCommand).
func quoteArg(s, goos string) string {
if s != "" && shellSafe.MatchString(s) {
return s
}
if goos == "windows" {
return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"`
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

// buildProxyCommand returns the value of ssh's ProxyCommand option. ssh
// expands %-tokens (%h, %p, ...) in it, so every literal '%' is doubled.
func buildProxyCommand(a proxyArgs, goos string) string {
words := []string{a.Exe, "wg-proxy"}
if a.ConfigFile != "" {
words = append(words, "--config", a.ConfigFile)
}
words = append(words,
"--domain", a.Domain,
"--namespace", a.Namespace,
"--device", a.Device,
"--port", strconv.Itoa(a.Port),
"--known-hosts-file", a.KnownHostsFile,
)
if a.TURNServer != "" {
words = append(words, "--turn-server", a.TURNServer)
}
words = append(words, "--hub-url", a.HubURL)

quoted := make([]string, len(words))
for i, w := range words {
quoted[i] = quoteArg(w, goos)
}
return strings.ReplaceAll(strings.Join(quoted, " "), "%", "%%")
}

// buildSSHArgs returns the argument list for the system ssh. The device host
// key is pinned: wg-proxy writes the key the hub vouches for to knownHosts
// before ssh sees any byte, and ssh refuses any other key.
func buildSSHArgs(proxyCommand, knownHosts, hostKeyAlias, keyFile, username, deviceName string) []string {
return []string{
"-o", "ProxyCommand=" + proxyCommand,
"-o", "UserKnownHostsFile=" + knownHosts,
"-o", "StrictHostKeyChecking=yes",
"-o", "HostKeyAlias=" + hostKeyAlias,
"-o", "ServerAliveInterval=30",
"-o", "IdentitiesOnly=yes",
"-i", keyFile,
username + "@" + deviceName,
}
}

// validLoginName guards the user@host argument against option injection.
func validLoginName(u string) error {
if u == "" || strings.HasPrefix(u, "-") || strings.ContainsAny(u, "@ \t\r\n\x00") {
return errors.New("the device login name is not usable for ssh")
}
return nil
}

// runSSHWireGuard is `tnctl ssh --wireguard`: the same device login as the SSH
// tunnel path, carried by a WireGuard session that wg-proxy brokers.
func runSSHWireGuard(domain, ns, deviceName string, dev *api.BackstageDevice) error {
if err := validateWGTarget(ns, deviceName, 22); err != nil {
return err
}
username, password, privateKey, err := decodeDeviceLogin(dev)
if err != nil {
return err
}
if err := validLoginName(username); err != nil {
return err
}

hubURL := resolveWGOption(sshWGHubURL, envWGHub, defaultHubURL(domain))
if err := validateHubURL(hubURL); err != nil {
return err
}
if err := validateHubHost(hubURL, domain); err != nil {
return err
}
turn := resolveWGOption(sshTURNServer, envWGTurnServer, "")

exe, err := os.Executable()
if err != nil {
return fmt.Errorf("cannot locate the tnctl executable: %w", err)
}
if exe, err = filepath.Abs(exe); err != nil {
return fmt.Errorf("cannot locate the tnctl executable: %w", err)
}

// 0700 directory with the private key (0600) and the known_hosts file that
// wg-proxy fills in; removed when ssh ends.
tmpDir, err := os.MkdirTemp("", "tnctl-wg-*")
if err != nil {
return fmt.Errorf("failed to create temporary directory: %w", err)
}
defer os.RemoveAll(tmpDir)

keyFile := filepath.Join(tmpDir, "id")
if err := os.WriteFile(keyFile, privateKey, 0600); err != nil {
return fmt.Errorf("failed to write private key to temporary file: %w", err)
}
knownHosts := filepath.Join(tmpDir, "known_hosts")

proxy := buildProxyCommand(proxyArgs{
Exe: exe, ConfigFile: cfgFile, Domain: domain, Namespace: ns, Device: deviceName, Port: 22,
KnownHostsFile: knownHosts, TURNServer: turn, HubURL: hubURL,
}, runtime.GOOS)

sshExec := exec.Command("ssh",
buildSSHArgs(proxy, knownHosts, wgapi.KnownHostsAlias(ns, deviceName), keyFile, username, deviceName)...)
sshExec.Stdin = os.Stdin
sshExec.Stdout = os.Stdout
sshExec.Stderr = os.Stderr

if config.Debug {
fmt.Printf("DEBUG: Executing command: %s %v\n", sshExec.Path, sshExec.Args)
}

printRemoteShellBanner(deviceName, password)

return runWithSignalForwarding(sshExec)
}

// runWithSignalForwarding runs cmd, forwarding SIGINT/SIGTERM/SIGHUP to it and
// waiting for it, so the caller's deferred cleanup (the private key directory)
// still runs when tnctl is terminated or its terminal closes.
func runWithSignalForwarding(cmd *exec.Cmd) error {
sigs := make(chan os.Signal, 4)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
defer signal.Stop(sigs)
if err := cmd.Start(); err != nil {
return err
}
done := make(chan struct{})
go func() {
for {
select {
case sig := <-sigs:
_ = cmd.Process.Signal(sig)
case <-done:
return
}
}
}()
err := cmd.Wait()
close(done)
return err
}
Loading
Loading