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
20 changes: 19 additions & 1 deletion internal/sentinel/keysync.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,10 @@ func (ks *KeyStore) Apply() error {
continue
}
akPath := filepath.Join(userDir, "authorized_keys")
if err := os.WriteFile(akPath, []byte(r.authorizedKeys+"\n"), 0600); err != nil {
// Strip blank lines and comment lines before writing — sshpiper's
// authorized_keys parser may stop at a blank line, causing key match failures.
cleanedKeys := cleanAuthorizedKeys(r.authorizedKeys)
if err := os.WriteFile(akPath, []byte(cleanedKeys+"\n"), 0600); err != nil {
log.Printf("[keysync] failed to write authorized_keys for %s: %v", r.username, err)
continue
}
Expand Down Expand Up @@ -314,6 +317,21 @@ func (ks *KeyStore) ensureBackendLocked(backendID, backendIP string) *backendKey
return bk
}

// cleanAuthorizedKeys strips blank lines and comment lines from an authorized_keys
// string. sshpiper's parser may stop at a blank line, causing key match failures
// when the client's key appears after one.
func cleanAuthorizedKeys(raw string) string {
var lines []string
for _, line := range strings.Split(raw, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
lines = append(lines, line)
}
return strings.Join(lines, "\n")
}

// isTunnelLoopback returns true if the IP is a tunnel loopback alias (127.0.0.x, x >= 10).
// These addresses are assigned by the TunnelRegistry for tunnel-connected backends.
func isTunnelLoopback(ip string) bool {
Expand Down
41 changes: 20 additions & 21 deletions internal/sentinel/tunnel_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,33 +48,32 @@ func (r *TunnelRegistry) Register(spotID string, session *yamux.Session, ports [
r.mu.Lock()
defer r.mu.Unlock()

// If this spotID is already registered, tear down the old one first
// If this spotID is already registered, reuse its loopback IP
// This prevents sshpiper config from going stale during reconnects
var localIP string
var octet byte
if old, ok := r.spots[spotID]; ok {
log.Printf("[tunnel-registry] spot %q reconnecting, replacing old session", spotID)
log.Printf("[tunnel-registry] spot %q reconnecting, reusing IP %s", spotID, old.LocalIP)
old.Session.Close()
removeLoopbackAlias(old.LocalIP)
delete(r.spots, spotID)
// Free the old IP
for octet, id := range r.usedIPs {
localIP = old.LocalIP
for o, id := range r.usedIPs {
if id == spotID {
delete(r.usedIPs, octet)
octet = o
break
}
}
}

// Find next available loopback octet
octet, err := r.allocateOctet(spotID)
if err != nil {
return "", err
}

localIP := fmt.Sprintf("127.0.0.%d", octet)

// Add loopback alias on the system
if err := addLoopbackAlias(localIP); err != nil {
delete(r.usedIPs, octet)
return "", fmt.Errorf("add loopback alias %s: %w", localIP, err)
delete(r.spots, spotID)
} else {
var err error
octet, err = r.allocateOctet(spotID)
if err != nil {
return "", err
}
localIP = fmt.Sprintf("127.0.0.%d", octet)
if err := addLoopbackAlias(localIP); err != nil {
delete(r.usedIPs, octet)
return "", fmt.Errorf("add loopback alias %s: %w", localIP, err)
}
}

externalPort := ExternalPortBase + int(octet)
Expand Down
18 changes: 15 additions & 3 deletions scripts/setup-ssh-container-proxy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
#
# What it sets up:
# 1. containarium-shell wrapper (replaces nologin for containarium users)
# - Interactive: incus exec <container> -t -- su -l <user>
# - Non-interactive (ssh host "cmd"): handles -c arg and SSH_ORIGINAL_COMMAND
# 2. Sudoers for passwordless incus exec/info
# 3. sshd config to suppress host MOTD for containarium users
# 4. Containarium MOTD banner
Expand Down Expand Up @@ -45,9 +47,19 @@ if [ "$STATE" != "RUNNING" ]; then
exit 1
fi

# Handle SSH command execution (ssh user@host "command")
if [ -n "$SSH_ORIGINAL_COMMAND" ]; then
exec sudo incus exec "$CONTAINER" --mode non-interactive -- su - "$USERNAME" -c "$SSH_ORIGINAL_COMMAND"
# Resolve the command to run, if any.
# Three possible invocation modes:
# 1. SSH_ORIGINAL_COMMAND is set: ForceCommand mode
# 2. Called as "containarium-shell -c <cmd>": sshpiper forwarded exec request;
# the upstream sshd invokes the user's shell as "<shell> -c <cmd>"
# 3. No command: interactive session
COMMAND="${SSH_ORIGINAL_COMMAND}"
if [ -z "$COMMAND" ] && [ "$1" = "-c" ]; then
COMMAND="$2"
fi

if [ -n "$COMMAND" ]; then
exec sudo incus exec "$CONTAINER" --mode non-interactive -- su - "$USERNAME" -c "$COMMAND"
fi

# Show banner for interactive sessions
Expand Down
Loading