diff --git a/examples/shell/curl.sh b/examples/shell/curl.sh new file mode 100755 index 0000000..1e536c3 --- /dev/null +++ b/examples/shell/curl.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +curl "${TERM_TAP_CURL_URL:-https://ipinfo.io:443/json}" diff --git a/internal/cli/run.go b/internal/cli/run.go index 344480c..2964907 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -21,6 +21,7 @@ var stdoutWriter io.Writer = stdioRef{isErr: false} var stderrWriter io.Writer = stdioRef{isErr: true} var startSessionFn = app.StartSession var runTUIFn = tui.Run +var currentGOOS = runtime.GOOS type stdioRef struct { isErr bool @@ -131,7 +132,6 @@ func runCert() { } certPath := ca.CertPath() - quotedCertPath := strconv.Quote(certPath) fmt.Fprintf(stdoutWriter, "Certificate path: %s\n", certPath) if ca.WasCreated() { fmt.Fprintln(stdoutWriter, "Created a new local HTTPS interception CA.") @@ -148,22 +148,33 @@ func runCert() { fmt.Fprintln(stdoutWriter, "System trust store: not trusted") } - if runtime.GOOS != "linux" { - fmt.Fprintln(stdoutWriter, "Install this certificate into your OS or client trust store to inspect HTTPS traffic.") - return - } - - fmt.Fprintln(stdoutWriter) - fmt.Fprintln(stdoutWriter, "Trust instructions (Linux):") - fmt.Fprintln(stdoutWriter, "Debian/Ubuntu:") - fmt.Fprintf(stdoutWriter, " sudo cp %s /usr/local/share/ca-certificates/termtap.crt\n", quotedCertPath) - fmt.Fprintln(stdoutWriter, " sudo update-ca-certificates") - fmt.Fprintln(stdoutWriter, "Fedora/RHEL/CentOS:") - fmt.Fprintf(stdoutWriter, " sudo cp %s /etc/pki/ca-trust/source/anchors/termtap.crt\n", quotedCertPath) - fmt.Fprintln(stdoutWriter, " sudo update-ca-trust") - fmt.Fprintln(stdoutWriter, "Arch:") - fmt.Fprintf(stdoutWriter, " sudo trust anchor %s\n", quotedCertPath) - fmt.Fprintln(stdoutWriter) - fmt.Fprintln(stdoutWriter, "Quick curl test:") - fmt.Fprintf(stdoutWriter, " curl --proxy http://%s --cacert %s https://example.com\n", proxyAddrForPort(defaultProxyPort), quotedCertPath) + printTrustInstructions(certPath) +} + +func printTrustInstructions(certPath string) { + quotedCertPath := strconv.Quote(certPath) + + switch currentGOOS { + case "windows": + fmt.Fprintln(stdoutWriter, "Install this certificate into Windows Trusted Root Certification Authorities.") + fmt.Fprintln(stdoutWriter, "If curl reports Schannel revocation errors, try: curl --ssl-no-revoke --cacert "+quotedCertPath+" https://example.com") + case "darwin": + fmt.Fprintln(stdoutWriter, "Import this certificate into Keychain Access and set it to always trust.") + fmt.Fprintf(stdoutWriter, "Quick curl test: curl --proxy http://%s --cacert %s https://example.com\n", proxyAddrForPort(defaultProxyPort), quotedCertPath) + case "linux": + fmt.Fprintln(stdoutWriter, "Trust instructions (Linux):") + fmt.Fprintln(stdoutWriter, "Debian/Ubuntu:") + fmt.Fprintf(stdoutWriter, " sudo cp %s /usr/local/share/ca-certificates/termtap.crt\n", quotedCertPath) + fmt.Fprintln(stdoutWriter, " sudo update-ca-certificates") + fmt.Fprintln(stdoutWriter, "Fedora/RHEL/CentOS:") + fmt.Fprintf(stdoutWriter, " sudo cp %s /etc/pki/ca-trust/source/anchors/termtap.crt\n", quotedCertPath) + fmt.Fprintln(stdoutWriter, " sudo update-ca-trust") + fmt.Fprintln(stdoutWriter, "Arch:") + fmt.Fprintf(stdoutWriter, " sudo trust anchor %s\n", quotedCertPath) + fmt.Fprintln(stdoutWriter) + fmt.Fprintln(stdoutWriter, "Quick curl test:") + fmt.Fprintf(stdoutWriter, " curl --proxy http://%s --cacert %s https://example.com\n", proxyAddrForPort(defaultProxyPort), quotedCertPath) + default: + fmt.Fprintln(stdoutWriter, "Install this certificate into your OS or client trust store to inspect HTTPS traffic.") + } } diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 21e57d2..dc69f90 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -206,6 +206,8 @@ func TestRunCert_EnsureCAFailureCallsFatalExit(t *testing.T) { func TestRunCertOutputContract(t *testing.T) { configRoot := t.TempDir() t.Setenv("XDG_CONFIG_HOME", configRoot) + origGOOS := currentGOOS + t.Cleanup(func() { currentGOOS = origGOOS }) stdout, _ := captureOutput(t, func() { runCert() @@ -228,6 +230,41 @@ func TestRunCertOutputContract(t *testing.T) { } } +func TestRunCert_WindowsHint(t *testing.T) { + configRoot := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configRoot) + origGOOS := currentGOOS + t.Cleanup(func() { currentGOOS = origGOOS }) + currentGOOS = "windows" + + stdout, _ := captureOutput(t, func() { + runCert() + }) + + if !strings.Contains(stdout, "Windows Trusted Root Certification Authorities") { + t.Fatalf("stdout missing windows trust hint: %q", stdout) + } + if !strings.Contains(stdout, "--ssl-no-revoke") { + t.Fatalf("stdout missing windows curl hint: %q", stdout) + } +} + +func TestRunCert_DarwinHint(t *testing.T) { + configRoot := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configRoot) + origGOOS := currentGOOS + t.Cleanup(func() { currentGOOS = origGOOS }) + currentGOOS = "darwin" + + stdout, _ := captureOutput(t, func() { + runCert() + }) + + if !strings.Contains(stdout, "Keychain Access") { + t.Fatalf("stdout missing macOS trust hint: %q", stdout) + } +} + func TestRunCert_CreatedThenExistingMessage(t *testing.T) { configRoot := t.TempDir() t.Setenv("XDG_CONFIG_HOME", configRoot) diff --git a/internal/proxy/certs.go b/internal/proxy/certs.go index 524074a..f5a6766 100644 --- a/internal/proxy/certs.go +++ b/internal/proxy/certs.go @@ -130,8 +130,7 @@ func (ca *CertificateAuthority) create() error { }, NotBefore: now.Add(-1 * time.Hour), NotAfter: now.Add(caValidFor), - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign | x509.KeyUsageCRLSign, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, BasicConstraintsValid: true, IsCA: true, MaxPathLen: 1, diff --git a/internal/tui/shell_integration_test.go b/internal/tui/shell_integration_test.go new file mode 100644 index 0000000..f97ee54 --- /dev/null +++ b/internal/tui/shell_integration_test.go @@ -0,0 +1,88 @@ +package tui + +import ( + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "termtap.dev/internal/app" + "termtap.dev/internal/model" +) + +func TestShellExampleProducesRequestData(t *testing.T) { + scriptPath := shellExamplePath(t) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, "shell-example-ok") + })) + t.Cleanup(upstream.Close) + + t.Setenv("TERM_TAP_CURL_URL", upstream.URL+"/demo") + + addr := freeTCPAddr(t) + s, err := app.StartSession(model.Command{Name: "sh", Args: []string{scriptPath}}, addr) + if err != nil { + t.Fatalf("StartSession() error = %v", err) + } + t.Cleanup(s.Stop) + + m := NewModel(s.Events, Controls{}) + if next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}); next != nil { + m = next.(Model) + } + + deadline := time.After(6 * time.Second) + for { + select { + case ev := <-s.Events: + next, _ := m.Update(EventMsg{value: ev}) + m = next.(Model) + if len(m.requests) > 0 && !m.requests[0].Pending && m.requests[0].Status == http.StatusOK && string(m.requests[0].ResponseData) == "shell-example-ok" { + if got := string(m.requests[0].ResponseData); got != "shell-example-ok" { + t.Fatalf("response data = %q, want %q", got, "shell-example-ok") + } + s.Stop() + return + } + case <-deadline: + t.Fatalf("timed out waiting for request data; requests=%#v", m.requests) + } + } +} + +func freeTCPAddr(t *testing.T) string { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("net.Listen() error = %v", err) + } + addr := ln.Addr().String() + if err := ln.Close(); err != nil { + t.Fatalf("listener close error = %v", err) + } + return addr +} + +func shellExamplePath(t *testing.T) string { + t.Helper() + + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + + path := filepath.Join(filepath.Dir(file), "..", "..", "examples", "shell", "curl.sh") + if _, err := os.Stat(path); err != nil { + t.Fatalf("stat shell example: %v", err) + } + return path +}