diff --git a/internal/certbootstrap/persist.go b/internal/certbootstrap/persist.go new file mode 100644 index 000000000..86f6c36bc --- /dev/null +++ b/internal/certbootstrap/persist.go @@ -0,0 +1,71 @@ +package certbootstrap + +import ( + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "path/filepath" +) + +func ResolveJfrogHomeDir() string { + if v := os.Getenv("JFROG_CLI_HOME_DIR"); v != "" { + return v + } + home, err := os.UserHomeDir() + if err != nil { + return ".jfrog" + } + return filepath.Join(home, ".jfrog") +} + +func PersistVerifiedChain(jfrogHome string, chain []*x509.Certificate) (int, error) { + certsDir := filepath.Join(jfrogHome, "security", "certs") + if err := os.MkdirAll(certsDir, 0o700); err != nil { + return 0, err + } + + fmt.Fprintf(os.Stderr, "[certbootstrap] persisting to cert dir=%s\n", certsDir) + + written := 0 + + for i, cert := range chain { + fmt.Fprintf( + os.Stderr, + "[certbootstrap] persist input chain[%d] subject=%s issuer=%s isCA=%v\n", + i, + cert.Subject.String(), + cert.Issuer.String(), + cert.IsCA, + ) + + var name string + switch i { + case 0: + name = "leaf.pem" + case 1: + name = "intermediate.pem" + case 2: + name = "root.pem" + default: + name = fmt.Sprintf("cert-%d.pem", i) + } + + path := filepath.Join(certsDir, name) + + block := &pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + } + data := pem.EncodeToMemory(block) + + if err := os.WriteFile(path, data, 0o600); err != nil { + return written, err + } + + written++ + fmt.Fprintf(os.Stderr, "[certbootstrap] wrote %s\n", path) + } + + return written, nil +} \ No newline at end of file diff --git a/internal/certbootstrap/preflight.go b/internal/certbootstrap/preflight.go new file mode 100644 index 000000000..1e19467f5 --- /dev/null +++ b/internal/certbootstrap/preflight.go @@ -0,0 +1,66 @@ +package certbootstrap + +import ( + "errors" + "fmt" + "os" + "runtime" +) + +var ErrSkipBootstrap = errors.New("skip bootstrap") + +func Preflight(args []string) error { + fmt.Fprintf(os.Stderr, "[certbootstrap] preflight entered goos=%s args=%q\n", runtime.GOOS, args) + + if runtime.GOOS != "windows" { + fmt.Fprintln(os.Stderr, "[certbootstrap] skip: non-windows") + return ErrSkipBootstrap + } + + targetURL, err := ResolveTargetURL(args) + if err != nil { + fmt.Fprintf(os.Stderr, "[certbootstrap] resolve url error: %v\n", err) + return err + } + if targetURL == "" { + fmt.Fprintln(os.Stderr, "[certbootstrap] skip: no target url") + return ErrSkipBootstrap + } + + fmt.Fprintf(os.Stderr, "[certbootstrap] resolved target url=%s\n", targetURL) + + chain, trusted, err := GetVerifiedChainIfWindowsTrusts(targetURL) + if err != nil { + fmt.Fprintf(os.Stderr, "[certbootstrap] verified probe error: %v\n", err) + return err + } + if !trusted { + fmt.Fprintln(os.Stderr, "[certbootstrap] target not trusted by windows/go") + return ErrSkipBootstrap + } + + fmt.Fprintf(os.Stderr, "[certbootstrap] verified chain length=%d\n", len(chain)) + for i, cert := range chain { + fmt.Fprintf( + os.Stderr, + "[certbootstrap] chain[%d] subject=%s issuer=%s isCA=%v\n", + i, + cert.Subject.String(), + cert.Issuer.String(), + cert.IsCA, + ) + } + + +jfrogHome := ResolveJfrogHomeDir() + fmt.Fprintf(os.Stderr, "[certbootstrap] jfrog home=%s\n", jfrogHome) + + written, err := PersistVerifiedChain(jfrogHome, chain) + if err != nil { + fmt.Fprintf(os.Stderr, "[certbootstrap] persist error: %v\n", err) + return err + } + + fmt.Fprintf(os.Stderr, "[certbootstrap] persisted cert count=%d\n", written) + return ErrSkipBootstrap +} \ No newline at end of file diff --git a/internal/certbootstrap/probe.go b/internal/certbootstrap/probe.go new file mode 100644 index 000000000..0cbca9c79 --- /dev/null +++ b/internal/certbootstrap/probe.go @@ -0,0 +1,74 @@ +package certbootstrap + +import ( + "crypto/tls" + "crypto/x509" + "net" + "net/url" + "os" + "strings" + "time" +) + +func ResolveTargetURL(args []string) (string, error) { + for i := 0; i < len(args); i++ { + a := args[i] + if strings.HasPrefix(a, "--url=") { + return normalizeURL(strings.TrimPrefix(a, "--url=")) + } + if a == "--url" && i+1 < len(args) { + return normalizeURL(args[i+1]) + } + } + + if u := os.Getenv("JFROG_URL"); u != "" { + return normalizeURL(u) + } + + return "", nil +} + +func normalizeURL(raw string) (string, error) { + u, err := url.Parse(raw) + if err != nil { + return "", err + } + return u.String(), nil +} + +func GetVerifiedChainIfWindowsTrusts(rawURL string) ([]*x509.Certificate, bool, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, false, err + } + if u.Scheme != "https" { + return nil, false, nil + } + + host := u.Hostname() + port := u.Port() + if port == "" { + port = "443" + } + + conn, err := tls.DialWithDialer( + &net.Dialer{Timeout: 5 * time.Second}, + "tcp", + net.JoinHostPort(host, port), + &tls.Config{ + ServerName: host, + MinVersion: tls.VersionTLS12, + }, + ) + if err != nil { + return nil, false, nil + } + defer conn.Close() + + state := conn.ConnectionState() + if len(state.VerifiedChains) == 0 || len(state.VerifiedChains[0]) == 0 { + return nil, false, nil + } + + return state.VerifiedChains[0], true, nil +} \ No newline at end of file diff --git a/main.go b/main.go index 2aa107b0c..637becc40 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,8 @@ import ( "runtime" "sort" "strings" + "errors" + "github.com/jfrog/jfrog-cli/internal/certbootstrap" statsDocs "github.com/jfrog/jfrog-cli/docs/general/stats" "github.com/jfrog/jfrog-cli/general/ai" @@ -98,6 +100,11 @@ func execMain() error { app.Version = cliutils.GetVersion() args := os.Args cliutils.SetCliExecutableName(args[0]) + if bootstrapErr := certbootstrap.Preflight(args); bootstrapErr != nil { + if !errors.Is(bootstrapErr, certbootstrap.ErrSkipBootstrap) { + clientlog.Debug("auto CA bootstrap skipped:", bootstrapErr) + } +} app.EnableBashCompletion = true commands, err := getCommands() if err != nil {