Commit eb0002c
Eric Bower
·
2026-08-15 13:31:36 -0400 EDT
parent f7542d3
fix: debug bridge
4 files changed,
+110,
-32
+13,
-2
1@@ -10,6 +10,10 @@ inputs:
2 description: "SSH private key registered with pico.sh for authenticated pipe.pico.sh debug relay access"
3 required: false
4 default: ""
5+ pico_ssh_cert:
6+ description: "SSH public certificate key for authenticating with pico.sh debug relay"
7+ required: false
8+ default: ""
9 debug_on_fail:
10 description: "Automatically open an interactive pipe.pico.sh debug bridge if a job fails (true, false, auto)"
11 required: false
12@@ -34,7 +38,7 @@ inputs:
13 runs:
14 using: "composite"
15 steps:
16- - name: Set up SSH key for pico.sh debug relay
17+ - name: Set up SSH key and certificate for pico.sh debug relay
18 if: inputs.pico_ssh_key != ''
19 shell: bash
20 run: |
21@@ -42,6 +46,10 @@ runs:
22 chmod 700 ~/.ssh
23 echo "${{ inputs.pico_ssh_key }}" > ~/.ssh/id_pici_debug
24 chmod 600 ~/.ssh/id_pici_debug
25+ if [ -n "${{ inputs.pico_ssh_cert }}" ]; then
26+ echo "${{ inputs.pico_ssh_cert }}" > ~/.ssh/id_pici_debug-cert.pub
27+ chmod 644 ~/.ssh/id_pici_debug-cert.pub
28+ fi
29
30 - name: Install zmx
31 shell: bash
32@@ -82,10 +90,13 @@ runs:
33 FLAGS="$FLAGS --summary-file $GITHUB_STEP_SUMMARY"
34 fi
35
36- # Pass SSH key if configured
37+ # Pass SSH key & certificate if configured
38 if [ -f "$HOME/.ssh/id_pici_debug" ]; then
39 FLAGS="$FLAGS -pk $HOME/.ssh/id_pici_debug"
40 fi
41+ if [ -f "$HOME/.ssh/id_pici_debug-cert.pub" ]; then
42+ FLAGS="$FLAGS -ck $HOME/.ssh/id_pici_debug-cert.pub"
43+ fi
44
45 echo "==> Executing: pici run $FLAGS"
46 pici run $FLAGS \
+64,
-24
1@@ -8,8 +8,10 @@ import (
2 "net"
3 "os"
4 "os/exec"
5+ "os/signal"
6 "path/filepath"
7 "strings"
8+ "syscall"
9 "time"
10
11 "github.com/creack/pty"
12@@ -75,13 +77,34 @@ func scrubEnvironment(environ []string) []string {
13 return cleaned
14 }
15
16+// loadCertSigner parses the certificate file at certPath and wraps the key signer.
17+func loadCertSigner(signer ssh.Signer, certPath string) (ssh.Signer, error) {
18+ certBytes, err := os.ReadFile(certPath)
19+ if err != nil {
20+ return nil, fmt.Errorf("read cert file (%s): %w", certPath, err)
21+ }
22+ pubKey, _, _, _, err := ssh.ParseAuthorizedKey(certBytes)
23+ if err != nil {
24+ return nil, fmt.Errorf("parse cert (%s): %w", certPath, err)
25+ }
26+ cert, ok := pubKey.(*ssh.Certificate)
27+ if !ok {
28+ return nil, fmt.Errorf("public key at %s is not an SSH certificate", certPath)
29+ }
30+ certSigner, err := ssh.NewCertSigner(cert, signer)
31+ if err != nil {
32+ return nil, fmt.Errorf("create cert signer: %w", err)
33+ }
34+ return certSigner, nil
35+}
36+
37 // findSSHAuthMethod discovers an available SSH authentication method from:
38-// 1. Specified keyLocation (and optional certificateLocation)
39-// 2. PICO_SSH_KEY environment variable (file path or raw key content)
40+// 1. Explicit keyLocation (and optional explicit certificateLocation)
41+// 2. Explicit PICO_SSH_KEY environment variable (file path or raw key content)
42 // 3. Active SSH agent ($SSH_AUTH_SOCK)
43 // 4. Default user SSH keys (~/.ssh/id_ed25519, ~/.ssh/id_rsa)
44 func findSSHAuthMethod(keyLocation string, certificateLocation string) (ssh.AuthMethod, error) {
45- // 1. Specified key path
46+ // 1. Specified key path (-pk)
47 if keyLocation != "" {
48 keyBytes, err := os.ReadFile(keyLocation)
49 if err != nil {
50@@ -92,21 +115,9 @@ func findSSHAuthMethod(keyLocation string, certificateLocation string) (ssh.Auth
51 return nil, fmt.Errorf("parse private key: %w", err)
52 }
53 if certificateLocation != "" {
54- certBytes, err := os.ReadFile(certificateLocation)
55- if err != nil {
56- return nil, fmt.Errorf("read cert file: %w", err)
57- }
58- pubKey, _, _, _, err := ssh.ParseAuthorizedKey(certBytes)
59+ certSigner, err := loadCertSigner(signer, certificateLocation)
60 if err != nil {
61- return nil, fmt.Errorf("parse cert: %w", err)
62- }
63- cert, ok := pubKey.(*ssh.Certificate)
64- if !ok {
65- return nil, fmt.Errorf("public key is not a certificate")
66- }
67- certSigner, err := ssh.NewCertSigner(cert, signer)
68- if err != nil {
69- return nil, fmt.Errorf("new cert signer: %w", err)
70+ return nil, err
71 }
72 return ssh.PublicKeys(certSigner), nil
73 }
74@@ -123,6 +134,13 @@ func findSSHAuthMethod(keyLocation string, certificateLocation string) (ssh.Auth
75 }
76 signer, err := ssh.ParsePrivateKey(keyBytes)
77 if err == nil {
78+ if certificateLocation != "" {
79+ certSigner, err := loadCertSigner(signer, certificateLocation)
80+ if err != nil {
81+ return nil, err
82+ }
83+ return ssh.PublicKeys(certSigner), nil
84+ }
85 return ssh.PublicKeys(signer), nil
86 }
87 }
88@@ -135,12 +153,19 @@ func findSSHAuthMethod(keyLocation string, certificateLocation string) (ssh.Auth
89 }
90 }
91
92- // 4. Standard ~/.ssh/ files
93+ // 4. Standard ~/.ssh/ files (default fallback when no key is explicitly passed)
94 if homeDir, err := os.UserHomeDir(); err == nil {
95 for _, keyName := range []string{"id_ed25519", "id_rsa"} {
96- p := filepath.Join(homeDir, ".ssh", keyName)
97- if keyBytes, err := os.ReadFile(p); err == nil {
98+ keyPath := filepath.Join(homeDir, ".ssh", keyName)
99+ if keyBytes, err := os.ReadFile(keyPath); err == nil {
100 if signer, err := ssh.ParsePrivateKey(keyBytes); err == nil {
101+ if certificateLocation != "" {
102+ certSigner, err := loadCertSigner(signer, certificateLocation)
103+ if err != nil {
104+ return nil, err
105+ }
106+ return ssh.PublicKeys(certSigner), nil
107+ }
108 return ssh.PublicKeys(signer), nil
109 }
110 }
111@@ -182,21 +207,30 @@ func startDebugBridge(ctx context.Context, cfg DebugBridgeConfig) error {
112 User: "pici",
113 Auth: []ssh.AuthMethod{auth},
114 HostKeyCallback: ssh.InsecureIgnoreHostKey(),
115- Timeout: 10 * time.Second,
116+ Timeout: 5 * time.Second,
117 }
118
119 fmt.Fprintf(cfg.Stdout, "š connecting debug bridge to %s (topic: %s)...\n", cfg.Host, cfg.Topic) //nolint:errcheck
120- client, err := ssh.Dial("tcp", cfg.Host, sshConfig)
121+
122+ dialer := net.Dialer{Timeout: 5 * time.Second}
123+ conn, err := dialer.DialContext(ctx, "tcp", cfg.Host)
124 if err != nil {
125 return fmt.Errorf("dial ssh (%s): %w", cfg.Host, err)
126 }
127- defer client.Close()
128+ defer func() { _ = conn.Close() }()
129+
130+ sshConn, chans, reqs, err := ssh.NewClientConn(conn, cfg.Host, sshConfig)
131+ if err != nil {
132+ return fmt.Errorf("ssh handshake (%s): %w", cfg.Host, err)
133+ }
134+ client := ssh.NewClient(sshConn, chans, reqs)
135+ defer func() { _ = client.Close() }()
136
137 session, err := client.NewSession()
138 if err != nil {
139 return fmt.Errorf("ssh session: %w", err)
140 }
141- defer session.Close()
142+ defer func() { _ = session.Close() }()
143
144 sshStdin, err := session.StdinPipe()
145 if err != nil {
146@@ -243,6 +277,10 @@ func startDebugBridge(ctx context.Context, cfg DebugBridgeConfig) error {
147 ctxWithTimeout, cancel := context.WithTimeout(ctx, cfg.Timeout)
148 defer cancel()
149
150+ sigCh := make(chan os.Signal, 1)
151+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
152+ defer signal.Stop(sigCh)
153+
154 errCh := make(chan error, 3)
155
156 go func() {
157@@ -262,6 +300,8 @@ func startDebugBridge(ctx context.Context, cfg DebugBridgeConfig) error {
158 select {
159 case <-ctxWithTimeout.Done():
160 fmt.Fprintf(cfg.Stdout, "\nā° debug session timed out after %s\n", cfg.Timeout) //nolint:errcheck
161+ case <-sigCh:
162+ fmt.Fprintf(cfg.Stdout, "\nā¹ debug session aborted by signal\n") //nolint:errcheck
163 case <-errCh:
164 fmt.Fprintf(cfg.Stdout, "\nš debug session ended\n") //nolint:errcheck
165 }
M
main.go
+6,
-6
1@@ -1259,13 +1259,13 @@ func writeSummaryMarkdown(filePath string, repoName, jobID string, jobSessions [
2 statusIcon := map[string]string{"success": "ā
", "failed": "ā"}[status]
3
4 var sb strings.Builder
5- sb.WriteString(fmt.Sprintf("### Pici CI Summary: `%s` (Job `%s`)\n\n", repoName, jobID))
6- sb.WriteString(fmt.Sprintf("**Status:** %s %s | **Exit Code:** `%d` | **Duration:** `%s`\n\n", statusIcon, strings.ToUpper(status), exitCode, duration))
7+ fmt.Fprintf(&sb, "### Pici CI Summary: `%s` (Job `%s`)\n\n", repoName, jobID)
8+ fmt.Fprintf(&sb, "**Status:** %s %s | **Exit Code:** `%d` | **Duration:** `%s`\n\n", statusIcon, strings.ToUpper(status), exitCode, duration)
9
10 if debugActive && debugTopic != "" {
11 sb.WriteString("### š§ Remote Debug Active\n\n")
12 sb.WriteString("To attach to the interactive shell session for this job:\n\n")
13- sb.WriteString(fmt.Sprintf("```bash\nssh -t pipe.pico.sh pipe %s\n```\n\n", debugTopic))
14+ fmt.Fprintf(&sb, "```bash\nssh -t pipe.pico.sh pipe %s\n```\n\n", debugTopic)
15 }
16
17 sb.WriteString("| Session | Status | Exit Code | Duration |\n")
18@@ -1289,7 +1289,7 @@ func writeSummaryMarkdown(filePath string, repoName, jobID string, jobSessions [
19 }
20 sIcon = map[string]string{"running": "š", "success": "ā
", "failed": "ā"}[sStatus]
21 }
22- sb.WriteString(fmt.Sprintf("| `%s` | %s %s | `%s` | %s |\n", s.Short, sIcon, sStatus, sExit, sDur))
23+ fmt.Fprintf(&sb, "| `%s` | %s %s | `%s` | %s |\n", s.Short, sIcon, sStatus, sExit, sDur)
24 }
25 sb.WriteString("\n")
26
27@@ -1308,14 +1308,14 @@ func writeSummaryMarkdown(filePath string, repoName, jobID string, jobSessions [
28 if err != nil {
29 history = fmt.Sprintf("(history unavailable: %v)", err)
30 }
31- sb.WriteString(fmt.Sprintf("<details><summary><b>%s</b> (exit %s)</summary>\n\n```\n%s\n```\n\n</details>\n\n", s.Short, state.exitCode, strings.TrimSpace(history)))
32+ fmt.Fprintf(&sb, "<details><summary><b>%s</b> (exit %s)</summary>\n\n```\n%s\n```\n\n</details>\n\n", s.Short, state.exitCode, strings.TrimSpace(history))
33 }
34
35 f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
36 if err != nil {
37 return fmt.Errorf("open summary file: %w", err)
38 }
39- defer f.Close()
40+ defer func() { _ = f.Close() }()
41
42 if _, err := f.WriteString(sb.String()); err != nil {
43 return fmt.Errorf("write summary file: %w", err)
+27,
-0
1@@ -1469,3 +1469,30 @@ func TestScrubEnvironment(t *testing.T) {
2 }
3 }
4 }
5+
6+func TestFindSSHAuthMethod_ExplicitKeyAndCert(t *testing.T) {
7+ tempDir := t.TempDir()
8+ keyPath := filepath.Join(tempDir, "id_ed25519")
9+
10+ // Generate a temporary ED25519 key
11+ cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-N", "", "-f", keyPath)
12+ if err := cmd.Run(); err != nil {
13+ t.Skipf("ssh-keygen not available or failed: %v", err)
14+ }
15+
16+ // Test 1: Explicit key path
17+ auth, err := findSSHAuthMethod(keyPath, "")
18+ if err != nil {
19+ t.Fatalf("findSSHAuthMethod failed: %v", err)
20+ }
21+ if auth == nil {
22+ t.Fatal("expected non-nil auth method")
23+ }
24+
25+ // Test 2: Non-existent cert path explicitly specified returns error
26+ _, err = findSSHAuthMethod(keyPath, filepath.Join(tempDir, "nonexistent-cert.pub"))
27+ if err == nil {
28+ t.Fatal("expected error when non-existent certificate is explicitly specified")
29+ }
30+}
31+