From 7530a8183a78704d9ca7ab726b03f16c8300d156 Mon Sep 17 00:00:00 2001 From: geemus Date: Fri, 16 Feb 2024 14:37:47 -0600 Subject: [PATCH] v0.0.8: add lcl command, misc improvements --- anchorcli/category.go | 167 +++++++++++++++++ api/api.go | 251 +++++++++++++++++++++++-- api/apitest/apitest.go | 24 ++- api/openapi.gen.go | 270 +++++++++++++++++++++++++-- api/util.go | 5 + auth/models/signin.go | 107 +++++++++++ auth/models/signout.go | 33 ++++ auth/signin.go | 214 +++++++++++++-------- auth/signout.go | 22 ++- auth/whoami.go | 8 +- auth/whoami_test.go | 4 +- cert/models/provision.go | 61 ++++++ cert/provision.go | 83 +++++++++ cli.go | 33 +++- cmd/anchor/.goreleaser.yaml | 9 +- cmd/anchor/main.go | 78 ++++++-- command.go | 43 ++++- detection/detection.go | 110 +++++++++++ detection/detection_test.go | 72 +++++++ detection/filesystem.go | 110 +++++++++++ detection/filesystem_test.go | 232 +++++++++++++++++++++++ detection/framework.go | 41 ++++ detection/framework_test.go | 108 +++++++++++ detection/languages.go | 37 ++++ detection/package_managers.go | 32 ++++ diagnostic/server.go | 145 ++++++++++++++ diagnostic/server_test.go | 104 +++++++++++ ext509/anchor.go | 95 ++++++++++ ext509/oid/oid.go | 8 + go.mod | 29 ++- go.sum | 64 +++++-- internal/must/x509.go | 134 +++++++++++++ lcl/detect.go | 139 ++++++++++++++ lcl/diagnostic.go | 150 +++++++++++++++ lcl/lcl.go | 202 ++++++++++++++++++++ lcl/lcl_test.go | 286 ++++++++++++++++++++++++++++ lcl/models/detect.go | 250 +++++++++++++++++++++++++ lcl/models/diagnostic.go | 86 +++++++++ lcl/models/lcl.go | 186 ++++++++++++++++++ lcl/provision.go | 74 ++++++++ lcl/provision_test.go | 72 +++++++ trust/audit.go | 146 +++++++++++++++ trust/audit_test.go | 145 ++++++++++++++ trust/clean.go | 124 ++++++++++++ trust/models/clean.go | 146 +++++++++++++++ trust/models/sync.go | 168 +++++++++++++++++ trust/models/trust.go | 20 ++ trust/sync.go | 110 +++++++++++ trust/trust.go | 332 +++++++++++++++++++++++++-------- trust/trust_test.go | 53 ++++-- truststore/audit.go | 139 ++++++++++++++ truststore/audit_test.go | 80 ++++++++ truststore/brew.go | 78 ++++++++ truststore/brew_test.go | 73 +------- truststore/errors.go | 1 + truststore/fs.go | 27 ++- truststore/mock.go | 35 ++++ truststore/mock_test.go | 5 + truststore/nss.go | 96 ++++++++++ truststore/platform.go | 18 ++ truststore/platform_darwin.go | 66 ++++++- truststore/platform_linux.go | 38 ++++ truststore/platform_windows.go | 9 + truststore/truststore.go | 47 ++--- truststore/truststore_test.go | 147 +++++++++++++++ ui/driver.go | 132 +++++++++++++ ui/styles.go | 99 ++++++++++ ui/uitest/uitest.go | 32 ++++ 68 files changed, 6177 insertions(+), 367 deletions(-) create mode 100644 anchorcli/category.go create mode 100644 api/util.go create mode 100644 auth/models/signin.go create mode 100644 auth/models/signout.go create mode 100644 cert/models/provision.go create mode 100644 cert/provision.go create mode 100644 detection/detection.go create mode 100644 detection/detection_test.go create mode 100644 detection/filesystem.go create mode 100644 detection/filesystem_test.go create mode 100644 detection/framework.go create mode 100644 detection/framework_test.go create mode 100644 detection/languages.go create mode 100644 detection/package_managers.go create mode 100644 diagnostic/server.go create mode 100644 diagnostic/server_test.go create mode 100644 ext509/anchor.go create mode 100644 ext509/oid/oid.go create mode 100644 internal/must/x509.go create mode 100644 lcl/detect.go create mode 100644 lcl/diagnostic.go create mode 100644 lcl/lcl.go create mode 100644 lcl/lcl_test.go create mode 100644 lcl/models/detect.go create mode 100644 lcl/models/diagnostic.go create mode 100644 lcl/models/lcl.go create mode 100644 lcl/provision.go create mode 100644 lcl/provision_test.go create mode 100644 trust/audit.go create mode 100644 trust/audit_test.go create mode 100644 trust/clean.go create mode 100644 trust/models/clean.go create mode 100644 trust/models/sync.go create mode 100644 trust/models/trust.go create mode 100644 trust/sync.go create mode 100644 truststore/audit.go create mode 100644 truststore/audit_test.go create mode 100644 truststore/mock.go create mode 100644 truststore/mock_test.go create mode 100644 truststore/truststore_test.go create mode 100644 ui/driver.go create mode 100644 ui/styles.go create mode 100644 ui/uitest/uitest.go diff --git a/anchorcli/category.go b/anchorcli/category.go new file mode 100644 index 0000000..de536c5 --- /dev/null +++ b/anchorcli/category.go @@ -0,0 +1,167 @@ +package anchorcli + +const ( + Deb PackageFormat = "deb" + Gem PackageFormat = "gem" + GoMod PackageFormat = "gomod" + NPM PackageFormat = "npm" + SDist PackageFormat = "sdist" +) + +const ( + SectionApplication Section = "application" + SectionWebServer Section = "web_server" + SectionDatabase Section = "database" + SectionSystem Section = "system" +) + +type Section string + +func (s Section) String() string { return string(s) } + +type PackageFormat string + +func (p PackageFormat) String() string { return string(p) } + +type Category struct { + ID int `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + Glyph string `json:"glyph"` + Section Section `json:"section"` + PkgFormat PackageFormat `json:"pkgFormat"` +} + +func (c Category) String() string { return string(c.Name) } + +// Languages +var CategoryCustom = &Category{ + ID: 0, + Key: "custom", + Name: "Custom", + Description: "A custom service", + Glyph: "code", +} + +var CategoryGo = &Category{ + ID: 1, + Key: "go", + Name: "Go", + Description: "A Go Application", + Glyph: "language-go", + Section: SectionApplication, + PkgFormat: GoMod, +} + +var CategoryJavascript = &Category{ + ID: 2, + Key: "javascript", + Name: "Javascript", + Description: "A JavaScript Application", + Section: SectionApplication, + Glyph: "language-javascript", + PkgFormat: NPM, +} + +var CategoryPython = &Category{ + ID: 3, + Key: "python", + Name: "Python", + Description: "A Python Application", + Section: SectionApplication, + Glyph: "language-python", + PkgFormat: SDist, +} + +var CategoryRuby = &Category{ + ID: 4, + Key: "ruby", + Name: "Ruby", + Description: "A Ruby Application", + Section: SectionApplication, + Glyph: "language-ruby", + PkgFormat: Gem, +} + +// Web Servers +var CategoryApache = &Category{ + ID: 5, + Key: "apache", + Name: "Apache", + Description: "An Apache Web Server", + Section: SectionWebServer, +} + +var CategoryCaddy = &Category{ + ID: 6, + Key: "caddy", + Name: "Caddy", + Description: "A Caddy Web Server", + Section: SectionWebServer, + Glyph: "caddy-logo", +} + +var CategoryNginx = &Category{ + ID: 7, + Key: "nginx", + Name: "Nginx", + Description: "An Nginx Web Server", + Section: SectionWebServer, +} + +// Databases +var CategoryMonogoDB = &Category{ + ID: 8, + Key: "mongodb", + Name: "MongoDB", + Description: "A MongoDB Database", + Section: SectionDatabase, + Glyph: "server-2", +} + +var CategoryMySQL = &Category{ + ID: 9, + Key: "mysql", + Name: "MySQL", + Description: "A MySQL Database", + Section: SectionDatabase, + Glyph: "server-2", +} + +var CategoryPostgreSQL = &Category{ + ID: 10, + Key: "postgresql", + Name: "PostgreSQL", + Description: "A PostgreSQL Database", + Section: SectionDatabase, + Glyph: "server-2", +} + +// Systems/Browsers +var CategoryLocalhost = &Category{ + ID: 11, + Key: "localhost", + Name: "System", + Description: "A lcl.host System", + Section: SectionSystem, + Glyph: "terminal", +} + +var CategoryDebian = &Category{ + ID: 12, + Key: "debian", + Name: "Debian/Ubuntu", + Description: "A Debian/Ubuntu System", + Section: SectionSystem, + Glyph: "debian-logo", + PkgFormat: Deb, +} + +var CategoryDiagnostic = &Category{ + ID: 13, + Key: "diagnostic", + Name: "lcl.host Diagnostic", + Description: "lcl.host Diagnostic System", + Glyph: "code", +} diff --git a/api/api.go b/api/api.go index 4c424ce..77f5cd5 100644 --- a/api/api.go +++ b/api/api.go @@ -3,6 +3,9 @@ package api //go:generate go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen --package=api -generate=types -o ./openapi.gen.go ../../config/openapi.yml import ( + "bytes" + "context" + "encoding/json" "errors" "fmt" "mime" @@ -19,15 +22,31 @@ var ( ErrSignedOut = errors.New("sign in required") ) -// TODO: maybe make this return a real client, not an http.Client -func Client(cfg *cli.Config) (*http.Client, error) { - client := &http.Client{ - Transport: urlRewriter{ - RoundTripper: responseChecker{ - RoundTripper: new(http.Transport), +// NB: can't call this Client since the name is already taken by an openapi +// generated type. It's more like a session anyways, since it caches some +// current user info. + +type Session struct { + *http.Client + + cfg *cli.Config + + userInfo *Root +} + +// TODO: rename to NewSession +func NewClient(cfg *cli.Config) (*Session, error) { + anc := &Session{ + Client: &http.Client{ + Transport: urlRewriter{ + RoundTripper: responseChecker{ + RoundTripper: new(http.Transport), + }, + URL: cfg.API.URL, }, - URL: cfg.API.URL, }, + + cfg: cfg, } apiToken := cfg.API.Token @@ -39,7 +58,7 @@ func Client(cfg *cli.Config) (*http.Client, error) { ) if apiToken, err = kr.Get(keyring.APIToken); err == keyring.ErrNotFound { - return client, ErrSignedOut + return anc, ErrSignedOut } else if err != nil { return nil, fmt.Errorf("reading PAT token from keyring failed: %w", err) } @@ -48,12 +67,215 @@ func Client(cfg *cli.Config) (*http.Client, error) { } } - client.Transport = basicAuther{ - RoundTripper: client.Transport, + anc.Client.Transport = basicAuther{ + RoundTripper: anc.Client.Transport, PAT: apiToken, } - return client, nil + return anc, nil +} + +func attachServicePath(orgSlug, serviceSlug string) string { + return "/orgs/" + url.QueryEscape(orgSlug) + "/services/" + url.QueryEscape(serviceSlug) + "/actions/attach" +} + +func (s *Session) AttachService(ctx context.Context, chainSlug string, domains []string, orgSlug, realmSlug, serviceSlug string) (*ServicesXtach200, error) { + attachInput := AttachOrgServiceJSONRequestBody{ + Domains: domains, + } + attachInput.Relationships.Chain.Slug = chainSlug + attachInput.Relationships.Realm.Slug = realmSlug + + var attachOutput ServicesXtach200 + if err := s.post(ctx, attachServicePath(orgSlug, serviceSlug), attachInput, &attachOutput); err != nil { + return nil, err + } + return &attachOutput, nil +} + +func (s *Session) CreatePATToken(ctx context.Context, deviceCode string) (string, error) { + reqBody := CreateCliTokenJSONRequestBody{ + DeviceCode: deviceCode, + } + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(reqBody); err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, "POST", "/cli/pat-tokens", &buf) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + + res, err := s.Do(req) + if err != nil { + return "", err + } + + switch res.StatusCode { + case http.StatusOK: + var patTokens *AuthCliPatTokensResponse + if err = json.NewDecoder(res.Body).Decode(&patTokens); err != nil { + return "", err + } + return patTokens.PatToken, nil + case http.StatusBadRequest: + var errorsRes *Error + if err = json.NewDecoder(res.Body).Decode(&errorsRes); err != nil { + return "", err + } + switch errorsRes.Type { + case "urn:anchordev:api:cli-auth:authorization-pending": + return "", nil + case "urn:anchordev:api:cli-auth:expired-device-code": + return "", fmt.Errorf("Your authorization request has expired, please try again.") + case "urn:anchordev:api:cli-auth:incorrect-device-code": + return "", fmt.Errorf("Your authorization request was not found, please try again.") + default: + return "", fmt.Errorf("unexpected error: %s", errorsRes.Detail) + } + default: + return "", fmt.Errorf("unexpected response code: %d", res.StatusCode) + } +} + +func (s *Session) CreateEAB(ctx context.Context, chainSlug, orgSlug, realmSlug, serviceSlug, subCASlug string) (*Eab, error) { + var eabInput CreateEabTokenJSONRequestBody + eabInput.Relationships.Chain.Slug = chainSlug + eabInput.Relationships.Organization.Slug = orgSlug + eabInput.Relationships.Realm.Slug = realmSlug + eabInput.Relationships.Service.Slug = &serviceSlug + eabInput.Relationships.SubCa.Slug = subCASlug + + var eabOutput Eab + if err := s.post(ctx, "/acme/eab-tokens", eabInput, &eabOutput); err != nil { + return nil, err + } + return &eabOutput, nil +} + +func (s *Session) CreateService(ctx context.Context, orgSlug, serverType, serviceSlug string) (*Service, error) { + serviceInput := CreateServiceJSONRequestBody{ + Name: serviceSlug, + ServerType: serverType, + } + serviceInput.Relationships.Organization.Slug = orgSlug + + var serviceOutput Service + if err := s.post(ctx, "/services", serviceInput, &serviceOutput); err != nil { + return nil, err + } + return &serviceOutput, nil +} + +func fetchCredentialsPath(orgSlug, realmSlug string) string { + return "/orgs/" + url.QueryEscape(orgSlug) + "/realms/" + url.QueryEscape(realmSlug) + "/x509/credentials" +} + +func (s *Session) FetchCredentials(ctx context.Context, orgSlug, realmSlug string) ([]Credential, error) { + var creds struct { + Items []Credential `json:"items,omitempty"` + } + if err := s.get(ctx, fetchCredentialsPath(orgSlug, realmSlug), &creds); err != nil { + return nil, err + } + return creds.Items, nil +} + +func (s *Session) UserInfo(ctx context.Context) (*Root, error) { + if s.userInfo != nil { + return s.userInfo, nil + } + + if err := s.get(ctx, "", &s.userInfo); err != nil { + return nil, err + } + return s.userInfo, nil +} + +func (s *Session) GenerateUserFlowCodes(ctx context.Context, source string) (*AuthCliCodesResponse, error) { + var codes AuthCliCodesResponse + if err := s.post(ctx, "/cli/codes", nil, &codes); err != nil { + return nil, err + } + + // TODO: should the request POST the signup source instead? + if source != "" { + codes.VerificationUri += "?signup_src=" + source + } + return &codes, nil +} + +func getOrgServicesPath(orgSlug string) string { + return "/orgs/" + url.QueryEscape(orgSlug) + "/services" +} + +func (s *Session) GetOrgServices(ctx context.Context, orgSlug string) ([]Service, error) { + var svc Services + if err := s.get(ctx, getOrgServicesPath(orgSlug), &svc); err != nil { + return nil, err + } + return svc.Items, nil +} + +func getServicePath(orgSlug, serviceSlug string) string { + return "/orgs/" + url.QueryEscape(orgSlug) + "/services/" + url.QueryEscape(serviceSlug) +} + +func (s *Session) GetService(ctx context.Context, orgSlug, serviceSlug string) (*Service, error) { + var svc Service + if err := s.get(ctx, getServicePath(orgSlug, serviceSlug), &svc); err != nil { + if err == NotFoundErr { + return nil, nil + } + return nil, err + } + return &svc, nil +} + +func (s *Session) get(ctx context.Context, path string, out any) error { + req, err := http.NewRequestWithContext(ctx, "GET", path, nil) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + res, err := s.Do(req) + if err != nil { + return err + } + if res.StatusCode != http.StatusOK { + return StatusCodeError(res.StatusCode) + } + return json.NewDecoder(res.Body).Decode(out) +} + +func (s *Session) post(ctx context.Context, path string, in, out any) error { + var buf bytes.Buffer + if in != nil { + if err := json.NewEncoder(&buf).Encode(in); err != nil { + return err + } + } + + req, err := http.NewRequestWithContext(ctx, "POST", path, &buf) + if err != nil { + return err + } + if in != nil { + req.Header.Set("Content-Type", "application/json") + } + + res, err := s.Do(req) + if err != nil { + return err + } + if res.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected response code: %d", res.StatusCode) + } + return json.NewDecoder(res.Body).Decode(out) } type basicAuther struct { @@ -122,3 +344,10 @@ func (s mediaTypes) Matches(val string) bool { } return slices.Contains(s, media) } + +type StatusCodeError int + +const NotFoundErr = StatusCodeError(http.StatusNotFound) + +func (err StatusCodeError) StatusCode() int { return int(err) } +func (err StatusCodeError) Error() string { return fmt.Sprintf("unexpected response code: %d", err) } diff --git a/api/apitest/apitest.go b/api/apitest/apitest.go index 72d13f0..c117337 100644 --- a/api/apitest/apitest.go +++ b/api/apitest/apitest.go @@ -27,7 +27,7 @@ import ( var ( proxy = flag.Bool("prism-proxy", false, "run prism in proxy mode") - verbose = flag.Bool("prism-verbose", false, "run prism in proxy mode") + verbose = flag.Bool("prism-verbose", false, "verbose output for prism/rails servers") oapiConfig = flag.String("oapi-config", "config/openapi.yml", "openapi spec file path") lockfile = flag.String("api-lockfile", "tmp/apitest.lock", "rails server lockfile path") ) @@ -36,7 +36,8 @@ type Server struct { Host string RootDir string - URL string + URL string + RailsPort string proxy bool verbose bool @@ -129,6 +130,7 @@ func (s *Server) StartProxy(ctx context.Context) error { stopfn() return err } + s.RailsPort = port if s.Host != "" { host = s.Host @@ -179,7 +181,7 @@ func (s *Server) StartProxy(ctx context.Context) error { } func (s *Server) startMock(ctx context.Context) (string, func() error, error) { - addr, err := unusedPort() + addr, err := UnusedPort() if err != nil { return "", nil, err } @@ -225,7 +227,7 @@ func (s *Server) startCmd(ctx context.Context, args []string) (func() error, err } func (s *Server) startRails(ctx context.Context) (string, func() error, error) { - addr, err := unusedPort() + addr, err := UnusedPort() if err != nil { return "", nil, err } @@ -247,7 +249,7 @@ func (s *Server) startRails(ctx context.Context) (string, func() error, error) { } func (s *Server) startProxy(ctx context.Context, upstreamAddr string) (string, func() error, error) { - addr, err := unusedPort() + addr, err := UnusedPort() if err != nil { return "", nil, err } @@ -327,7 +329,7 @@ func drainCmd(cmd *exec.Cmd) (func() error, error) { }, nil } -func unusedPort() (string, error) { +func UnusedPort() (string, error) { ln, err := net.Listen("tcp4", ":0") if err != nil { return "", err @@ -337,12 +339,16 @@ func unusedPort() (string, error) { return ln.Addr().String(), nil } -func RunTUI(ctx context.Context, tui cli.TUI) (*bytes.Buffer, error) { +func RunTTY(ctx context.Context, ui cli.UI) (*bytes.Buffer, error) { ptmx, pts, err := pty.Open() if err != nil { return nil, err } - defer pts.Close() + + // the test TTY needs to be a real TTY (*os.File), but the data we want to + // read from is tee'd to the buffer, so throw away the TTY's data to avoid + // blocking on a full TTY buffer, which may stall the test. + go io.Copy(io.Discard, pts) tty := &testTTY{ File: ptmx, @@ -351,7 +357,7 @@ func RunTUI(ctx context.Context, tui cli.TUI) (*bytes.Buffer, error) { output := termenv.NewOutput(tty, termenv.WithProfile(termenv.Ascii)) termenv.SetDefaultOutput(output) - if err := tui.Run(ctx, output.TTY()); err != nil { + if err := ui.RunTTY(ctx, output.TTY()); err != nil { return nil, err } return &tty.buf, nil diff --git a/api/openapi.gen.go b/api/openapi.gen.go index 953024e..f590c8f 100644 --- a/api/openapi.gen.go +++ b/api/openapi.gen.go @@ -14,6 +14,15 @@ const ( Pat_bearerScopes = "pat_bearer.Scopes" ) +// Defines values for ClientType. +const ( + ClientTypeCustom ClientType = "custom" + ClientTypeGo ClientType = "go" + ClientTypeJavascript ClientType = "javascript" + ClientTypePython ClientType = "python" + ClientTypeRuby ClientType = "ruby" +) + // Defines values for CredentialStatus. const ( Expired CredentialStatus = "expired" @@ -21,6 +30,17 @@ const ( Revoked CredentialStatus = "revoked" ) +// Defines values for ServiceServerType. +const ( + ServiceServerTypeCaddy ServiceServerType = "caddy" + ServiceServerTypeCustom ServiceServerType = "custom" + ServiceServerTypeDiagnostic ServiceServerType = "diagnostic" + ServiceServerTypeGo ServiceServerType = "go" + ServiceServerTypeJavascript ServiceServerType = "javascript" + ServiceServerTypePython ServiceServerType = "python" + ServiceServerTypeRuby ServiceServerType = "ruby" +) + // AuthCliCodesResponse defines model for auth_cli_codes_response. type AuthCliCodesResponse struct { // DeviceCode Unique code associated with origin device for CLI auth flow. @@ -45,6 +65,50 @@ type AuthCliPatTokensResponse struct { PatToken string `json:"pat_token"` } +// Chain defines model for chain. +type Chain struct { + // Name The name of the chain. + Name string `json:"name"` + Relationships struct { + Org struct { + // Slug A value used as a parameter when referencing this org. + Slug string `json:"slug"` + } `json:"org"` + Realm struct { + // Slug A value used as a parameter when referencing this realm. + Slug string `json:"slug"` + } `json:"realm"` + } `json:"relationships"` + + // Slug A value used as a parameter when referencing this chain. + Slug string `json:"slug"` +} + +// Client defines model for client. +type Client struct { + // Name A name for the client. + Name string `json:"name"` + Relationships *struct { + Organization *struct { + // Slug A value used as a parameter when referencing this organization. + Slug string `json:"slug"` + } `json:"organization,omitempty"` + Service *struct { + // Slug A value used as a parameter when referencing this service. + Slug string `json:"slug"` + } `json:"service,omitempty"` + } `json:"relationships,omitempty"` + + // Slug A value used as a parameter when referencing this client. + Slug string `json:"slug"` + + // Type A type for the client. + Type ClientType `json:"type"` +} + +// ClientType A type for the client. +type ClientType string + // Credential defines model for credential. type Credential struct { // CreatedAt UTC time when credential was created. @@ -59,7 +123,7 @@ type Credential struct { // RevokedAt UTC time after which credential will be revoked RevokedAt *time.Time `json:"revoked_at"` - // Serial serial id for credetial + // Serial serial id for credential Serial string `json:"serial"` // SignatureAlgorithm Algorithm used to sign credential @@ -84,13 +148,40 @@ type Credential struct { // CredentialStatus current status of credential type CredentialStatus string +// Credentials defines model for credentials. +type Credentials struct { + Items []Credential `json:"items"` +} + // Eab defines model for eab. type Eab struct { // HmacKey EAB HMAC key HmacKey string `json:"hmac_key"` // Kid EAB key identifier - Kid string `json:"kid"` + Kid string `json:"kid"` + Relationships struct { + Chain struct { + // Slug A value used as a parameter when referencing this chain. + Slug string `json:"slug"` + } `json:"chain"` + Organization struct { + // Slug A value used as a parameter when referencing this organization. + Slug string `json:"slug"` + } `json:"organization"` + Realm struct { + // Slug A value used as a parameter when referencing this realm. + Slug string `json:"slug"` + } `json:"realm"` + Service struct { + // Slug A value used as a parameter when referencing this service. + Slug string `json:"slug"` + } `json:"service"` + SubCa struct { + // Slug A value used as a parameter when referencing this sub_ca. + Slug string `json:"slug"` + } `json:"sub_ca"` + } `json:"relationships"` } // Error defines model for error. @@ -111,30 +202,117 @@ type Error struct { // Root defines model for root. type Root struct { PersonalOrg struct { - Slug *string `json:"slug,omitempty"` + Slug string `json:"slug"` } `json:"personal_org"` Whoami string `json:"whoami"` } +// Service defines model for service. +type Service struct { + // Name A name for the service. + Name string `json:"name"` + Relationships *struct { + Organization struct { + // Slug A value used as a parameter when referencing this organization. + Slug string `json:"slug"` + } `json:"organization"` + } `json:"relationships,omitempty"` + + // ServerType A server type for the service. + ServerType ServiceServerType `json:"server_type"` + + // Slug A value used as a parameter when referencing this service. + Slug string `json:"slug"` +} + +// ServiceServerType A server type for the service. +type ServiceServerType string + +// Services defines model for services. +type Services struct { + Items []Service `json:"items"` +} + // PathOrgParam defines model for path_org_param. type PathOrgParam = string // PathRealmParam defines model for path_realm_param. type PathRealmParam = string +// PathServiceParam defines model for path_service_param. +type PathServiceParam = string + // QueryCaParam defines model for query_ca_param. type QueryCaParam = string +// ServicesXtach200 defines model for services_xtach_200. +type ServicesXtach200 struct { + // Domains A list of domains for this attachment. + Domains []string `json:"domains"` + Relationships struct { + Chain struct { + // Slug A value used as a parameter when referencing this chain. + Slug string `json:"slug"` + } `json:"chain"` + Organization struct { + // Slug A value used as a parameter when referencing this organization. + Slug string `json:"slug"` + } `json:"organization"` + Realm *struct { + // Slug A value used as a parameter when referencing this realm. + Slug string `json:"slug"` + } `json:"realm,omitempty"` + Service struct { + // Slug A value used as a parameter when referencing this services + Slug string `json:"slug"` + } `json:"service"` + SubCa struct { + // Slug A value used as a parameter when referencing this sub_ca + Slug string `json:"slug"` + } `json:"sub_ca"` + } `json:"relationships"` +} + +// ServicesXtach defines model for services_xtach. +type ServicesXtach struct { + // Domains A list of domains for this attachment. + Domains []string `json:"domains"` + Relationships struct { + Chain struct { + // Slug A value used as a parameter when referencing this chain. + Slug string `json:"slug"` + } `json:"chain"` + Realm struct { + // Slug A value used as a parameter when referencing this realm. + Slug string `json:"slug"` + } `json:"realm"` + } `json:"relationships"` +} + // CreateEabTokenJSONBody defines parameters for CreateEabToken. type CreateEabTokenJSONBody struct { - Organization struct { - // Name unique name of organization to create EAB tokens within - Name string `json:"name"` - } `json:"organization"` - Realm struct { - // Name unique name of realm to create EAB tokens within - Name string `json:"name"` - } `json:"realm"` + Relationships struct { + Chain struct { + // Slug A value used as a parameter when referencing this chain. + Slug string `json:"slug"` + } `json:"chain"` + Organization struct { + // Slug A value used as a parameter when referencing this organization. + Slug string `json:"slug"` + } `json:"organization"` + Realm struct { + // Slug A value used as a parameter when referencing this realm. + Slug string `json:"slug"` + } `json:"realm"` + Service struct { + // Slug A value used as a parameter when referencing this service. + Slug *string `json:"slug,omitempty"` + } `json:"service,omitempty"` + SubCa struct { + // Slug A value used as a parameter when referencing this sub_ca. + Slug string `json:"slug"` + } `json:"sub_ca"` + } `json:"relationships"` } // CreateCliTokenJSONBody defines parameters for CreateCliToken. @@ -143,14 +321,84 @@ type CreateCliTokenJSONBody struct { DeviceCode string `json:"device_code"` } +// CreateClientJSONBody defines parameters for CreateClient. +type CreateClientJSONBody struct { + Relationships *struct { + Organization *struct { + Slug string `json:"slug"` + } `json:"organization,omitempty"` + Service *struct { + Slug string `json:"slug"` + } `json:"service,omitempty"` + } `json:"relationships,omitempty"` + ServerType *string `json:"server_type,omitempty"` + Type *string `json:"type,omitempty"` +} + // GetCredentialsParams defines parameters for GetCredentials. type GetCredentialsParams struct { // CaParam ca for operation CaParam *QueryCaParam `form:"ca_param,omitempty" json:"ca_param,omitempty"` } +// AttachOrgServiceJSONBody defines parameters for AttachOrgService. +type AttachOrgServiceJSONBody struct { + // Domains A list of domains for this attachment. + Domains []string `json:"domains"` + Relationships struct { + Chain struct { + // Slug A value used as a parameter when referencing this chain. + Slug string `json:"slug"` + } `json:"chain"` + Realm struct { + // Slug A value used as a parameter when referencing this realm. + Slug string `json:"slug"` + } `json:"realm"` + } `json:"relationships"` +} + +// DetachOrgServiceJSONBody defines parameters for DetachOrgService. +type DetachOrgServiceJSONBody struct { + // Domains A list of domains for this attachment. + Domains []string `json:"domains"` + Relationships struct { + Chain struct { + // Slug A value used as a parameter when referencing this chain. + Slug string `json:"slug"` + } `json:"chain"` + Realm struct { + // Slug A value used as a parameter when referencing this realm. + Slug string `json:"slug"` + } `json:"realm"` + } `json:"relationships"` +} + +// CreateServiceJSONBody defines parameters for CreateService. +type CreateServiceJSONBody struct { + Name string `json:"name"` + Relationships struct { + Organization struct { + // Slug A value used as a parameter when referencing this organization. + Slug string `json:"slug"` + } `json:"organization"` + } `json:"relationships"` + ServerType string `json:"server_type"` +} + // CreateEabTokenJSONRequestBody defines body for CreateEabToken for application/json ContentType. type CreateEabTokenJSONRequestBody CreateEabTokenJSONBody // CreateCliTokenJSONRequestBody defines body for CreateCliToken for application/json ContentType. type CreateCliTokenJSONRequestBody CreateCliTokenJSONBody + +// CreateClientJSONRequestBody defines body for CreateClient for application/json ContentType. +type CreateClientJSONRequestBody CreateClientJSONBody + +// AttachOrgServiceJSONRequestBody defines body for AttachOrgService for application/json ContentType. +type AttachOrgServiceJSONRequestBody AttachOrgServiceJSONBody + +// DetachOrgServiceJSONRequestBody defines body for DetachOrgService for application/json ContentType. +type DetachOrgServiceJSONRequestBody DetachOrgServiceJSONBody + +// CreateServiceJSONRequestBody defines body for CreateService for application/json ContentType. +type CreateServiceJSONRequestBody CreateServiceJSONBody diff --git a/api/util.go b/api/util.go new file mode 100644 index 0000000..a874330 --- /dev/null +++ b/api/util.go @@ -0,0 +1,5 @@ +package api + +func PointerTo[T any](v T) *T { + return &v +} diff --git a/auth/models/signin.go b/auth/models/signin.go new file mode 100644 index 0000000..6a17960 --- /dev/null +++ b/auth/models/signin.go @@ -0,0 +1,107 @@ +package models + +import ( + "fmt" + "strings" + + "github.com/anchordotdev/cli/ui" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" +) + +type SignInPreamble struct { + Message string +} + +func (SignInPreamble) Init() tea.Cmd { return nil } + +func (m SignInPreamble) Update(tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + +func (m SignInPreamble) View() string { + var b strings.Builder + fmt.Fprintln(&b, ui.Header(fmt.Sprintf("Signin to Anchor.dev %s", ui.Whisper("`anchor auth signin`")))) + if m.Message != "" { + fmt.Fprintln(&b, m.Message) + } + + return b.String() +} + +type SignInPrompt struct { + ConfirmCh chan<- struct{} + InClipboard bool + UserCode string + VerificationURL string +} + +func (SignInPrompt) Init() tea.Cmd { return nil } + +func (m *SignInPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + if m.ConfirmCh != nil { + close(m.ConfirmCh) + m.ConfirmCh = nil + } + } + } + + return m, nil +} + +func (m *SignInPrompt) View() string { + var b strings.Builder + + if m.ConfirmCh != nil { + if m.InClipboard { + fmt.Fprintln(&b, ui.StepAlert(fmt.Sprintf("Copied your user code %s to your clipboard.", ui.Emphasize(m.UserCode)))) + } else { + fmt.Fprintln(&b, ui.StepAlert(fmt.Sprintf("Copy your user code: %s", ui.Announce(m.UserCode)))) + } + fmt.Fprintln(&b, ui.StepAlert(fmt.Sprintf("%s to open %s in your browser", ui.Action("Press Enter"), ui.URL(m.VerificationURL)))) + return b.String() + } + + fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Copied your user code to your clipboard."))) + fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Opened %s in your browser", ui.URL(m.VerificationURL)))) + + return b.String() +} + +type SignInChecker struct { + whoami string + + spinner spinner.Model +} + +func (m *SignInChecker) Init() tea.Cmd { + m.spinner = ui.Spinner() + + return m.spinner.Tick +} + +type UserSignInMsg string + +func (m *SignInChecker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case UserSignInMsg: + m.whoami = string(msg) + return m, tea.Quit + } + + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd +} + +func (m *SignInChecker) View() string { + var b strings.Builder + if m.whoami == "" { + fmt.Fprintln(&b, ui.StepInProgress(fmt.Sprintf("Signing in… %s", m.spinner.View()))) + } else { + fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Signed in as %s.", ui.Emphasize(m.whoami)))) + } + return b.String() +} diff --git a/auth/models/signout.go b/auth/models/signout.go new file mode 100644 index 0000000..80b3a0f --- /dev/null +++ b/auth/models/signout.go @@ -0,0 +1,33 @@ +package models + +import ( + "fmt" + "strings" + + "github.com/anchordotdev/cli/ui" + tea "github.com/charmbracelet/bubbletea" +) + +type SignOutPreamble struct{} + +func (SignOutPreamble) Init() tea.Cmd { return nil } + +func (m *SignOutPreamble) Update(tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + +func (m *SignOutPreamble) View() string { + var b strings.Builder + fmt.Fprintln(&b, ui.Header(fmt.Sprintf("Signout from Anchor.dev %s", ui.Whisper("`anchor auth signout`")))) + return b.String() +} + +type SignOutSuccess struct{} + +func (SignOutSuccess) Init() tea.Cmd { return nil } + +func (m *SignOutSuccess) Update(tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + +func (m *SignOutSuccess) View() string { + var b strings.Builder + fmt.Fprintln(&b, ui.StepDone("Signed out.")) + return b.String() +} diff --git a/auth/signin.go b/auth/signin.go index fc27a10..c39e7b5 100644 --- a/auth/signin.go +++ b/auth/signin.go @@ -1,7 +1,6 @@ package auth import ( - "bytes" "context" "encoding/json" "errors" @@ -9,13 +8,16 @@ import ( "net/http" "time" + "github.com/atotto/clipboard" "github.com/cli/browser" "github.com/mattn/go-isatty" "github.com/muesli/termenv" "github.com/anchordotdev/cli" "github.com/anchordotdev/cli/api" + "github.com/anchordotdev/cli/auth/models" "github.com/anchordotdev/cli/keyring" + "github.com/anchordotdev/cli/ui" ) var ( @@ -24,46 +26,62 @@ var ( type SignIn struct { Config *cli.Config + + Preamble string + Source string } -func (s SignIn) TUI() cli.TUI { - return cli.TUI{ - Run: s.run, +func (s SignIn) UI() cli.UI { + return cli.UI{ + RunTTY: s.runTTY, + RunTUI: s.RunTUI, } } -func (s *SignIn) run(ctx context.Context, tty termenv.File) error { +func (s *SignIn) runTTY(ctx context.Context, tty termenv.File) error { output := termenv.DefaultOutput() cp := output.ColorProfile() - anc, err := api.Client(s.Config) + fmt.Fprintln(tty, + output.String("# Run `anchor auth signin`").Bold(), + ) + + anc, err := api.NewClient(s.Config) if err != nil && err != api.ErrSignedOut { return err } - codesRes, err := anc.Post("/cli/codes", "application/json", nil) + codes, err := anc.GenerateUserFlowCodes(ctx, s.Source) if err != nil { return err } - if codesRes.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected response code: %d", codesRes.StatusCode) - } - var codes *api.AuthCliCodesResponse - if err = json.NewDecoder(codesRes.Body).Decode(&codes); err != nil { - return err - } - - fmt.Fprintln(tty) if isatty.IsTerminal(tty.Fd()) { + if err := clipboard.WriteAll(codes.UserCode); err != nil { + fmt.Fprintln(tty, + " ", + output.String("!").Background(cp.Color("#7000ff")), + output.String("Copy").Foreground(cp.Color("#ff6000")).Bold(), + "your user code:", + output.String(codes.UserCode).Background(cp.Color("#7000ff")).Bold(), + ) + } else { + fmt.Fprintln(tty, + " ", + output.String("!").Background(cp.Color("#7000ff")), + "Copied your user code", + output.String(codes.UserCode).Background(cp.Color("#7000ff")).Bold(), + "to your clipboard.", + ) + } fmt.Fprintln(tty, - output.String("!").Foreground(cp.Color("#ff6000")), - "First copy your user code:", - output.String(codes.UserCode).Background(cp.Color("#7000ff")).Bold(), + " ", + output.String("| When prompted you will paste this code in your browser to connect your CLI.").Faint(), ) fmt.Fprintln(tty, - "Then", - output.String("Press Enter").Bold(), + " ", + output.String("!").Background(cp.Color("#7000ff")), + output.String("Press Enter").Foreground(cp.Color("#ff6000")).Bold(), "to open", output.String(codes.VerificationUri).Faint().Underline(), "in your browser...", @@ -75,75 +93,101 @@ func (s *SignIn) run(ctx context.Context, tty termenv.File) error { } } else { fmt.Fprintln(tty, - output.String("!").Foreground(cp.Color("#ff6000")), - "Open", + " ", + output.String("!").Background(cp.Color("#7000ff")), + output.String("Open").Foreground(cp.Color("#ff6000")).Bold(), output.String(codes.VerificationUri).Faint().Underline(), "in a browser and enter your user code:", output.String(codes.UserCode).Bold(), ) } - var looper = func() error { - for { - body := new(bytes.Buffer) - req := api.CreateCliTokenJSONRequestBody{ - DeviceCode: codes.DeviceCode, - } - if err = json.NewEncoder(body).Encode(req); err != nil { - return err - } - tokensRes, err := anc.Post("/cli/pat-tokens", "application/json", body) - if err != nil { - return err - } - - switch tokensRes.StatusCode { - case http.StatusOK: - var patTokens *api.AuthCliPatTokensResponse - if err = json.NewDecoder(tokensRes.Body).Decode(&patTokens); err != nil { - return err - } - s.Config.API.Token = patTokens.PatToken - return nil - case http.StatusBadRequest: - var errorsRes *api.Error - if err = json.NewDecoder(tokensRes.Body).Decode(&errorsRes); err != nil { - return err - } - switch errorsRes.Type { - case "urn:anchordev:api:cli-auth:authorization-pending": - time.Sleep(time.Duration(codes.Interval) * time.Second) - case "urn:anchordev:api:cli-auth:expired-device-code": - return fmt.Errorf("Your authorization request has expired, please try again.") - case "urn:anchordev:api:cli-auth:incorrect-device-code": - return fmt.Errorf("Your authorization request was not found, please try again.") - default: - return fmt.Errorf("unexpected error: %s", errorsRes.Detail) - } - default: - return fmt.Errorf("unexpected response code: %d", tokensRes.StatusCode) - } + var patToken string + for patToken == "" { + if patToken, err = anc.CreatePATToken(ctx, codes.DeviceCode); err != nil { + return err + } + + if patToken == "" { + time.Sleep(time.Duration(codes.Interval) * time.Second) } } - if err := looper(); err != nil { + s.Config.API.Token = patToken + + userInfo, err := fetchUserInfo(s.Config) + if err != nil { return err } - anc, err = api.Client(s.Config) - if err != nil { + kr := keyring.Keyring{Config: s.Config} + if err := kr.Set(keyring.APIToken, s.Config.API.Token); err != nil { return err } - res, err := anc.Get("") + fmt.Fprintln(tty) + fmt.Fprintf(tty, + " - Signed in as %s!", + output.String(userInfo.Whoami).Bold(), + ) + fmt.Fprintln(tty) + + return nil +} + +func (s *SignIn) RunTUI(ctx context.Context, drv *ui.Driver) error { + drv.Activate(ctx, models.SignInPreamble{ + Message: s.Preamble, + }) + + anc, err := api.NewClient(s.Config) + if err != nil && err != api.ErrSignedOut { + return err + } + + codes, err := anc.GenerateUserFlowCodes(ctx, s.Source) if err != nil { return err } - if res.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected response code: %d", res.StatusCode) + + // TODO: skipping TTY check since this is TUI mode, but is it needed? + clipboardErr := clipboard.WriteAll(codes.UserCode) + + confirmc := make(chan struct{}) + drv.Activate(ctx, &models.SignInPrompt{ + ConfirmCh: confirmc, + InClipboard: (clipboardErr == nil), + UserCode: codes.UserCode, + VerificationURL: codes.VerificationUri, + }) + + if !s.Config.NonInteractive { + select { + case <-confirmc: + case <-ctx.Done(): + return ctx.Err() + } } - var userInfo *api.Root - if err := json.NewDecoder(res.Body).Decode(&userInfo); err != nil { + if err := browser.OpenURL(codes.VerificationUri); err != nil { + return err + } + + drv.Activate(ctx, new(models.SignInChecker)) + + var patToken string + for patToken == "" { + if patToken, err = anc.CreatePATToken(ctx, codes.DeviceCode); err != nil { + return err + } + + if patToken == "" { + time.Sleep(time.Duration(codes.Interval) * time.Second) + } + } + s.Config.API.Token = patToken + + userInfo, err := fetchUserInfo(s.Config) + if err != nil { return err } @@ -152,12 +196,28 @@ func (s *SignIn) run(ctx context.Context, tty termenv.File) error { return err } - fmt.Fprintln(tty) - fmt.Fprintf(tty, - "Success, hello %s!", - output.String(userInfo.Whoami).Bold(), - ) - fmt.Fprintln(tty) + drv.Send(models.UserSignInMsg(userInfo.Whoami)) return nil } + +func fetchUserInfo(cfg *cli.Config) (*api.Root, error) { + anc, err := api.NewClient(cfg) + if err != nil { + return nil, err + } + + res, err := anc.Get("") + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected response code: %d", res.StatusCode) + } + + var userInfo *api.Root + if err := json.NewDecoder(res.Body).Decode(&userInfo); err != nil { + return nil, err + } + return userInfo, nil +} diff --git a/auth/signout.go b/auth/signout.go index 88c54fd..23b223f 100644 --- a/auth/signout.go +++ b/auth/signout.go @@ -3,23 +3,31 @@ package auth import ( "context" - "github.com/muesli/termenv" - "github.com/anchordotdev/cli" + "github.com/anchordotdev/cli/auth/models" "github.com/anchordotdev/cli/keyring" + "github.com/anchordotdev/cli/ui" ) type SignOut struct { Config *cli.Config } -func (s SignOut) TUI() cli.TUI { - return cli.TUI{ - Run: s.run, +func (s SignOut) UI() cli.UI { + return cli.UI{ + RunTUI: s.runTUI, } } -func (s *SignOut) run(ctx context.Context, tty termenv.File) error { +func (s *SignOut) runTUI(ctx context.Context, drv *ui.Driver) error { + drv.Activate(ctx, &models.SignOutPreamble{}) + kr := keyring.Keyring{Config: s.Config} - return kr.Delete(keyring.APIToken) + err := kr.Delete(keyring.APIToken) + + if err == nil { + drv.Activate(ctx, &models.SignOutSuccess{}) + } + + return err } diff --git a/auth/whoami.go b/auth/whoami.go index 14c4806..b0660c2 100644 --- a/auth/whoami.go +++ b/auth/whoami.go @@ -17,14 +17,14 @@ type WhoAmI struct { Config *cli.Config } -func (w WhoAmI) TUI() cli.TUI { - return cli.TUI{ - Run: w.run, +func (w WhoAmI) UI() cli.UI { + return cli.UI{ + RunTTY: w.run, } } func (w *WhoAmI) run(ctx context.Context, tty termenv.File) error { - anc, err := api.Client(w.Config) + anc, err := api.NewClient(w.Config) if err != nil { return err } diff --git a/auth/whoami_test.go b/auth/whoami_test.go index 22150d7..5741334 100644 --- a/auth/whoami_test.go +++ b/auth/whoami_test.go @@ -22,7 +22,7 @@ func TestWhoAmI(t *testing.T) { Config: cfg, } - _, err := apitest.RunTUI(ctx, cmd.TUI()) + _, err := apitest.RunTTY(ctx, cmd.UI()) if want, got := api.ErrSignedOut, err; want != got { t.Fatalf("want signin failure error %q, got %q", want, got) } @@ -42,7 +42,7 @@ func TestWhoAmI(t *testing.T) { Config: cfg, } - buf, err := apitest.RunTUI(ctx, cmd.TUI()) + buf, err := apitest.RunTTY(ctx, cmd.UI()) if err != nil { t.Fatal(err) } diff --git a/cert/models/provision.go b/cert/models/provision.go new file mode 100644 index 0000000..f80607f --- /dev/null +++ b/cert/models/provision.go @@ -0,0 +1,61 @@ +package models + +import ( + "fmt" + "strings" + + "github.com/anchordotdev/cli/ui" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" +) + +type Provision struct { + Domains []string + + certFile, chainFile, keyFile string + + spinner spinner.Model +} + +func (m *Provision) Init() tea.Cmd { + m.spinner = ui.Spinner() + + return m.spinner.Tick +} + +type ProvisionedFiles [3]string + +func (m *Provision) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case ProvisionedFiles: + m.certFile = msg[0] + m.chainFile = msg[1] + m.keyFile = msg[2] + + return m, tea.Quit + default: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } +} + +func (m *Provision) View() string { + var b strings.Builder + fmt.Fprintln(&b, ui.Header("Provision Certificate")) + fmt.Fprintln(&b, ui.StepHint("You can manually use these certificate files or automate your certificates by following our setup guide.")) + + if m.certFile == "" { + fmt.Fprintln(&b, ui.StepInProgress(fmt.Sprintf("Provisioning certificate for [%s]… %s", + ui.Domains(m.Domains), m.spinner.View()))) + + return b.String() + } + + fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Provisioned certificate for [%s].", ui.Domains(m.Domains)))) + fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Wrote certificate to %s", ui.Emphasize(m.certFile)))) + fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Wrote chain to %s", ui.Emphasize(m.chainFile)))) + fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Wrote key to %s", ui.Emphasize(m.chainFile)))) + + return b.String() +} diff --git a/cert/provision.go b/cert/provision.go new file mode 100644 index 0000000..7ee6b8a --- /dev/null +++ b/cert/provision.go @@ -0,0 +1,83 @@ +package cert + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "strconv" + + "github.com/anchordotdev/cli" + "github.com/anchordotdev/cli/cert/models" + "github.com/anchordotdev/cli/ui" +) + +type Provision struct { + Config *cli.Config + + Cert *tls.Certificate +} + +func (p *Provision) RunTUI(ctx context.Context, drv *ui.Driver, domains ...string) error { + drv.Activate(ctx, &models.Provision{ + Domains: domains, + }) + + // TODO: as a stand-alone command, it makes no sense to expect a cert as an + // initialize value for this command, but this is only used by the 'lcl + // diagnostic' stuff for the time being, which already provisions a cert. + + cert := p.Cert + + prefix := cert.Leaf.Subject.CommonName + if num := len(domains); num > 1 { + prefix += "+" + strconv.Itoa(num-1) + } + + certFile := fmt.Sprintf("./%s-cert.pem", prefix) + chainFile := fmt.Sprintf("./%s-chain.pem", prefix) + keyFile := fmt.Sprintf("./%s-key.pem", prefix) + + certBlock := &pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Certificate[0], + } + + if err := os.WriteFile(certFile, pem.EncodeToMemory(certBlock), 0644); err != nil { + return err + } + + var chainData []byte + for _, certDER := range cert.Certificate { + chainBlock := &pem.Block{ + Type: "CERTIFICATE", + Bytes: certDER, + } + + chainData = append(chainData, pem.EncodeToMemory(chainBlock)...) + } + + if err := os.WriteFile(chainFile, chainData, 0644); err != nil { + return err + } + + keyDER, err := x509.MarshalPKCS8PrivateKey(cert.PrivateKey) + if err != nil { + return err + } + + keyBlock := &pem.Block{ + Type: "PRIVATE KEY", + Headers: make(map[string]string), + Bytes: keyDER, + } + + if err := os.WriteFile(keyFile, pem.EncodeToMemory(keyBlock), 0644); err != nil { + return err + } + + drv.Send(models.ProvisionedFiles{certFile, chainFile, keyFile}) + return nil +} diff --git a/cli.go b/cli.go index 4eb9515..519ff1a 100644 --- a/cli.go +++ b/cli.go @@ -4,6 +4,8 @@ import ( "context" "github.com/muesli/termenv" + + "github.com/anchordotdev/cli/ui" ) type Config struct { @@ -11,11 +13,29 @@ type Config struct { NonInteractive bool `desc:"Run without ever asking for user input." flag:"non-interactive,n" env:"NON_INTERACTIVE" toml:"non-interactive"` Verbose bool `desc:"Verbose output." flag:"verbose,v" env:"VERBOSE" toml:"verbose"` + AnchorURL string `default:"https://anchor.dev" desc:"TODO" flag:"host" env:"ANCHOR_HOST" toml:"anchor-host"` + API struct { URL string `default:"https://api.anchor.dev/v0" desc:"Anchor API endpoint URL." flag:"api-url,u" env:"API_URL" json:"api_url" toml:"api-url"` Token string `desc:"Anchor API personal access token (PAT)." flag:"api-token,t" env:"API_TOKEN" json:"api_token" toml:"token"` } + Lcl struct { + Service string `desc:"Name for lcl.host diagnostic service." flag:"service" env:"SERVICE" json:"service" toml:"service"` + Subdomain string `desc:"Subdomain for lcl.host diagnostic service." flag:"subdomain" env:"SUBDOMAIN" json:"subdomain" toml:"subdomain"` + + DiagnosticAddr string `default:":4433" desc:"Local server address" flag:"addr,a" env:"ADDR" json:"address" toml:"address"` + LclHostURL string `default:"https://lcl.host" env:"LCL_HOST_URL"` + + Detect struct { + PackageManager string `desc:"Package manager to use for integrating Anchor." flag:"package-manager" env:"PACKAGE_MANAGER" json:"package_manager" toml:"package-manager"` + Service string `desc:"Name for lcl.host service." flag:"service" env:"SERVICE" json:"service" toml:"service"` + Subdomain string `desc:"Subdomain for lcl.host service." flag:"subdomain" env:"SUBDOMAIN" json:"subdomain" toml:"subdomain"` + File string `desc:"File Anchor should use to detect package manager." flag:"file" env:"PACKAGE_MANAGER_FILE" json:"file" toml:"file"` + Language string `desc:"Language to use for integrating Anchor." flag:"language" json:"language" toml:"language"` + } `cmd:"detect"` + } `cmd:"lcl"` + Trust struct { Org string `desc:"organization" flag:"org,o" env:"ORG" json:"org" toml:"org"` Realm string `desc:"realm" flag:"realm,r" env:"REALM" json:"realm" toml:"realm"` @@ -23,6 +43,14 @@ type Config struct { NoSudo bool `desc:"Disable sudo prompts." flag:"no-sudo" env:"NO_SUDO" toml:"no-sudo"` MockMode bool `env:"ANCHOR_CLI_TRUSTSTORE_MOCK_MODE"` + + Stores []string `default:"[system,nss,homebrew]" desc:"trust stores" flag:"trust-stores" env:"TRUST_STORES" toml:"trust-stores"` + + Audit struct{} `cmd:"audit"` + + Clean struct { + States []string `default:"[expired]" desc:"cert state(s)" flag:"cert-states" env:"CERT_STATES" toml:"cert-states"` + } `cmd:"clean"` } `cmd:"trust"` User struct { @@ -42,6 +70,7 @@ type Config struct { } } -type TUI struct { - Run func(context.Context, termenv.File) error +type UI struct { + RunTTY func(context.Context, termenv.File) error + RunTUI func(context.Context, *ui.Driver) error } diff --git a/cmd/anchor/.goreleaser.yaml b/cmd/anchor/.goreleaser.yaml index 4894fbe..74e8ef8 100644 --- a/cmd/anchor/.goreleaser.yaml +++ b/cmd/anchor/.goreleaser.yaml @@ -26,9 +26,14 @@ archives: format: zip brews: - - tap: - owner: anchordotdev + - repository: name: homebrew-tap + owner: anchordotdev + description: Command-line tools for Anchor.dev folder: Formula homepage: https://anchor.dev/ + license: MIT + + test: | + assert_match "anchor is a command line interface for the Anchor certificate management platform.", shell_output("#{bin}/anchor") diff --git a/cmd/anchor/main.go b/cmd/anchor/main.go index deba580..82a2fda 100644 --- a/cmd/anchor/main.go +++ b/cmd/anchor/main.go @@ -10,6 +10,7 @@ import ( "github.com/anchordotdev/cli" "github.com/anchordotdev/cli/auth" + "github.com/anchordotdev/cli/lcl" "github.com/anchordotdev/cli/trust" ) @@ -25,15 +26,15 @@ var ( `), SubCommands: []*cli.Command{ - &cli.Command{ + { Name: "auth", Use: "auth ", Short: "Authentication", Group: "user", SubCommands: []*cli.Command{ - &cli.Command{ - TUI: auth.SignIn{Config: cfg}.TUI(), + { + UI: auth.SignIn{Config: cfg}.UI(), Name: "signin", Use: "signin", @@ -45,8 +46,8 @@ var ( for the local system user. `), }, - &cli.Command{ - TUI: auth.SignOut{Config: cfg}.TUI(), + { + UI: auth.SignOut{Config: cfg}.UI(), Name: "signout", Use: "signout", @@ -58,8 +59,8 @@ var ( system user. `), }, - &cli.Command{ - TUI: auth.WhoAmI{Config: cfg}.TUI(), + { + UI: auth.WhoAmI{Config: cfg}.UI(), Name: "whoami", Use: "whoami", @@ -70,8 +71,26 @@ var ( }, }, }, - &cli.Command{ - TUI: trust.Command{Config: cfg}.TUI(), + { + UI: lcl.Command{Config: cfg}.UI(), + + Name: "lcl", + Use: "lcl ", + Short: "lcl.host", + Hidden: true, + + SubCommands: []*cli.Command{ + { + UI: lcl.Detect{Config: cfg}.UI(), + + Name: "detect", + Use: "detect", + Short: "Detect Framework/Language", + }, + }, + }, + { + UI: trust.Sync{Config: cfg}.UI(), Name: "trust", Use: "trust [org[/realm[/ca]]]", @@ -85,6 +104,41 @@ var ( After installation of the AnchorCA certificates, Leaf certificates under the AnchorCA certificates will be trusted by browsers and programs on your system. `), + + SubCommands: []*cli.Command{ + { + UI: trust.Audit{Config: cfg}.UI(), + + Name: "audit", + Use: "audit [org[/realm[/ca]]]", + Short: "Local trust store audit", + + Long: heredoc.Doc(` + Perform an audit of the local trust store(s) and report any expected, missing, + or extra CA certificates per store. A set of expected CAs is fetched for the + target org and (optional) realm. The default stores to audit are system, nss, + and homebrew. + + CA certificate states: + + * VALID: an expected CA certificate is present in every trust store. + * MISSING: an expected CA certificate is missing in one or more stores. + * EXTRA: an unexpected CA certificate is present in one or more stores. + `), + }, + { + UI: trust.Clean{Config: cfg}.UI(), + + Name: "clean", + Use: "clean TODO", + Short: "clean the Local trust store(s)", + Hidden: true, + + Long: heredoc.Doc(` + TODO + `), + }, + }, }, }, @@ -96,8 +150,8 @@ var ( // Version info set by GoReleaser via ldflags version = "dev" - commit = "none" - date = "unknown" + commit = "none" //lint:ignore U1000 set by GoReleaser + date = "unknown" //lint:ignore U1000 set by GoReleaser ) func main() { @@ -119,7 +173,7 @@ func versionCheck(ctx context.Context) error { return err } if release.TagName == nil || *release.TagName != "v"+version { - return fmt.Errorf("Anchor CLI v%s is out of date, run `brew upgrade anchordotdev/tap/anchor` to install the latest", version) + return fmt.Errorf("anchor CLI v%s is out of date, run `brew upgrade anchordotdev/tap/anchor` to install the latest", version) } return nil } diff --git a/command.go b/command.go index fb1dde4..2ce407d 100644 --- a/command.go +++ b/command.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/anchordotdev/cli/ui" "github.com/joeshaw/envdecode" "github.com/mcuadros/go-defaults" "github.com/muesli/termenv" @@ -16,7 +17,7 @@ import ( ) type Command struct { - TUI + UI Name string Use string @@ -24,6 +25,8 @@ type Command struct { Group string + Hidden bool + SubCommands []*Command Preflight func(context.Context) error @@ -54,6 +57,7 @@ func (c *Command) cobraCommand(ctx context.Context, cfgv reflect.Value) *cobra.C Short: c.Short, Long: c.Long, GroupID: c.Group, + Hidden: c.Hidden, } if c.Preflight != nil { @@ -62,11 +66,42 @@ func (c *Command) cobraCommand(ctx context.Context, cfgv reflect.Value) *cobra.C } } - if c.Run != nil { + switch { + case c.RunTUI != nil: + cmd.RunE = func(_ *cobra.Command, args []string) error { + // TODO: positional args + + ctx, cancel := context.WithCancelCause(ctx) + defer cancel(nil) + + drv, prg := ui.NewDriverTUI(ctx) + errc := make(chan error) + + go func() { + defer close(errc) + + _, err := prg.Run() + cancel(err) + + errc <- err + }() + + if err := c.RunTUI(ctx, drv); err != nil && err != context.Canceled { + prg.Quit() + + <-errc // TODO: report this somewhere? + return err + } + + prg.Quit() + + return <-errc // TODO: special handling for a UI error + } + case c.RunTTY != nil: cmd.RunE = func(_ *cobra.Command, args []string) error { // TODO: positional args - return c.Run(ctx, termenv.DefaultOutput().TTY()) + return c.RunTTY(ctx, termenv.DefaultOutput().TTY()) } } @@ -86,7 +121,7 @@ func (c *Command) cobraCommand(ctx context.Context, cfgv reflect.Value) *cobra.C func (c *Command) cobraBuild(cmd *cobra.Command, cfgv reflect.Value, cmdVals map[string]reflect.Value) { flags := cmd.Flags() - if c.TUI.Run == nil { + if c.UI.RunTTY == nil { flags = cmd.PersistentFlags() } diff --git a/detection/detection.go b/detection/detection.go new file mode 100644 index 0000000..b9f4d03 --- /dev/null +++ b/detection/detection.go @@ -0,0 +1,110 @@ +package detection + +import ( + "github.com/anchordotdev/cli/anchorcli" +) + +// Confidence represents the confidence score +type Confidence int + +// Different confidence levels +const ( + High Confidence = 100 + Medium Confidence = 60 + Low Confidence = 40 + None Confidence = 0 +) + +// Confidence.String() returns the string representation of the confidence level +func (s Confidence) String() string { + switch s { + case High: + return "High" + case Medium: + return "Medium" + case Low: + return "Low" + case None: + return "None" + default: + return "Unknown" + } +} + +var ( + DefaultDetectors = []Detector{ + RubyDetector, + GoDetector, + JavascriptDetector, + PythonDetector, + } + + DetectorsByFlag = map[string]Detector{ + "django": DjangoDetector, + "flask": FlaskDetector, + "go": GoDetector, + "javascript": JavascriptDetector, + "python": PythonDetector, + "rails": RailsDetector, + "ruby": RubyDetector, + "sinatra": SinatraDetector, + } + + PositiveDetectionMessage = "%s project detected with confidence level %s!\n" +) + +// Match holds the detection result, confidence, and follow-up detectors +type Match struct { + Detector Detector + Detected bool + Confidence Confidence + // MissingRequiredFiles represents a list of files that are required but missing. + MissingRequiredFiles []string + FollowUpDetectors []Detector + Details string + AnchorCategory *anchorcli.Category +} + +// Detector interface represents a project detector +type Detector interface { + GetTitle() string + Detect(directory string) (Match, error) + FollowUp() []Detector +} + +func Perform(detectors []Detector, dir string) (Results, error) { + res := make(Results) + for _, detector := range detectors { + match, err := detector.Detect(dir) + if err != nil { + return nil, err + } + + if !match.Detected { + continue + } + + res[match.Confidence] = append(res[match.Confidence], match) + + if followupResults, err := Perform(match.FollowUpDetectors, dir); err == nil { + res.merge(followupResults) + } else { + return nil, err + } + } + return res, nil +} + +type Results map[Confidence][]Match + +func (r Results) merge(other Results) { + for confidence, matches := range other { + for _, match := range matches { + if !match.Detected { + continue + } + // Merge the results, putting the new matches at the front of the list + r[confidence] = append([]Match{match}, r[confidence]...) + } + } +} diff --git a/detection/detection_test.go b/detection/detection_test.go new file mode 100644 index 0000000..e8460a8 --- /dev/null +++ b/detection/detection_test.go @@ -0,0 +1,72 @@ +package detection + +import ( + "flag" + "slices" + "testing" + "testing/fstest" +) + +var ( + _ = flag.Bool("prism-verbose", false, "ignored") + _ = flag.Bool("prism-proxy", false, "ignored") +) + +func TestScore_String(t *testing.T) { + testCases := []struct { + confidence Confidence + expectedString string + }{ + {High, "High"}, + {Medium, "Medium"}, + {Low, "Low"}, + {None, "None"}, + {Confidence(42), "Unknown"}, // Unknown confidence scores + } + + for _, testCase := range testCases { + t.Run(testCase.expectedString, func(t *testing.T) { + actualString := testCase.confidence.String() + if actualString != testCase.expectedString { + t.Errorf("Expected string representation %s, but got %s", testCase.expectedString, actualString) + } + }) + } +} + +func TestDefaultDetectors(t *testing.T) { + // Verify that the default detectors are present + if len(DefaultDetectors) < 1 { + t.Errorf("Expected some default detectors, but got %d", len(DefaultDetectors)) + } + + fakeFS := fstest.MapFS{ + "app/Gemfile": &fstest.MapFile{Data: []byte(""), Mode: 0644}, + "app/Gemfile.lock": &fstest.MapFile{Data: []byte(""), Mode: 0644}, + "app/package.json": &fstest.MapFile{Data: []byte(""), Mode: 0644}, + "app/requirements.txt": &fstest.MapFile{Data: []byte(""), Mode: 0644}, + "app/main.go": &fstest.MapFile{Data: []byte(""), Mode: 0644}, + "app/index.js": &fstest.MapFile{Data: []byte(""), Mode: 0644}, + "app/app.py": &fstest.MapFile{Data: []byte(""), Mode: 0644}, + } + + for _, detector := range DefaultDetectors { + t.Run(detector.GetTitle(), func(t *testing.T) { + // Assume all detectors are FileDetectors right now + det := detector.(*FileDetector) + det.FileSystem = fakeFS + match, err := det.Detect("app") + if err != nil { + t.Fatal(err) + } + + if !match.Detected { + t.Errorf("Expected detection result to be true, but got false") + } + + if !slices.Contains([]Confidence{High, Medium, Low}, match.Confidence) { + t.Errorf("Expected confidence to be High, Medium or Low, but got %s", match.Confidence) + } + }) + } +} diff --git a/detection/filesystem.go b/detection/filesystem.go new file mode 100644 index 0000000..c9397e2 --- /dev/null +++ b/detection/filesystem.go @@ -0,0 +1,110 @@ +package detection + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "slices" + + "github.com/anchordotdev/cli/anchorcli" +) + +// FileDetector is a generic file-based project detector +type FileDetector struct { + Title string + Paths []string + RequiredFiles []string + FollowUpDetectors []Detector + FileSystem fs.StatFS + AnchorCategory *anchorcli.Category +} + +// GetTitle returns the name of the detector +func (fd FileDetector) GetTitle() string { + return fd.Title +} + +// Detect checks if the directory contains any of the specified files +func (fd FileDetector) Detect(directory string) (Match, error) { + if fd.FileSystem == nil { + fd.FileSystem = &osFS{} + } + + var matchedPaths []string + + for _, path := range fd.Paths { + fullPath := filepath.Join(directory, path) + if _, err := fd.FileSystem.Stat(fullPath); err == nil { + matchedPaths = append(matchedPaths, path) + } else if !os.IsNotExist(err) { + return Match{}, errors.Join(err, errors.New("project file detection failure")) + } + } + + // Calculate the match confidence based on the percentage of matched paths + percentage := float64(len(matchedPaths)) / float64(len(fd.Paths)) + var confidence Confidence + + // Assume a 25% window for each confidence level, anything less than 30% is None + // Completely arbitrary, but it's a start. + switch { + case percentage >= 0.80: + confidence = High + case percentage >= 0.55: + confidence = Medium + case percentage >= 0.30: + confidence = Low + default: + confidence = None + } + + var missingRequiredFiles []string + if len(matchedPaths) > 0 && fd.RequiredFiles != nil && len(fd.RequiredFiles) > 0 { + for _, reqPath := range fd.RequiredFiles { + if !slices.Contains(matchedPaths, reqPath) { + missingRequiredFiles = append(missingRequiredFiles, reqPath) + // Only lower confidence + if confidence != None && confidence != Low { + confidence = Low // Force confidence to low when required files are missing + } + continue + } + } + } + + match := Match{ + Detector: fd, + Detected: len(matchedPaths) > 0, + Confidence: confidence, + FollowUpDetectors: fd.FollowUpDetectors, + MissingRequiredFiles: missingRequiredFiles, + } + + if fd.AnchorCategory != nil { + match.AnchorCategory = fd.AnchorCategory + } else { + // Default to Custom Category if not specified by the detector + match.AnchorCategory = anchorcli.CategoryCustom + } + + // Return a Match with the calculated confidence, follow-ups and category + return match, nil +} + +// FollowUp returns additional detectors +func (fd FileDetector) FollowUp() []Detector { + return fd.FollowUpDetectors +} + +// osFS is a simple wrapper around the os package's file system functions +// so that we can mock them out for testing. +type osFS struct{} + +// Stat wraps os.Stat +func (osFS) Stat(path string) (fs.FileInfo, error) { return os.Stat(path) } +func (osFS) Open(path string) (fs.File, error) { return os.Open(path) } + +var ( + _ fs.FS = (*osFS)(nil) +) diff --git a/detection/filesystem_test.go b/detection/filesystem_test.go new file mode 100644 index 0000000..03a700f --- /dev/null +++ b/detection/filesystem_test.go @@ -0,0 +1,232 @@ +package detection + +import ( + "os" + "slices" + "testing" + "testing/fstest" + + "github.com/anchordotdev/cli/anchorcli" +) + +func TestFileDetector_Detect(t *testing.T) { + emptyFile := &fstest.MapFile{Data: []byte(""), Mode: 0644} + + testCases := []struct { + name string + detector FileDetector + directory string + expected Match + expectError bool + }{ + { + name: "Mock Exact Match", + detector: FileDetector{ + Title: "Test Detector", + Paths: []string{"1.txt", "2.txt", "3.txt", "4.txt", "5.txt"}, + FileSystem: fstest.MapFS{ + "app/1.txt": emptyFile, + "app/2.txt": emptyFile, + "app/3.txt": emptyFile, + "app/4.txt": emptyFile, + "app/5.txt": emptyFile, + }, + AnchorCategory: anchorcli.CategoryCustom, + }, + directory: "app/", + expected: Match{Detected: true, Confidence: High, FollowUpDetectors: nil, AnchorCategory: anchorcli.CategoryCustom}, + expectError: false, + }, + { + name: "Mock Strong Match", + detector: FileDetector{ + Title: "Test Detector", + Paths: []string{"1.txt", "2.txt", "3.txt", "4.txt", "5.txt"}, + FileSystem: fstest.MapFS{ + "app/1.txt": emptyFile, + "app/2.txt": emptyFile, + "app/3.txt": emptyFile, + }, + }, + directory: "app/", + expected: Match{Detected: true, Confidence: Medium, FollowUpDetectors: nil, AnchorCategory: anchorcli.CategoryCustom}, + expectError: false, + }, + { + name: "Mock Possible Match", + detector: FileDetector{ + Title: "Test Detector", + Paths: []string{"1.txt", "2.txt", "3.txt", "4.txt", "5.txt"}, + FileSystem: fstest.MapFS{ + "app/1.txt": emptyFile, + "app/2.txt": emptyFile, + }, + }, + directory: "app/", + expected: Match{Detected: true, Confidence: Low, FollowUpDetectors: nil, AnchorCategory: anchorcli.CategoryCustom}, + expectError: false, + }, + { + name: "Mock None Match", + detector: FileDetector{ + Title: "Test Detector", + Paths: []string{"1.txt", "2.txt", "3.txt", "4.txt", "5.txt"}, + FileSystem: fstest.MapFS{}, + }, + directory: "app/", + expected: Match{Detected: false, Confidence: None, FollowUpDetectors: nil, AnchorCategory: anchorcli.CategoryCustom}, + expectError: false, + }, + { + name: "Missing RequiredFiles forces match to Low", + detector: FileDetector{ + Title: "Test Detector", + Paths: []string{"1.txt", "2.txt", "3.txt", "4.txt", "5.txt"}, + RequiredFiles: []string{"1.txt"}, + FileSystem: fstest.MapFS{ + "app/2.txt": emptyFile, + "app/3.txt": emptyFile, + "app/4.txt": emptyFile, + "app/5.txt": emptyFile, + }, + }, + directory: "app/", + expected: Match{Detected: true, Confidence: Low, FollowUpDetectors: nil, AnchorCategory: anchorcli.CategoryCustom, MissingRequiredFiles: []string{"1.txt"}}, + expectError: false, + }, + { + name: "Missing Required Files never forces None match to Low", + detector: FileDetector{ + Title: "Test Detector", + Paths: []string{"20.txt", "40.txt", "60.txt", "80.txt", "100.txt"}, + RequiredFiles: []string{"1.txt"}, + FileSystem: fstest.MapFS{ + "app/20.txt": emptyFile, + }, + }, + directory: "app/", + expected: Match{Detected: true, Confidence: None, FollowUpDetectors: nil, AnchorCategory: anchorcli.CategoryCustom, MissingRequiredFiles: []string{"1.txt"}}, + expectError: false, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + match, err := testCase.detector.Detect(testCase.directory) + + if testCase.expectError && err == nil { + t.Errorf("Expected an error, but got none") + } + + if !testCase.expectError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if match.Detected != testCase.expected.Detected { + t.Errorf("Expected detection result %t, but got %t", testCase.expected.Detected, match.Detected) + } + + if match.Confidence != testCase.expected.Confidence { + t.Errorf("Expected confidence score %s, but got %s", testCase.expected.Confidence, match.Confidence) + } + + if len(match.FollowUpDetectors) != len(testCase.expected.FollowUpDetectors) { + t.Errorf("Expected %d follow-up detectors, but got %d", len(testCase.expected.FollowUpDetectors), len(match.FollowUpDetectors)) + } + + if match.AnchorCategory != testCase.expected.AnchorCategory { + t.Errorf("Expected AnchorCategory %s, but got %s", testCase.expected.AnchorCategory, match.AnchorCategory) + } + + if testCase.expected.MissingRequiredFiles != nil && slices.Compare(match.MissingRequiredFiles, testCase.expected.MissingRequiredFiles) != 0 { + t.Errorf("Expected missing required files %v, but got %v", testCase.expected.MissingRequiredFiles, match.MissingRequiredFiles) + } + }) + } +} + +func TestFileDetector_DetectWithoutMockFS(t *testing.T) { + // Specify the name of an existing directory that contains the expected files + directory, err := os.Getwd() + if err != nil { + t.Fatalf("Unexpected Getwd error: %v", err) + } + + files, err := os.ReadDir(directory) + if err != nil { + t.Fatalf("Unexpected ReadDir error: %v", err) + } + + // Choose a random file from working directory + expectedFiles := []string{files[0].Name()} + + // Create the FileDetector with the expected file paths + // Instantiating one without a FileSystem will use the actual OS File System + detector := FileDetector{ + Title: "Test Detector", + Paths: expectedFiles, + } + + // Perform the detection + match, err := detector.Detect(directory) + + if err != nil { + t.Fatalf("Unexpected error during detection: %v", err) + } + + // Verify the detection result + if !match.Detected { + t.Errorf("Expected detection result to be true, but got false") + } + + if match.Confidence != High { + t.Errorf("Expected confidence to be High, but got %s", match.Confidence) + } +} + +func TestFileDetector_GetTitle(t *testing.T) { + detector := FileDetector{ + Title: "Test Detector", + Paths: []string{ + "file1.txt", + "file2.txt", + }, + } + + expectedTitle := "Test Detector" + actualTitle := detector.GetTitle() + + if actualTitle != expectedTitle { + t.Errorf("Expected detector name %s, but got %s", expectedTitle, actualTitle) + } +} + +func TestFileDetector_FollowUp(t *testing.T) { + // Create a FileDetector with some follow-up detectors + detector1 := FileDetector{Title: "Detector 1", Paths: []string{"file1.txt"}} + detector2 := FileDetector{Title: "Detector 2", Paths: []string{"file2.txt"}} + detector3 := FileDetector{Title: "Detector 3", Paths: []string{"file3.txt"}} + + parentDetector := FileDetector{ + Title: "Parent Detector", + Paths: []string{"parent.txt"}, + FollowUpDetectors: []Detector{&detector1, &detector2, &detector3}, + } + + // Get the follow-up detectors + followUpDetectors := parentDetector.FollowUp() + + // Verify the expected follow-up detectors + expectedFollowUpDetectors := []Detector{&detector1, &detector2, &detector3} + if len(followUpDetectors) != len(expectedFollowUpDetectors) { + t.Errorf("Expected %d follow-up detectors, but got %d", len(expectedFollowUpDetectors), len(followUpDetectors)) + return + } + + for i, expectedDetector := range expectedFollowUpDetectors { + actualDetector := followUpDetectors[i] + if actualDetector.GetTitle() != expectedDetector.GetTitle() { + t.Errorf("Expected follow-up detector name %s, but got %s", expectedDetector.GetTitle(), actualDetector.GetTitle()) + } + } +} diff --git a/detection/framework.go b/detection/framework.go new file mode 100644 index 0000000..d23b971 --- /dev/null +++ b/detection/framework.go @@ -0,0 +1,41 @@ +package detection + +import ( + "github.com/anchordotdev/cli/anchorcli" +) + +// Ruby Frameworks +var RailsFiles []string = []string{"Gemfile", "Rakefile", "config.ru", "app", "config", "db", "lib", "public", "vendor"} +var SinatraFiles []string = []string{"Gemfile", "config.ru", "app.rb"} + +// Python Frameworks +var DjangoFiles []string = []string{"requirements.txt", "manage.py"} +var FlaskFiles []string = []string{"requirements.txt", "app.py"} + +var RailsDetector = &FileDetector{ + Title: "rails", + Paths: RailsFiles, + FollowUpDetectors: nil, + AnchorCategory: anchorcli.CategoryRuby, +} + +var SinatraDetector = &FileDetector{ + Title: "sinatra", + Paths: SinatraFiles, + FollowUpDetectors: nil, + AnchorCategory: anchorcli.CategoryRuby, + RequiredFiles: []string{"app.rb"}, +} + +var DjangoDetector = &FileDetector{ + Title: "django", + Paths: DjangoFiles, + FollowUpDetectors: nil, + AnchorCategory: anchorcli.CategoryPython, +} +var FlaskDetector = &FileDetector{ + Title: "flask", + Paths: FlaskFiles, + FollowUpDetectors: nil, + AnchorCategory: anchorcli.CategoryPython, +} diff --git a/detection/framework_test.go b/detection/framework_test.go new file mode 100644 index 0000000..cd51688 --- /dev/null +++ b/detection/framework_test.go @@ -0,0 +1,108 @@ +package detection + +import ( + "path/filepath" + "testing" + "testing/fstest" +) + +func TestRailsDetector_Detect(t *testing.T) { + fakeFS := fstest.MapFS{} + for _, file := range RailsFiles { + fakeFS[filepath.Join("rails-app", file)] = &fstest.MapFile{Data: []byte(""), Mode: 0644} + } + + detector := RailsDetector + detector.FileSystem = fakeFS + + match, err := detector.Detect("rails-app") + + if err != nil { + t.Fatalf("Unexpected error during detection: %v", err) + } + + if !match.Detected { + t.Errorf("Expected detection result to be true, but got false") + } + + if match.Confidence != High { + t.Errorf("Expected confidence score to be High, but got %s", match.Confidence) + } +} + +func TestSinatraDetector_Detect(t *testing.T) { + fakeFS := fstest.MapFS{} + for _, file := range SinatraFiles { + fakeFS[filepath.Join("sinatra-app", file)] = &fstest.MapFile{Data: []byte(""), Mode: 0644} + } + + detector := SinatraDetector + detector.FileSystem = fakeFS + + match, err := detector.Detect("sinatra-app") + + if err != nil { + t.Fatalf("Unexpected error during detection: %v", err) + } + + // Verify the detection result + if !match.Detected { + t.Errorf("Expected detection result to be true, but got false") + } + + if match.Confidence != High { + t.Errorf("Expected confidence score to be High, but got %s", match.Confidence) + } +} + +func TestDjangoDetector_Detect(t *testing.T) { + fakeFS := fstest.MapFS{} + for _, file := range DjangoFiles { + fakeFS[filepath.Join("django-app", file)] = &fstest.MapFile{Data: []byte(""), Mode: 0644} + } + + detector := DjangoDetector + detector.FileSystem = fakeFS + + match, err := detector.Detect("django-app") + + if err != nil { + t.Fatalf("Unexpected error during detection: %v", err) + } + + // Verify the detection result + if !match.Detected { + t.Errorf("Expected detection result to be true, but got false") + } + + if match.Confidence != High { + t.Errorf("Expected confidence score to be High, but got %s", match.Confidence) + } +} + +func TestFlaskDetector_Detect(t *testing.T) { + fakeFS := fstest.MapFS{} + + for _, file := range FlaskFiles { + fakeFS[filepath.Join("flask-app", file)] = &fstest.MapFile{Data: []byte(""), Mode: 0644} + } + + detector := FlaskDetector + detector.FileSystem = fakeFS + + // Perform the detection + match, err := detector.Detect("flask-app") + + if err != nil { + t.Fatalf("Unexpected error during detection: %v", err) + } + + // Verify the detection result + if !match.Detected { + t.Errorf("Expected detection result to be true, but got false") + } + + if match.Confidence != High { + t.Errorf("Expected confidence score to be High, but got %s", match.Confidence) + } +} diff --git a/detection/languages.go b/detection/languages.go new file mode 100644 index 0000000..1fa0758 --- /dev/null +++ b/detection/languages.go @@ -0,0 +1,37 @@ +package detection + +import ( + "github.com/anchordotdev/cli/anchorcli" +) + +// RubyDetector is a detector for Ruby projects. +var RubyDetector = &FileDetector{ + Title: "ruby", + Paths: []string{"Gemfile", "Gemfile.lock", "Rakefile"}, + FollowUpDetectors: []Detector{RailsDetector, SinatraDetector}, + AnchorCategory: anchorcli.CategoryRuby, +} + +// GoDetector is a Go detector +var GoDetector = &FileDetector{ + Title: "go", + Paths: []string{"main.go", "go.mod", "go.sum"}, + FollowUpDetectors: nil, + AnchorCategory: anchorcli.CategoryGo, +} + +// JavascriptDetector is a JavaScript detector +var JavascriptDetector = &FileDetector{ + Title: "javascript", + Paths: []string{"package.json", "index.js", "app.js"}, + FollowUpDetectors: nil, + AnchorCategory: anchorcli.CategoryJavascript, +} + +// PythonDetector is a Python detector with Django and Flask follow-up detectors +var PythonDetector = &FileDetector{ + Title: "python", + Paths: []string{"requirements.txt"}, + FollowUpDetectors: []Detector{DjangoDetector, FlaskDetector}, + AnchorCategory: anchorcli.CategoryPython, +} diff --git a/detection/package_managers.go b/detection/package_managers.go new file mode 100644 index 0000000..a3a3cd4 --- /dev/null +++ b/detection/package_managers.go @@ -0,0 +1,32 @@ +package detection + +type PackageManager string +type PackageManagerManifest string + +var SupportedPackageManagers = []PackageManager{ + RubyGemsPkgManager, + NPMPkgManager, + YarnPkgManager, +} + +const ( + RubyGemsPkgManager PackageManager = "rubygems" + NPMPkgManager PackageManager = "npm" + YarnPkgManager PackageManager = "yarn" +) + +const ( + Gemfile PackageManagerManifest = "Gemfile" + GemfileLock PackageManagerManifest = "Gemfile.lock" + PackageJSON PackageManagerManifest = "package.json" + PackageLockJSON PackageManagerManifest = "package-lock.json" + YarnLock PackageManagerManifest = "yarn.lock" +) + +func (pmm PackageManagerManifest) String() string { + return string(pmm) +} + +func (pm PackageManager) String() string { + return string(pm) +} diff --git a/diagnostic/server.go b/diagnostic/server.go new file mode 100644 index 0000000..90385e6 --- /dev/null +++ b/diagnostic/server.go @@ -0,0 +1,145 @@ +package diagnostic + +import ( + "context" + "crypto/tls" + "io" + "log" + "net" + "net/http" + "sync" + + "github.com/inetaf/tcpproxy" +) + +var discardLogger = log.New(io.Discard, "", 0) + +type Server struct { + Addr string + + LclHostURL string + + GetCertificate func(*tls.ClientHelloInfo) (*tls.Certificate, error) + + proxy *tcpproxy.Proxy + server *http.Server + + ln net.Listener + + rchanmu sync.Mutex + rchan chan string +} + +func (s *Server) Start(ctx context.Context) error { + var err error + if s.ln, err = new(net.ListenConfig).Listen(ctx, "tcp", s.Addr); err != nil { + return err + } + s.Addr = s.ln.Addr().String() + + s.proxy = &tcpproxy.Proxy{ + ListenFunc: func(string, string) (net.Listener, error) { + return s.ln, nil + }, + } + + lnTLS := &tcpproxy.TargetListener{ + Address: s.Addr, + } + + s.proxy.AddSNIRouteFunc(s.Addr, func(context.Context, string) (tcpproxy.Target, bool) { + return lnTLS, true + }) + + lnHTTP := &tcpproxy.TargetListener{ + Address: s.Addr, + } + + s.proxy.AddRoute(s.Addr, lnHTTP) + + s.server = &http.Server{ + Addr: s.Addr, + + Handler: http.HandlerFunc(s.serveHTTP), + + BaseContext: func(net.Listener) context.Context { return ctx }, + ErrorLog: discardLogger, + } + + go s.server.Serve(lnHTTP) + go s.server.Serve(tls.NewListener(lnTLS, &tls.Config{ + NextProtos: []string{"h2", "http/1.1"}, + GetCertificate: s.GetCertificate, + })) + + return s.proxy.Start() +} + +func (s *Server) Close() error { + if err := s.ln.Close(); err != nil { + return err + } + + if err := s.proxy.Close(); err != nil { + return err + } + + if err := s.server.Shutdown(context.Background()); err != nil { + return err + } + + return s.proxy.Wait() +} + +func (s *Server) RequestChan() <-chan string { + s.rchanmu.Lock() + defer s.rchanmu.Unlock() + + s.rchan = make(chan string) + + return s.rchan +} + +func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { + if r.TLS != nil { + s.notifyRequest("https") + + w.Write([]byte(` + + + +`)) + } else { + s.notifyRequest("http") + + w.Write([]byte(` + + + +`)) + } +} + +func (s *Server) notifyRequest(scheme string) { + s.rchanmu.Lock() + defer s.rchanmu.Unlock() + + if s.rchan != nil { + select { + case s.rchan <- scheme: + default: + } + } +} + +func (s *Server) lclHostURL(path string) string { + return s.LclHostURL + path +} diff --git a/diagnostic/server_test.go b/diagnostic/server_test.go new file mode 100644 index 0000000..2d02fc3 --- /dev/null +++ b/diagnostic/server_test.go @@ -0,0 +1,104 @@ +package diagnostic + +import ( + "context" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "flag" + "net" + "net/http" + "testing" + + "github.com/anchordotdev/cli/internal/must" +) + +var ( + _ = flag.Bool("prism-verbose", false, "ignored") + _ = flag.Bool("prism-proxy", false, "ignored") +) + +func TestServerSupportsDualProtocols(t *testing.T) { + cert := leaf.TLS() + + srv := &Server{ + Addr: ":0", + GetCertificate: func(cii *tls.ClientHelloInfo) (*tls.Certificate, error) { + return &cert, nil + }, + } + + if err := srv.Start(context.Background()); err != nil { + t.Fatal(err) + } + defer srv.Close() + + _, port, err := net.SplitHostPort(srv.Addr) + if err != nil { + t.Fatal(err) + } + + client := &http.Client{ + Transport: &http.Transport{ + ForceAttemptHTTP2: true, + DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { + return new(net.Dialer).DialContext(ctx, network, srv.Addr) + }, + TLSClientConfig: &tls.Config{ + RootCAs: anchorCA.CertPool(), + }, + }, + } + + resHTTP, err := client.Get("http://example.lcl.host.test:" + port) + if err != nil { + t.Fatal(err) + } + if want, got := http.StatusOK, resHTTP.StatusCode; want != got { + t.Errorf("want http response status %d, got %d", want, got) + } + if got := resHTTP.TLS; got != nil { + t.Errorf("want nil http response tls info, got %#v", got) + } + + resHTTPS, err := client.Get("https://example.lcl.host.test:" + port) + if err != nil { + t.Fatal(err) + } + if want, got := http.StatusOK, resHTTPS.StatusCode; want != got { + t.Errorf("want https response status %d, got %d", want, got) + } + if got := resHTTPS.TLS; got == nil { + t.Error("https response tls info was nil") + } +} + +var ( + anchorCA = must.CA(&x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Example CA - AnchorCA", + Organization: []string{"Example, Inc"}, + }, + KeyUsage: x509.KeyUsageCertSign, + IsCA: true, + }) + + subCA = anchorCA.Issue(&x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Example CA - SubCA", + Organization: []string{"Example, Inc"}, + }, + KeyUsage: x509.KeyUsageCertSign, + IsCA: true, + BasicConstraintsValid: true, + }) + + leaf = subCA.Issue(&x509.Certificate{ + Subject: pkix.Name{ + CommonName: "example.lcl.host.test", + Organization: []string{"Example, Inc"}, + }, + + DNSNames: []string{"example.lcl.host.test", "*.example.lcl.host.test"}, + }) +) diff --git a/ext509/anchor.go b/ext509/anchor.go new file mode 100644 index 0000000..13cf3ed --- /dev/null +++ b/ext509/anchor.go @@ -0,0 +1,95 @@ +package ext509 + +import ( + "crypto/x509/pkix" + "errors" + "time" + + "golang.org/x/crypto/cryptobyte" + xasn1 "golang.org/x/crypto/cryptobyte/asn1" + + "github.com/anchordotdev/cli/ext509/oid" +) + +// AnchorCertificate is a custom X.509 certificate extension used by Anchor +// Security, Inc. to include important certificate metadata. +type AnchorCertificate struct { + AutoRenewAt time.Time + RenewAfter time.Time +} + +func (ac AnchorCertificate) Extension() (pkix.Extension, error) { + var b cryptobyte.Builder + b.AddValue(ac) + + buf, err := b.Bytes() + if err != nil { + return pkix.Extension{}, err + } + + return pkix.Extension{ + Id: oid.AnchorCertificateExtension, + Critical: false, + Value: buf, + }, nil +} + +var ( + tagAutoRenewAt = xasn1.Tag(1).Constructed().ContextSpecific() + tagRenewAfter = xasn1.Tag(2).Constructed().ContextSpecific() +) + +func (ac AnchorCertificate) Marshal(b *cryptobyte.Builder) error { + // Anchor ::= SEQUENCE { + // _reserved_ [0] RESERVED OPTIONAL, + // autoRenewAt [1] GeneralizedTime OPTIONAL, + // renewAfter [2] GeneralizedTime OPTIONAL } + b.AddASN1(xasn1.SEQUENCE, func(b *cryptobyte.Builder) { + if !ac.AutoRenewAt.IsZero() { + b.AddASN1(tagAutoRenewAt, func(b *cryptobyte.Builder) { + b.AddASN1GeneralizedTime(ac.AutoRenewAt.UTC().Round(time.Second)) + }) + } + + if !ac.RenewAfter.IsZero() { + b.AddASN1(tagRenewAfter, func(b *cryptobyte.Builder) { + b.AddASN1GeneralizedTime(ac.RenewAfter.UTC().Round(time.Second)) + }) + } + }) + return nil +} + +func (ac *AnchorCertificate) Unmarshal(ext pkix.Extension) error { + if !ext.Id.Equal(oid.AnchorCertificateExtension) || ext.Critical { + return errors.New("ext509: not an Anchor Certificate Extension") + } + + input := cryptobyte.String(ext.Value) + if !input.ReadASN1(&input, xasn1.SEQUENCE) { + return errors.New("ext509: malformed Anchor Certificate Extension") + } + + for !input.Empty() { + var ( + buf cryptobyte.String + tag xasn1.Tag + ) + + if !input.ReadAnyASN1(&buf, &tag) { + return errors.New("ext509: malformed Anchor Certificate Extension") + } + + switch tag { + case tagAutoRenewAt: + if !buf.ReadASN1GeneralizedTime(&ac.AutoRenewAt) { + return errors.New("ext509: malformed Anchor Certificate Extension: autoRenewAt") + } + case tagRenewAfter: + if !buf.ReadASN1GeneralizedTime(&ac.RenewAfter) { + return errors.New("ext509: malformed Anchor Certificate Extension: renewAfter") + } + } + } + return nil +} diff --git a/ext509/oid/oid.go b/ext509/oid/oid.go new file mode 100644 index 0000000..782c981 --- /dev/null +++ b/ext509/oid/oid.go @@ -0,0 +1,8 @@ +package oid + +import "encoding/asn1" + +var ( + AnchorPEN = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 60900} + AnchorCertificateExtension = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 60900, 1} +) diff --git a/go.mod b/go.mod index e764376..466173c 100644 --- a/go.mod +++ b/go.mod @@ -4,21 +4,28 @@ go 1.21 require ( github.com/MakeNowJust/heredoc v1.0.0 + github.com/atotto/clipboard v0.1.4 + github.com/charmbracelet/bubbles v0.17.2-0.20240108170749-ec883029c8e6 + github.com/charmbracelet/bubbletea v0.25.0 + github.com/charmbracelet/lipgloss v0.9.1 + github.com/charmbracelet/x/exp/teatest v0.0.0-20240202113029-6ff29cf0473e github.com/cli/browser v1.3.0 github.com/creack/pty v1.1.21 github.com/deepmap/oapi-codegen v1.16.2 github.com/gofrs/flock v0.8.1 github.com/google/go-github/v54 v54.0.0 + github.com/inetaf/tcpproxy v0.0.0-20240214030015-3ce58045626c github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd github.com/mattn/go-isatty v0.0.20 github.com/mcuadros/go-defaults v1.2.0 github.com/muesli/termenv v0.15.2 - github.com/oapi-codegen/runtime v1.1.0 + github.com/oapi-codegen/runtime v1.1.1 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 github.com/zalando/go-keyring v0.2.3 + golang.org/x/crypto v0.18.0 golang.org/x/exp v0.0.0-20231006140011-7918f672742d - golang.org/x/sync v0.5.0 + golang.org/x/sync v0.6.0 howett.net/plist v1.0.1 ) @@ -26,7 +33,9 @@ require ( github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect github.com/alessio/shellescape v1.4.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/cloudflare/circl v1.3.3 // indirect + github.com/aymanbagabas/go-udiff v0.2.0 // indirect + github.com/cloudflare/circl v1.3.7 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/danieljoos/wincred v1.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/getkin/kin-openapi v0.118.0 // indirect @@ -36,24 +45,30 @@ require ( github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/google/uuid v1.4.0 // indirect + github.com/google/uuid v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/yaml v0.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/perimeterx/marshmallow v1.1.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect - golang.org/x/crypto v0.17.0 // indirect + github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect golang.org/x/mod v0.14.0 // indirect + golang.org/x/net v0.20.0 // indirect golang.org/x/oauth2 v0.13.0 // indirect - golang.org/x/sys v0.15.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/term v0.16.0 // indirect golang.org/x/text v0.14.0 // indirect - golang.org/x/tools v0.14.0 // indirect + golang.org/x/tools v0.16.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/protobuf v1.31.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 18f5e6a..4c69e48 100644 --- a/go.sum +++ b/go.sum @@ -4,14 +4,29 @@ github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwF github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= +github.com/armon/go-proxyproto v0.0.0-20210323213023-7e956b284f0a/go.mod h1:QmP9hvJ91BbJmGVGSbutW19IC0Q9phDCLGaomwTJbgU= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/charmbracelet/bubbles v0.17.2-0.20240108170749-ec883029c8e6 h1:6nVCV8pqGaeyxetur3gpX3AAaiyKgzjIoCPV3NXKZBE= +github.com/charmbracelet/bubbles v0.17.2-0.20240108170749-ec883029c8e6/go.mod h1:9HxZWlkCqz2PRwsCbYl7a3KXvGzFaDHpYbSYMJ+nE3o= +github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= +github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= +github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= +github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= +github.com/charmbracelet/x/exp/teatest v0.0.0-20240202113029-6ff29cf0473e h1:cJmZ2KOD9/EzIX2ZQKR/VUNbs+rNfIg4x9GI9NAfbss= +github.com/charmbracelet/x/exp/teatest v0.0.0-20240202113029-6ff29cf0473e/go.mod h1:OZ61R8FnVvNSCTOzAprW6avzlYA4xd9h0PFi79QtMH0= github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= -github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= -github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= +github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= @@ -49,11 +64,13 @@ github.com/google/go-github/v54 v54.0.0 h1:OZdXwow4EAD5jEo5qg+dGFH2DpkyZvVsAehjv github.com/google/go-github/v54 v54.0.0/go.mod h1:Sw1LXWHhXRZtzJ9LI5fyJg9wbQzYvFhW8W5P2yaAQ7s= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= -github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/inetaf/tcpproxy v0.0.0-20240214030015-3ce58045626c h1:gYfYE403/nlrGNYj6BEOs9ucLCAGB9gstlSk92DttTg= +github.com/inetaf/tcpproxy v0.0.0-20240214030015-3ce58045626c/go.mod h1:Di7LXRyUcnvAcLicFhtM9/MlZl/TNgRSDHORM2c6CMI= github.com/invopop/yaml v0.1.0 h1:YW3WGUoJEXYfzWBjn00zIlrw7brGVD0fUKRYDPAPhrc= github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= @@ -68,6 +85,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -76,28 +95,40 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mcuadros/go-defaults v1.2.0 h1:FODb8WSf0uGaY8elWJAkoLL0Ri6AlZ1bFlenk56oZtc= github.com/mcuadros/go-defaults v1.2.0/go.mod h1:WEZtHEVIGYVDqkKSWBdWKUVdRyKlMfulPaGDWIVeCWY= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/oapi-codegen/runtime v1.1.0 h1:rJpoNUawn5XTvekgfkvSZr0RqEnoYpFkyvrzfWeFKWM= -github.com/oapi-codegen/runtime v1.1.0/go.mod h1:BeSfBkWWWnAnGdyS+S/GnlbmHKzf8/hwkvelJZDeKA8= +github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= +github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/perimeterx/marshmallow v1.1.4 h1:pZLDH9RjlLGGorbXhcaQLhfuV0pFMNfPO55FuFkxqLw= github.com/perimeterx/marshmallow v1.1.4/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= +github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -122,8 +153,8 @@ github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97 github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= @@ -134,23 +165,28 @@ golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= -golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -160,8 +196,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= -golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= +golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM= +golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= diff --git a/internal/must/x509.go b/internal/must/x509.go new file mode 100644 index 0000000..0bd3c47 --- /dev/null +++ b/internal/must/x509.go @@ -0,0 +1,134 @@ +package must + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "time" +) + +type Certificate tls.Certificate + +func CA(template *x509.Certificate) *Certificate { + template.IsCA = true + template.BasicConstraintsValid = true + + return Cert(template).SelfSign() +} + +func Cert(template *x509.Certificate) *Certificate { + leaf := new(x509.Certificate) + *leaf = *template + + sigAlgo := leaf.SignatureAlgorithm + if sigAlgo == x509.UnknownSignatureAlgorithm { + sigAlgo = x509.ECDSAWithSHA256 + } + + priv := generateKey(sigAlgo) + if leaf.PublicKey == nil { + leaf.PublicKey = priv.Public() + } + + if leaf.SerialNumber == nil { + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + panic(err) + } + + leaf.SerialNumber = serialNumber + leaf.SubjectKeyId = serialNumber.Bytes() + } + + if leaf.NotAfter.IsZero() { + now := time.Now() + + leaf.NotBefore = now.Add(-5 * time.Second) + leaf.NotAfter = now.Add(5 * time.Minute) + } + + return &Certificate{ + PrivateKey: priv, + Leaf: leaf, + } +} + +func Load(data string) *Certificate { + blk, _ := pem.Decode([]byte(data)) + cert, err := x509.ParseCertificate(blk.Bytes) + if err != nil { + panic(err) + } + return Cert(cert) +} + +func (c *Certificate) New(name string) *Certificate { + template := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: name, + }, + DNSNames: []string{name}, + } + + return c.Sign(Cert(template)) +} + +func (c *Certificate) Issue(template *x509.Certificate) *Certificate { + return c.Sign(Cert(template)) +} + +func (c *Certificate) SelfSign() *Certificate { return c.Sign(c) } + +func (c *Certificate) Sign(template *Certificate) *Certificate { + cert, err := x509.CreateCertificate(rand.Reader, template.Leaf, c.Leaf, template.Leaf.PublicKey, c.PrivateKey) + if err != nil { + panic(err) + } + + leaf, err := x509.ParseCertificate(cert) + if err != nil { + panic(err) + } + + return &Certificate{ + Certificate: append([][]byte{cert}, c.Certificate...), + PrivateKey: template.PrivateKey, + Leaf: leaf, + } +} + +func (c *Certificate) CertPool() *x509.CertPool { + pool := x509.NewCertPool() + pool.AddCert(c.X509()) + return pool +} + +func (c *Certificate) TLS() tls.Certificate { return (tls.Certificate)(*c) } + +func (c *Certificate) X509() *x509.Certificate { return c.Leaf } + +func generateKey(sa x509.SignatureAlgorithm) (key crypto.Signer) { + var err error + switch sa { + case x509.MD2WithRSA, x509.MD5WithRSA, x509.SHA1WithRSA, x509.SHA256WithRSA, x509.SHA384WithRSA, x509.SHA512WithRSA, x509.SHA256WithRSAPSS, x509.SHA384WithRSAPSS, x509.SHA512WithRSAPSS: + key, err = rsa.GenerateKey(rand.Reader, 2048) + case x509.ECDSAWithSHA1, x509.ECDSAWithSHA256, x509.ECDSAWithSHA384, x509.ECDSAWithSHA512: + key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + case x509.PureEd25519: + _, key, err = ed25519.GenerateKey(rand.Reader) + } + + if err != nil { + panic(err) + } + return key +} diff --git a/lcl/detect.go b/lcl/detect.go new file mode 100644 index 0000000..407cc21 --- /dev/null +++ b/lcl/detect.go @@ -0,0 +1,139 @@ +package lcl + +import ( + "context" + "errors" + "net/url" + "os" + "path/filepath" + + "github.com/cli/browser" + + "github.com/anchordotdev/cli" + "github.com/anchordotdev/cli/api" + "github.com/anchordotdev/cli/cert" + "github.com/anchordotdev/cli/detection" + "github.com/anchordotdev/cli/lcl/models" + "github.com/anchordotdev/cli/ui" +) + +type Detect struct { + Config *cli.Config + + anc *api.Session + orgSlug string +} + +func (d Detect) UI() cli.UI { + return cli.UI{ + RunTUI: d.run, + } +} + +func (d Detect) run(ctx context.Context, drv *ui.Driver) error { + drv.Activate(ctx, new(models.DetectPreamble)) + + path, err := os.Getwd() + if err != nil { + return err + } + + detectors := detection.DefaultDetectors + if d.Config.Lcl.Detect.Language != "" { + if langDetector, ok := detection.DetectorsByFlag[d.Config.Lcl.Detect.Language]; !ok { + return errors.New("invalid language specified") + } else { + detectors = []detection.Detector{langDetector} + } + } + + results, err := detection.Perform(detectors, path) + if err != nil { + return err + } + drv.Send(results) + + choicec := make(chan string) + drv.Activate(ctx, &models.DetectCategory{ + ChoiceCh: choicec, + Results: results, + }) + + var serviceCategory string + select { + case serviceCategory = <-choicec: + case <-ctx.Done(): + return ctx.Err() + } + + inputc := make(chan string) + drv.Activate(ctx, &models.DetectName{ + InputCh: inputc, + Default: filepath.Base(path), // TODO: use detected name recommendation + }) + + var serviceName string + select { + case serviceName = <-inputc: + case <-ctx.Done(): + return ctx.Err() + } + + inputc = make(chan string) + drv.Activate(ctx, &models.DomainInput{ + InputCh: inputc, + Default: serviceName, + TLD: "lcl.host", + SkipHeader: true, + }) + + var serviceSubdomain string + select { + case serviceSubdomain = <-inputc: + case <-ctx.Done(): + return ctx.Err() + } + + domains := []string{serviceSubdomain + ".lcl.host", serviceSubdomain + ".localhost"} + + cmdProvision := &Provision{ + Config: d.Config, + Domains: domains, + orgSlug: d.orgSlug, + realmSlug: "localhost", + } + + service, _, _, tlsCert, err := cmdProvision.run(ctx, drv, d.anc, serviceName, serviceCategory) + if err != nil { + return err + } + + cmdCert := cert.Provision{ + Cert: tlsCert, + } + + if err := cmdCert.RunTUI(ctx, drv, domains...); err != nil { + return err + } + + setupGuideURL := d.Config.AnchorURL + "/" + url.QueryEscape(d.orgSlug) + "/services/" + url.QueryEscape(service.Slug) + "/guide" + setupGuideConfirmCh := make(chan struct{}) + + drv.Activate(ctx, &models.SetupGuidePrompt{ + ConfirmCh: setupGuideConfirmCh, + }) + + drv.Send(models.OpenSetupGuideMsg(setupGuideURL)) + + select { + case <-setupGuideConfirmCh: + case <-ctx.Done(): + return ctx.Err() + } + + if err := browser.OpenURL(setupGuideURL); err != nil { + return err + } + + return nil +} diff --git a/lcl/diagnostic.go b/lcl/diagnostic.go new file mode 100644 index 0000000..6f70d40 --- /dev/null +++ b/lcl/diagnostic.go @@ -0,0 +1,150 @@ +package lcl + +import ( + "context" + "crypto/tls" + "net" + "net/url" + + "github.com/cli/browser" + + "github.com/anchordotdev/cli" + "github.com/anchordotdev/cli/api" + "github.com/anchordotdev/cli/diagnostic" + "github.com/anchordotdev/cli/lcl/models" + "github.com/anchordotdev/cli/trust" + "github.com/anchordotdev/cli/ui" +) + +type Diagnostic struct { + Config *cli.Config + + anc *api.Session + orgSlug, realmSlug string +} + +func (d *Diagnostic) runTUI(ctx context.Context, drv *ui.Driver, cert *tls.Certificate) error { + _, diagPort, err := net.SplitHostPort(d.Config.Lcl.DiagnosticAddr) + if err != nil { + return err + } + + domain := cert.Leaf.Subject.CommonName + + var requestedScheme string + + // FIXME: ? spinner while booting server, transitioning to server booted message + srvDiag := &diagnostic.Server{ + Addr: d.Config.Lcl.DiagnosticAddr, + LclHostURL: d.Config.Lcl.LclHostURL, + GetCertificate: func(cii *tls.ClientHelloInfo) (*tls.Certificate, error) { + return cert, nil + }, + } + + if err := srvDiag.Start(ctx); err != nil { + return err + } + requestc := srvDiag.RequestChan() + + auditInfo, err := trust.PerformAudit(ctx, d.Config, d.anc, d.orgSlug, d.realmSlug) + if err != nil { + return err + } + + // If no certificates are missing, skip http and go directly to https + if len(auditInfo.Missing) != 0 { + httpURL, err := url.Parse("http://" + domain + ":" + diagPort) + if err != nil { + return err + } + httpConfirmCh := make(chan struct{}) + + drv.Activate(ctx, &models.Diagnostic{ + ConfirmCh: httpConfirmCh, + + Domain: domain, + Port: diagPort, + Scheme: "http", + ShowHeader: true, + }) + + drv.Send(models.OpenURLMsg(httpURL.String())) + + select { + case <-httpConfirmCh: + case <-ctx.Done(): + return ctx.Err() + } + + if !d.Config.Trust.MockMode { + if err := browser.OpenURL(httpURL.String()); err != nil { + return err + } + } + + select { + case requestedScheme = <-requestc: + case <-ctx.Done(): + return ctx.Err() + } + + if requestedScheme == "https" { + // TODO: skip to "detect" + drv.Activate(ctx, new(models.DiagnosticSuccess)) + return nil + } + + cmdTrustSync := &trust.Sync{ + Config: d.Config, + Anc: d.anc, + OrgSlug: d.orgSlug, + RealmSlug: d.realmSlug, + } + + if err := cmdTrustSync.UI().RunTUI(ctx, drv); err != nil { + return err + } + } + + httpsURL, err := url.Parse("https://" + domain + ":" + diagPort) + if err != nil { + return err + } + httpsConfirmCh := make(chan struct{}) + + drv.Activate(ctx, &models.Diagnostic{ + ConfirmCh: httpsConfirmCh, + + Domain: domain, + Port: diagPort, + Scheme: "https", + ShowHeader: true, + }) + + drv.Send(models.OpenURLMsg(httpsURL.String())) + + select { + case <-httpsConfirmCh: + case <-ctx.Done(): + return ctx.Err() + } + + if !d.Config.Trust.MockMode { + if err := browser.OpenURL(httpsURL.String()); err != nil { + return err + } + } + + for requestedScheme != "https" { + select { + case requestedScheme = <-requestc: + case <-ctx.Done(): + return ctx.Err() + } + } + + drv.Activate(ctx, new(models.DiagnosticSuccess)) + + return nil +} diff --git a/lcl/lcl.go b/lcl/lcl.go new file mode 100644 index 0000000..6bea12f --- /dev/null +++ b/lcl/lcl.go @@ -0,0 +1,202 @@ +package lcl + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "time" + + "golang.org/x/crypto/acme" + "golang.org/x/crypto/acme/autocert" + + "github.com/anchordotdev/cli" + "github.com/anchordotdev/cli/api" + "github.com/anchordotdev/cli/auth" + "github.com/anchordotdev/cli/lcl/models" + "github.com/anchordotdev/cli/trust" + "github.com/anchordotdev/cli/ui" +) + +type Command struct { + Config *cli.Config +} + +func (c Command) UI() cli.UI { + return cli.UI{ + RunTUI: c.run, + } +} + +func (c *Command) run(ctx context.Context, drv *ui.Driver) error { + drv.Activate(ctx, &models.LclPreamble{}) + + anc, err := api.NewClient(c.Config) + if errors.Is(err, api.ErrSignedOut) { + if err := c.runSignIn(ctx, drv); err != nil { + return err + } + if anc, err = api.NewClient(c.Config); err != nil { + return err + } + } + + userInfo, err := anc.UserInfo(ctx) + if errors.Is(err, api.ErrSignedOut) { + if err := c.runSignIn(ctx, drv); err != nil { + return err + } + if anc, err = api.NewClient(c.Config); err != nil { + return err + } + if userInfo, err = anc.UserInfo(ctx); err != nil { + return err + } + } else if err != nil { + return err + } + + orgSlug := userInfo.PersonalOrg.Slug + realmSlug := "localhost" + + drv.Activate(ctx, &models.LclScan{}) + + auditInfo, err := trust.PerformAudit(ctx, c.Config, anc, orgSlug, realmSlug) + if err != nil { + return err + } + + var diagnosticService *api.Service + services, err := anc.GetOrgServices(ctx, orgSlug) + if err != nil { + return err + } + for _, service := range services { + if service.ServerType == "diagnostic" { + diagnosticService = &service + } + } + + drv.Send(models.ScanFinishedMsg{}) + + if diagnosticService == nil || len(auditInfo.Missing) != 0 { + inputc := make(chan string) + drv.Activate(ctx, &models.DomainInput{ + InputCh: inputc, + Default: "hi-" + orgSlug, + TLD: "lcl.host", + }) + + var serviceName string + select { + case serviceName = <-inputc: + case <-ctx.Done(): + return ctx.Err() + } + + domains := []string{serviceName + ".lcl.host", serviceName + ".localhost"} + + cmdProvision := &Provision{ + Config: c.Config, + Domains: domains, + orgSlug: orgSlug, + realmSlug: realmSlug, + } + + _, _, _, cert, err := cmdProvision.run(ctx, drv, anc, serviceName, "diagnostic") + if err != nil { + return err + } + + cmdDiagnostic := &Diagnostic{ + Config: c.Config, + anc: anc, + orgSlug: orgSlug, + realmSlug: realmSlug, + } + + if err := cmdDiagnostic.runTUI(ctx, drv, cert); err != nil { + return err + } + } + + // run detect command + + cmdDetect := &Detect{ + Config: c.Config, + anc: anc, + orgSlug: orgSlug, + } + + if err := cmdDetect.run(ctx, drv); err != nil { + return err + } + + return nil +} + +func (c *Command) runSignIn(ctx context.Context, drv *ui.Driver) error { + cmdSignIn := &auth.SignIn{ + Config: c.Config, + Preamble: ui.StepHint("You need to signin first, so we can track resources for you."), + Source: "lclhost", + } + return cmdSignIn.RunTUI(ctx, drv) +} + +func provisionCert(eab *api.Eab, domains []string, acmeURL string) (*tls.Certificate, error) { + hmacKey, err := base64.URLEncoding.DecodeString(eab.HmacKey) + if err != nil { + return nil, err + } + + mgr := &autocert.Manager{ + Prompt: autocert.AcceptTOS, + HostPolicy: autocert.HostWhitelist(domains...), + Client: &acme.Client{ + DirectoryURL: acmeURL, + }, + ExternalAccountBinding: &acme.ExternalAccountBinding{ + KID: eab.Kid, + Key: hmacKey, + }, + RenewBefore: 24 * time.Hour, + } + + // TODO: switch to using ACME package here, so that extra domains can be sent through for SAN extension + clientHello := &tls.ClientHelloInfo{ + ServerName: domains[0], + CipherSuites: []uint16{tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256}, + } + + return mgr.GetCertificate(clientHello) +} + +func createEAB(anc *http.Client, chainParam string, orgParam string, realmParam string, serviceParam string, subCaParam string) (*api.Eab, error) { + eabBody := new(bytes.Buffer) + eabReq := api.CreateEabTokenJSONRequestBody{} + eabReq.Relationships.Chain.Slug = chainParam + eabReq.Relationships.Organization.Slug = orgParam + eabReq.Relationships.Realm.Slug = realmParam + eabReq.Relationships.Service.Slug = &serviceParam + eabReq.Relationships.SubCa.Slug = subCaParam + + if err := json.NewEncoder(eabBody).Encode(eabReq); err != nil { + return nil, err + } + eabRes, err := anc.Post("/acme/eab-tokens", "application/json", eabBody) + if err != nil { + return nil, err + } + if eabRes.StatusCode != http.StatusOK { + return nil, errors.New("unexpected response") + } + var eab api.Eab + if err := json.NewDecoder(eabRes.Body).Decode(&eab); err != nil { + return nil, err + } + return &eab, nil +} diff --git a/lcl/lcl_test.go b/lcl/lcl_test.go new file mode 100644 index 0000000..95b3d75 --- /dev/null +++ b/lcl/lcl_test.go @@ -0,0 +1,286 @@ +package lcl + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "flag" + "net" + "net/http" + "os" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/teatest" + + "github.com/anchordotdev/cli" + "github.com/anchordotdev/cli/api/apitest" + "github.com/anchordotdev/cli/truststore" + "github.com/anchordotdev/cli/ui/uitest" +) + +var srv = &apitest.Server{ + Host: "api.anchor.lcl.host", + RootDir: "../..", +} + +func TestMain(m *testing.M) { + flag.Parse() + + if err := srv.Start(context.Background()); err != nil { + panic(err) + } + + defer os.Exit(m.Run()) + + srv.Close() +} + +func TestLcl(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + diagAddr, err := apitest.UnusedPort() + if err != nil { + t.Fatal(err) + } + + _, diagPort, err := net.SplitHostPort(diagAddr) + if err != nil { + t.Fatal(err) + } + + httpURL := "http://hello-world.lcl.host:" + diagPort + httpsURL := "https://hello-world.lcl.host:" + diagPort + + cfg := new(cli.Config) + cfg.API.URL = srv.URL + cfg.AnchorURL = "http://anchor.lcl.host:" + srv.RailsPort + "/" + cfg.Lcl.DiagnosticAddr = diagAddr + cfg.Lcl.Service = "hi-example" + cfg.Lcl.Subdomain = "hi-example" + cfg.Trust.MockMode = true + cfg.Trust.NoSudo = true + cfg.Trust.Stores = []string{"mock"} + + if cfg.API.Token, err = srv.GeneratePAT("example@example.com"); err != nil { + t.Fatal(err) + } + + t.Run("basics", func(t *testing.T) { + drv, tm := uitest.TestTUI(ctx, t) + + cmd := Command{ + Config: cfg, + } + + errc := make(chan error, 1) + go func() { errc <- cmd.UI().RunTUI(ctx, drv) }() + + // wait for prompt + + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + if len(errc) > 0 { + t.Fatal(<-errc) + } + + return bytes.Contains(bts, []byte("? What lcl.host domain would you like to use for diagnostics?")) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + tm.Type("hello-world") + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + + if !srv.IsProxy() { + t.Skip("diagnostic unsupported in non-mock mode") + } + + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + if len(errc) > 0 { + t.Fatal(<-errc) + } + + expect := "Entered hello-world.lcl.host domain for lcl.host diagnostic certificate." + + return bytes.Contains(bts, []byte(expect)) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + if len(errc) > 0 { + t.Fatal(<-errc) + } + + expect := "! Press Enter to test " + httpURL + ". (without HTTPS)" + return bytes.Contains(bts, []byte(expect)) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + + if _, err := http.Get(httpURL); err != nil { + t.Fatal(err) + } + + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + if len(errc) > 0 { + t.Fatal(<-errc) + } + + expect := "! Press Enter to install 2 missing certificates. (requires sudo)" + return bytes.Contains(bts, []byte(expect)) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + if len(errc) > 0 { + t.Fatal(<-errc) + } + + expect := "! Press Enter to test " + httpsURL + ". (with HTTPS)" + return bytes.Contains(bts, []byte(expect)) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + + pool := x509.NewCertPool() + for _, ca := range truststore.MockCAs { + pool.AddCert(ca.Certificate) + } + + httpsClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: pool, + }, + }, + } + + if _, err := httpsClient.Get(httpsURL); err != nil { + t.Fatal(err) + } + + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + if len(errc) > 0 { + t.Fatal(<-errc) + } + + expect := "- Scanned current directory." + return bytes.Contains(bts, []byte(expect)) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + t.Skip("pending ability to stub out the detection.Perform results") + + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + if len(errc) > 0 { + t.Fatal(<-errc) + } + + expect := "What is your application server type?" + + return bytes.Contains(bts, []byte(expect)) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + if len(errc) > 0 { + t.Fatal(<-errc) + } + + expect := "? What is your application name?" + return bytes.Contains(bts, []byte(expect)) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*5), + ) + + tm.Type("test-app") + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + if len(errc) > 0 { + t.Fatal(<-errc) + } + + expect := "? What lcl.host subdomain would you like to use?" + return bytes.Contains(bts, []byte(expect)) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + if len(errc) > 0 { + if err != nil { + t.Fatal(<-errc) + } + } + + expect := "- Created test-app resources for lcl.host diagnostic server on Anchor.dev." + return bytes.Contains(bts, []byte(expect)) + }, + teatest.WithCheckInterval(time.Millisecond*1000), + teatest.WithDuration(time.Second*30), + ) + + // TODO: add tests once thing settle down + }) +} diff --git a/lcl/models/detect.go b/lcl/models/detect.go new file mode 100644 index 0000000..0bf075f --- /dev/null +++ b/lcl/models/detect.go @@ -0,0 +1,250 @@ +package models + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/anchordotdev/cli/detection" + "github.com/anchordotdev/cli/ui" +) + +type DetectPreamble struct { + results detection.Results + + spinner spinner.Model + finished bool +} + +func (m *DetectPreamble) Init() tea.Cmd { + m.spinner = ui.Spinner() + + return m.spinner.Tick +} + +func (m *DetectPreamble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case detection.Results: + m.finished = true + + return m, nil + default: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } +} + +func (m *DetectPreamble) View() string { + var b strings.Builder + fmt.Fprintln(&b, ui.Header("Configure lcl.host Integration")) + fmt.Fprintln(&b, ui.StepHint("We will integrate your application with the help of educated guesses about the current directory.")) + + if !m.finished { + fmt.Fprintln(&b, ui.StepInProgress("Scanning current directory..."+m.spinner.View())) + return b.String() + } + fmt.Fprintln(&b, ui.StepDone("Scanned current directory.")) + + return b.String() +} + +type DetectCategory struct { + ChoiceCh chan<- string + Results detection.Results + + list list.Model + choice string +} + +func (m *DetectCategory) Init() tea.Cmd { + var items []ui.ListItem[string] + for _, match := range m.Results[detection.High] { + item := ui.ListItem[string]{ + Key: match.AnchorCategory.Key, + Value: ui.Titlize(match.Detector.GetTitle()), + } + + items = append(items, item) + } + + for _, match := range m.Results[detection.Medium] { + item := ui.ListItem[string]{ + Key: match.AnchorCategory.Key, + Value: match.Detector.GetTitle(), + } + + items = append(items, item) + } + for _, match := range m.Results[detection.Low] { + item := ui.ListItem[string]{ + Key: match.AnchorCategory.Key, + Value: ui.Whisper(match.Detector.GetTitle()), + } + + items = append(items, item) + } + + m.list = ui.List(items) + + return nil +} + +func (m *DetectCategory) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + + if item, ok := m.list.SelectedItem().(ui.ListItem[string]); ok { + m.choice = item.Key + if m.ChoiceCh != nil { + m.ChoiceCh <- m.choice + close(m.ChoiceCh) + m.ChoiceCh = nil + } + } + + return m, cmd + } + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m *DetectCategory) View() string { + var b strings.Builder + + if m.ChoiceCh != nil { + fmt.Fprintln(&b, ui.StepPrompt("What is your application server type?")) + fmt.Fprintln(&b, m.list.View()) + + return b.String() + } + + fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Entered %s application server type.", ui.Emphasize(m.choice)))) + + return b.String() +} + +type DetectName struct { + InputCh chan<- string + + Default string + + input *textinput.Model + choice string +} + +func (m *DetectName) Init() tea.Cmd { + ti := textinput.New() + ti.Prompt = "" + ti.Cursor.Style = ui.Prompt + ti.Focus() + + if len(m.Default) > 0 { + ti.Placeholder = m.Default + } + + m.input = &ti + + return textinput.Blink +} + +func (m *DetectName) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + if m.InputCh != nil { + value := m.input.Value() + if value == "" { + value = m.Default + } + + m.choice = value + m.InputCh <- value + m.InputCh = nil + } + return m, nil + case tea.KeyCtrlC, tea.KeyEsc: + return m, tea.Quit + } + } + + ti, cmd := m.input.Update(msg) + m.input = &ti + return m, cmd +} + +func (m *DetectName) View() string { + var b strings.Builder + + if m.InputCh != nil { + fmt.Fprintln(&b, ui.StepPrompt("What is your application name?")) + fmt.Fprintln(&b, ui.StepPrompt(m.input.View())) + return b.String() + } + + fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Entered %s application name.", ui.Emphasize(m.choice)))) + + return b.String() +} + +type SetupGuidePrompt struct { + ConfirmCh chan<- struct{} + + confirmCh chan<- struct{} + url string +} + +type OpenSetupGuideMsg string + +func (SetupGuidePrompt) Init() tea.Cmd { return nil } + +func (m *SetupGuidePrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case OpenSetupGuideMsg: + if m.url == "" { + m.url = string(msg) + m.confirmCh = m.ConfirmCh + } + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + if m.confirmCh != nil { + close(m.confirmCh) + m.confirmCh = nil + } + } + } + + return m, nil +} + +func (m SetupGuidePrompt) View() string { + var b strings.Builder + + if m.url == "" { + return b.String() + } + + if m.confirmCh != nil { + fmt.Fprintln(&b, ui.StepAlert(fmt.Sprintf("%s to open %s.", + ui.Action("Press Enter"), + ui.URL(m.url), + ))) + } else { + fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Opened %s.", ui.URL(m.url)))) + } + + return b.String() +} diff --git a/lcl/models/diagnostic.go b/lcl/models/diagnostic.go new file mode 100644 index 0000000..00f1837 --- /dev/null +++ b/lcl/models/diagnostic.go @@ -0,0 +1,86 @@ +package models + +import ( + "fmt" + "strings" + + "github.com/anchordotdev/cli/ui" + tea "github.com/charmbracelet/bubbletea" +) + +type Diagnostic struct { + ConfirmCh chan<- struct{} + + Domain, Port, Scheme string + ShowHeader bool + + confirmCh chan<- struct{} + url string +} + +func (Diagnostic) Init() tea.Cmd { return nil } + +type OpenURLMsg string + +func (m *Diagnostic) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case OpenURLMsg: + if m.url == "" { + m.url = string(msg) + m.confirmCh = m.ConfirmCh + } + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + if m.confirmCh != nil { + close(m.confirmCh) + m.confirmCh = nil + } + } + } + + return m, nil +} + +func (m Diagnostic) View() string { + var b strings.Builder + + if m.url == "" { + return b.String() + } + + var schemeMessage string + if m.Scheme == "http" { + schemeMessage = "without HTTPS" + } else { + schemeMessage = "with HTTPS" + } + + if m.confirmCh != nil { + fmt.Fprintln(&b, ui.StepAlert(fmt.Sprintf("%s to test %s. (%s)", + ui.Action("Press Enter"), + ui.URL(m.url), + ui.Accentuate(schemeMessage)))) + } else { + fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Tested %s. (%s)", + ui.URL(m.url), + ui.Accentuate(schemeMessage), + ))) + } + + return b.String() +} + +type DiagnosticSuccess struct { + Org, Realm, CA string +} + +func (DiagnosticSuccess) Init() tea.Cmd { return nil } + +func (m DiagnosticSuccess) Update(tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + +func (m DiagnosticSuccess) View() string { + var b strings.Builder + fmt.Fprintln(&b, ui.StepDone("Confirmed your lcl.host Local HTTPS setup.")) + return b.String() +} diff --git a/lcl/models/lcl.go b/lcl/models/lcl.go new file mode 100644 index 0000000..ce00079 --- /dev/null +++ b/lcl/models/lcl.go @@ -0,0 +1,186 @@ +package models + +import ( + "fmt" + "strings" + + "github.com/anchordotdev/cli/ui" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type LclPreamble struct{} + +func (LclPreamble) Init() tea.Cmd { return nil } + +func (m LclPreamble) Update(tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + +func (m LclPreamble) View() string { + var b strings.Builder + fmt.Fprintln(&b, ui.Hint("Anchor + lcl.host provides fast, free, magic HTTPS for local applications and services.")) + fmt.Fprintln(&b, ui.Hint("")) + fmt.Fprintln(&b, ui.Hint("Let's setup your HTTPS secured development environment and dev/prod parity!")) + return b.String() +} + +type ( + ScanFinishedMsg struct{} +) + +type LclScan struct { + finished bool + + spinner spinner.Model +} + +func (m *LclScan) Init() tea.Cmd { + m.spinner = ui.Spinner() + + return m.spinner.Tick +} + +func (m *LclScan) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case ScanFinishedMsg: + m.finished = true + return m, nil + default: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } +} + +func (m *LclScan) View() string { + var b strings.Builder + fmt.Fprintln(&b, ui.Header("Set Up lcl.host Local HTTPS Diagnostic")) + fmt.Fprintln(&b, ui.StepHint("We will start by determining your system's starting point for setup.")) + + if !m.finished { + fmt.Fprintln(&b, ui.StepInProgress(fmt.Sprintf("Scanning local configuration and status…%s", m.spinner.View()))) + } else { + fmt.Fprintln(&b, ui.StepDone("Scanned local configuration and status.")) + } + return b.String() +} + +type DomainInput struct { + InputCh chan<- string + + Default string + Domain string + TLD string + SkipHeader bool + + input *textinput.Model +} + +func (m *DomainInput) Init() tea.Cmd { + ti := textinput.New() + ti.Prompt = "" + ti.Cursor.Style = ui.Prompt + ti.Focus() + ti.ShowSuggestions = true + + if len(m.Default) > 0 { + ti.Placeholder = m.Default + "." + m.TLD + } + + m.input = &ti + + return textinput.Blink +} + +func (m *DomainInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + if m.InputCh != nil { + value := m.input.Value() + if value == "" { + value = m.Default + } + + m.Domain = value + m.InputCh <- value + m.InputCh = nil + } + return m, nil + case tea.KeyCtrlC, tea.KeyEsc: + // TODO: double-check if this is necessary + return m, tea.Quit + } + } + + if len(m.input.Value()) > 0 { + m.input.SetSuggestions([]string{m.input.Value() + "." + m.TLD}) + } + + ti, cmd := m.input.Update(msg) + m.input = &ti + return m, cmd +} + +func (m *DomainInput) View() string { + var b strings.Builder + + if m.InputCh != nil { + fmt.Fprintln(&b, ui.StepPrompt("What lcl.host domain would you like to use for diagnostics?")) + fmt.Fprintln(&b, ui.StepPrompt(m.input.View())) + } else { + fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Entered %s domain for lcl.host diagnostic certificate.", ui.Emphasize(m.Domain+".lcl.host")))) + } + + return b.String() +} + +type ProvisionService struct { + Name, ServerType string + + Domains []string + + // TODO(wes): ShowHints field + + finished bool + + spinner spinner.Model +} + +func (m *ProvisionService) Init() tea.Cmd { + m.spinner = ui.Spinner() + + return m.spinner.Tick +} + +type ServiceProvisionedMsg struct{} + +func (m *ProvisionService) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg.(type) { + case ServiceProvisionedMsg: + m.finished = true + return m, nil + } + + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd +} + +func (m *ProvisionService) View() string { + var b strings.Builder + if m.finished { + fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Created %s [%s] %s server resources on Anchor.dev.", + ui.Emphasize(m.Name), + ui.Domains(m.Domains), + m.ServerType))) + } else { + fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Creating %s [%s] %s server resources on Anchor.dev… %s", + ui.Emphasize(m.Name), + ui.Domains(m.Domains), + m.ServerType, + m.spinner.View()))) + } + return b.String() +} diff --git a/lcl/provision.go b/lcl/provision.go new file mode 100644 index 0000000..4448e41 --- /dev/null +++ b/lcl/provision.go @@ -0,0 +1,74 @@ +package lcl + +import ( + "context" + "crypto/tls" + "net/url" + + "github.com/anchordotdev/cli" + "github.com/anchordotdev/cli/api" + "github.com/anchordotdev/cli/lcl/models" + "github.com/anchordotdev/cli/ui" +) + +type Provision struct { + Config *cli.Config + + Domains []string + orgSlug, realmSlug string +} + +func (p *Provision) run(ctx context.Context, drv *ui.Driver, anc *api.Session, serviceName, serverType string) (*api.Service, *api.ServicesXtach200, *api.Eab, *tls.Certificate, error) { + drv.Activate(ctx, &models.ProvisionService{ + Name: serviceName, + Domains: p.Domains, + ServerType: serverType, + }) + + userInfo, err := anc.UserInfo(ctx) + if err != nil { + return nil, nil, nil, nil, err + } + + if p.orgSlug == "" { + p.orgSlug = userInfo.PersonalOrg.Slug + } + serviceParam := serviceName + + srv, err := anc.GetService(ctx, p.orgSlug, serviceParam) + if err != nil { + return nil, nil, nil, nil, err + } + if srv == nil { + srv, err = anc.CreateService(ctx, p.orgSlug, serverType, serviceParam) + if err != nil { + return nil, nil, nil, nil, err + } + } + + // FIXME: we need to lookup and pass the chain and/or make it non-optional + chainParam := "ca" + + attach, err := anc.AttachService(ctx, chainParam, p.Domains, p.orgSlug, p.realmSlug, serviceParam) + if err != nil { + return nil, nil, nil, nil, err + } + + chainParam = attach.Relationships.Chain.Slug + subCaParam := attach.Relationships.SubCa.Slug + + eab, err := createEAB(anc.Client, chainParam, p.orgSlug, p.realmSlug, serviceParam, subCaParam) + if err != nil { + return nil, nil, nil, nil, err + } + + acmeURL := p.Config.AnchorURL + "/" + url.QueryEscape(p.orgSlug) + "/" + url.QueryEscape(p.realmSlug) + "/x509/" + chainParam + "/acme" + + cert, err := provisionCert(eab, p.Domains, acmeURL) + if err != nil { + return nil, nil, nil, nil, err + } + drv.Send(models.ServiceProvisionedMsg{}) + + return srv, attach, eab, cert, nil +} diff --git a/lcl/provision_test.go b/lcl/provision_test.go new file mode 100644 index 0000000..d723332 --- /dev/null +++ b/lcl/provision_test.go @@ -0,0 +1,72 @@ +package lcl + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/anchordotdev/cli" + "github.com/anchordotdev/cli/api" + "github.com/anchordotdev/cli/ui/uitest" + "github.com/charmbracelet/x/exp/teatest" +) + +func TestProvision(t *testing.T) { + if !srv.IsProxy() { + t.Skip("provision unsupported in non-mock mode") + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cfg := new(cli.Config) + cfg.API.URL = srv.URL + cfg.AnchorURL = "http://anchor.lcl.host:" + srv.RailsPort + cfg.Lcl.Service = "hi-example" + cfg.Lcl.Subdomain = "hi-example" + cfg.Trust.MockMode = true + cfg.Trust.NoSudo = true + + var err error + if cfg.API.Token, err = srv.GeneratePAT("example@example.com"); err != nil { + t.Fatal(err) + } + + anc, err := api.NewClient(cfg) + if err != nil { + t.Fatal(err) + } + + t.Run("diagnostic", func(t *testing.T) { + drv, tm := uitest.TestTUI(ctx, t) + + cmd := &Provision{ + Config: cfg, + Domains: []string{"subdomain.lcl.host", "subdomain.localhost"}, + } + + errc := make(chan error, 1) + go func() { + _, _, _, _, err := cmd.run(ctx, drv, anc, "test-service", "diagnostic") + errc <- err + }() + + t.Skip("pending ability to stub out the detection.Perform results") + + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + if len(errc) > 0 { + if err := <-errc; err != nil { + t.Fatal(<-errc) + } + } + + return bytes.Contains(bts, []byte("- Created test-service resources for lcl.host diagnostic server on Anchor.dev.")) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + }) +} diff --git a/trust/audit.go b/trust/audit.go new file mode 100644 index 0000000..5e4fa8d --- /dev/null +++ b/trust/audit.go @@ -0,0 +1,146 @@ +package trust + +import ( + "context" + "fmt" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + + "github.com/anchordotdev/cli" + "github.com/anchordotdev/cli/api" + "github.com/anchordotdev/cli/truststore" +) + +type Audit struct { + Config *cli.Config +} + +func (a Audit) UI() cli.UI { + return cli.UI{ + RunTTY: a.run, + } +} + +func (a *Audit) run(ctx context.Context, tty termenv.File) error { + anc, err := api.NewClient(a.Config) + if err != nil { + return err + } + + org, realm, err := fetchOrgAndRealm(ctx, a.Config, anc) + if err != nil { + return err + } + + expectedCAs, err := fetchExpectedCAs(ctx, anc, org, realm) + if err != nil { + return err + } + + stores, _, err := loadStores(a.Config) + if err != nil { + return err + } + + audit := &truststore.Audit{ + Expected: expectedCAs, + Stores: stores, + SelectFn: checkAnchorCert, + } + + info, err := audit.Perform() + if err != nil { + return err + } + + for _, ca := range info.Valid { + fmt.Fprintf(tty, "%s (%s) %-7s \"%s\"\n", + boldGreen.Render(fmt.Sprintf("%-8s", "VALID")), + period(ca), + ca.PublicKeyAlgorithm, + commonName(ca), + ) + + fmt.Fprintln(tty) + + for _, store := range stores { + fmt.Fprintf(tty, "%7s %35s %s\n", "", store.Description(), boldGreen.Render("TRUSTED")) + } + + fmt.Fprintln(tty) + } + + for _, ca := range info.Missing { + fmt.Fprintf(tty, "%s (%s) %-7s \"%s\"\n", + boldRed.Render(fmt.Sprintf("%-8s", "MISSING")), + period(ca), + ca.PublicKeyAlgorithm, + commonName(ca), + ) + + fmt.Fprintln(tty) + + for _, store := range stores { + if info.IsPresent(ca, store) { + fmt.Fprintf(tty, "%7s %35s %s\n", "", store.Description(), boldGreen.Render("TRUSTED")) + } else { + fmt.Fprintf(tty, "%7s %35s %s\n", "", store.Description(), boldRed.Render("NOT PRESENT")) + } + } + + fmt.Fprintln(tty) + } + + for _, ca := range info.Extra { + fmt.Fprintf(tty, "%s (%s) %-7s \"%s\"\n", + faint.Render(fmt.Sprintf("%-8s", "EXTRA")), + period(ca), + ca.PublicKeyAlgorithm, + commonName(ca), + ) + + fmt.Fprintln(tty) + + for _, store := range stores { + if info.IsPresent(ca, store) { + fmt.Fprintf(tty, "%7s %35s %s\n", "", store.Description(), faintGreen.Render("TRUSTED")) + } else { + fmt.Fprintf(tty, "%7s %35s %s\n", "", store.Description(), faint.Render("NOT PRESENT")) + } + } + + fmt.Fprintln(tty) + } + + return nil +} + +var ( + darkGreen = lipgloss.Color("#008000") + darkRed = lipgloss.Color("#800000") + lightGreen = lipgloss.Color("#00ff00") + lightRed = lipgloss.Color("#ff0000") + + boldGreen = lipgloss.NewStyle().Bold(true).Foreground(lightGreen) + boldRed = lipgloss.NewStyle().Bold(true).Foreground(lightRed) + faintGreen = lipgloss.NewStyle().Faint(true).Foreground(darkGreen) + faintRed = lipgloss.NewStyle().Faint(true).Foreground(darkRed) + + faint = lipgloss.NewStyle().Faint(true) + + italic = lipgloss.NewStyle().Italic(true) + underline = lipgloss.NewStyle().Underline(true) +) + +func commonName(ca *truststore.CA) string { + return underline.Render(fmt.Sprintf("%s", ca.Subject.CommonName)) +} + +func period(ca *truststore.CA) string { + startAt := ca.NotBefore.Format("2006-01-02") + expireAt := ca.NotAfter.Add(1 * time.Second).Format("2006-01-02") + + return italic.Render(fmt.Sprintf("%s - %s", startAt, expireAt)) +} diff --git a/trust/audit_test.go b/trust/audit_test.go new file mode 100644 index 0000000..a4815e4 --- /dev/null +++ b/trust/audit_test.go @@ -0,0 +1,145 @@ +package trust + +import ( + "bufio" + "context" + "crypto/x509" + "crypto/x509/pkix" + "regexp" + "testing" + + "github.com/anchordotdev/cli" + "github.com/anchordotdev/cli/api" + "github.com/anchordotdev/cli/api/apitest" + "github.com/anchordotdev/cli/ext509" + "github.com/anchordotdev/cli/internal/must" + "github.com/anchordotdev/cli/truststore" +) + +func TestAudit(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cfg := new(cli.Config) + cfg.API.URL = srv.URL + cfg.Trust.Stores = []string{"mock"} + + var err error + if cfg.API.Token, err = srv.GeneratePAT("example@example.com"); err != nil { + t.Fatal(err) + } + + anc, err := api.NewClient(cfg) + if err != nil { + t.Fatal(err) + } + + org, realm, err := fetchOrgAndRealm(ctx, cfg, anc) + if err != nil { + t.Fatal(err) + } + + expectedCAs, err := fetchExpectedCAs(ctx, anc, org, realm) + if err != nil { + t.Fatal(err) + } + + t.Run("expected, missing, and extra CAs", func(t *testing.T) { + truststore.MockCAs = []*truststore.CA{ + expectedCAs[0], + extraCA, + ignoredCA, + } + defer func() { truststore.MockCAs = nil }() + + cmd := &Audit{ + Config: cfg, + } + + buf, err := apitest.RunTTY(ctx, cmd.UI()) + if err != nil { + t.Fatal(err) + } + + scanner := bufio.NewScanner(buf) + + testOutput(t, scanner, []any{ + regexp.MustCompile(`^VALID [(][0-9- ]+[)] (RSA|ECDSA)\s+"[a-zA-Z0-9- \/]+ - AnchorCA"$`), + nil, + regexp.MustCompile(`\s+Mock\s+TRUSTED$`), + nil, + regexp.MustCompile(`^MISSING [(][0-9- ]+[)] (RSA|ECDSA)\s+"[a-zA-Z0-9- \/]+ - AnchorCA"$`), + nil, + regexp.MustCompile(`\s+Mock\s+NOT PRESENT$`), + nil, + regexp.MustCompile(`^EXTRA [(][0-9- ]+[)] (RSA|ECDSA)\s+"Extra CA - AnchorCA"$`), + nil, + regexp.MustCompile(`\s+Mock\s+TRUSTED$`), + nil, + }) + }) +} + +func testOutput(t *testing.T, scanner *bufio.Scanner, lines []any) { + t.Helper() + + for _, line := range lines { + if !scanner.Scan() { + t.Fatalf("want more output, got %q %v (nil is EOF)", scanner.Err(), scanner.Err()) + } + switch line := line.(type) { + case string: + if want, got := line, scanner.Text(); want != got { + t.Errorf("want output line %q, got %q", want, got) + } + case *regexp.Regexp: + if want, got := line, scanner.Text(); !want.MatchString(got) { + t.Errorf("want output line %q to match %q", got, want) + } + } + } + + if scanner.Scan() { + t.Errorf("want EOF, got %q", scanner.Text()) + } +} + +var ( + extraCA = mustCA(must.CA(&x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Extra CA - AnchorCA", + Organization: []string{"Example, Inc"}, + }, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + + ExtraExtensions: []pkix.Extension{ + mustAnchorExtension(ext509.AnchorCertificate{}), + }, + })) + + ignoredCA = mustCA(must.CA(&x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Extra CA - AnchorCA", + Organization: []string{"Example, Inc"}, + }, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + })) +) + +func mustCA(cert *must.Certificate) *truststore.CA { + uniqueName := cert.Leaf.SerialNumber.Text(16) + + return &truststore.CA{ + Certificate: cert.Leaf, + FilePath: "example-ca-" + uniqueName + ".pem", + UniqueName: uniqueName, + } +} + +func mustAnchorExtension(anc ext509.AnchorCertificate) pkix.Extension { + ext, err := anc.Extension() + if err != nil { + panic(err) + } + return ext +} diff --git a/trust/clean.go b/trust/clean.go new file mode 100644 index 0000000..8f0d7f5 --- /dev/null +++ b/trust/clean.go @@ -0,0 +1,124 @@ +package trust + +import ( + "context" + "os" + + "github.com/anchordotdev/cli" + "github.com/anchordotdev/cli/api" + "github.com/anchordotdev/cli/trust/models" + "github.com/anchordotdev/cli/truststore" + "github.com/anchordotdev/cli/ui" +) + +type Clean struct { + Config *cli.Config +} + +func (c Clean) UI() cli.UI { + return cli.UI{ + RunTUI: c.runTUI, + } +} + +func (c *Clean) runTUI(ctx context.Context, drv *ui.Driver) error { + drv.Activate(ctx, &models.CleanPreflight{ + CertStates: c.Config.Trust.Clean.States, + TrustStores: c.Config.Trust.Stores, + }) + + anc, err := api.NewClient(c.Config) + if err != nil { + return err + } + + handle, err := getHandle(ctx, anc) + if err != nil { + return err + } + drv.Send(models.HandleMsg(handle)) + + org, realm, err := fetchOrgAndRealm(ctx, c.Config, anc) + if err != nil { + return err + } + + expectedCAs, err := fetchExpectedCAs(ctx, anc, org, realm) + if err != nil { + return err + } + drv.Send(models.ExpectedCAsMsg(expectedCAs)) + + stores, sudoMgr, err := loadStores(c.Config) + if err != nil { + return err + } + + sudoMgr.AroundSudo = func(sudo func()) { + unpausec := drv.Pause() + defer close(unpausec) + + sudo() + } + + audit := &truststore.Audit{ + Expected: expectedCAs, + Stores: stores, + SelectFn: checkAnchorCert, + } + + info, err := audit.Perform() + if err != nil { + return err + } + + targetCAs := info.AllCAs(c.Config.Trust.Clean.States...) + drv.Send(models.TargetCAsMsg(targetCAs)) + + tmpDir, err := os.MkdirTemp("", "anchor-trust-clean") + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + + for _, ca := range targetCAs { + if ca.FilePath == "" { + if err := writeCAFile(ca, tmpDir); err != nil { + return err + } + } + + confirmc := make(chan struct{}) + + drv.Activate(ctx, &models.CleanCA{ + CA: ca, + ConfirmCh: confirmc, + }) + + select { + case <-confirmc: + case <-ctx.Done(): + return ctx.Err() + } + + for _, store := range stores { + if !info.IsPresent(ca, store) { + continue + } + + drv.Send(models.CleaningStoreMsg{Store: store}) + + if _, err := store.UninstallCA(ca); err != nil { + return err + } + + drv.Send(models.CleanedStoreMsg{Store: store}) + } + } + + drv.Activate(ctx, &models.CleanEpilogue{ + Count: len(targetCAs), + }) + + return nil +} diff --git a/trust/models/clean.go b/trust/models/clean.go new file mode 100644 index 0000000..1fc973b --- /dev/null +++ b/trust/models/clean.go @@ -0,0 +1,146 @@ +package models + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + + "github.com/anchordotdev/cli/truststore" + "github.com/anchordotdev/cli/ui" +) + +type CleanPreflight struct { + CertStates, TrustStores []string + + step preflightStep + + handle string + expectedCAs, targetCAs []*truststore.CA + + spinner spinner.Model +} + +func (c *CleanPreflight) Init() tea.Cmd { + c.spinner = ui.Spinner() + + return c.spinner.Tick +} + +func (c *CleanPreflight) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + c.spinner, cmd = c.spinner.Update(msg) + return c, cmd +} + +func (c *CleanPreflight) View() string { + states := strings.Join(c.CertStates, ", ") + stores := strings.Join(c.TrustStores, ", ") + + var b strings.Builder + fmt.Fprintln(&b, ui.Hint(fmt.Sprintf("Removing %s CA certificates from the %s store(s).", states, stores))) + + return b.String() +} + +type cleanCAStep int + +const ( + confirmingCA cleanCAStep = iota + cleaningStores + finishedCleanCA +) + +type CleanCA struct { + CA *truststore.CA + ConfirmCh chan<- struct{} + + step cleanCAStep + + stores []truststore.Store + cleaned map[truststore.Store]struct{} + + spinner spinner.Model +} + +func (c *CleanCA) Init() tea.Cmd { + c.spinner = ui.Spinner() + + c.cleaned = make(map[truststore.Store]struct{}) + + return c.spinner.Tick +} + +type ( + CleaningStoreMsg struct { + truststore.Store + } + + CleanedStoreMsg struct { + truststore.Store + } +) + +func (c *CleanCA) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case CleanedStoreMsg: + c.cleaned[msg.Store] = struct{}{} + return c, nil + case CleaningStoreMsg: + c.stores, c.step = append(c.stores, msg.Store), cleaningStores + return c, nil + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + if c.ConfirmCh == nil { + return c, nil + } + + close(c.ConfirmCh) + c.ConfirmCh = nil + } + } + + var cmd tea.Cmd + c.spinner, cmd = c.spinner.Update(msg) + return c, cmd +} + +func (c *CleanCA) View() string { + commonName := c.CA.Subject.CommonName + serial := c.CA.SerialNumber.Text(16) // TODO: format serial as XXXX:XXXX:XXXX:XXXX + algo := c.CA.PublicKeyAlgorithm + + var b strings.Builder + fmt.Fprintln(&b, ui.Header(fmt.Sprintf("Remove \"%s\" (%s) %s Certificate", ui.Underline(commonName), ui.Whisper(serial), algo))) + fmt.Fprintln(&b, ui.StepAlert(fmt.Sprintf("%s to remove the certificate (%s)", ui.Action("Press Enter"), ui.Accentuate("may require sudo")))) + + for _, store := range c.stores { + if _, ok := c.cleaned[store]; ok { + fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("removed certificate from the %s store.", ui.Emphasize(store.Description())))) + } else { + fmt.Fprintln(&b, ui.StepInProgress(fmt.Sprintf("removing certificate from the %s store…%s", ui.Emphasize(store.Description()), c.spinner.View()))) + } + } + + return b.String() +} + +type CleanEpilogue struct { + Count int +} + +func (CleanEpilogue) Init() tea.Cmd { return nil } + +func (c CleanEpilogue) Update(tea.Msg) (tea.Model, tea.Cmd) { + return c, tea.Quit +} + +func (c CleanEpilogue) View() string { + var b strings.Builder + fmt.Fprintln(&b, ui.Header("Finished")) + fmt.Fprintf(&b, ui.Hint("%d certificates were removed!\n"), c.Count) + + return b.String() +} diff --git a/trust/models/sync.go b/trust/models/sync.go new file mode 100644 index 0000000..a87a85d --- /dev/null +++ b/trust/models/sync.go @@ -0,0 +1,168 @@ +package models + +import ( + "fmt" + "strings" + + "github.com/anchordotdev/cli/truststore" + "github.com/anchordotdev/cli/ui" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" +) + +type SyncPreflight struct { + NonInteractive bool + ConfirmCh chan<- struct{} + + step preflightStep + + expectedCAs []*truststore.CA + auditInfo *truststore.AuditInfo + + spinner spinner.Model +} + +func (m *SyncPreflight) Init() tea.Cmd { + m.spinner = ui.Spinner() + + return m.spinner.Tick +} + +type ( + AuditInfoMsg *truststore.AuditInfo + + PreflightFinishedMsg struct{} +) + +func (m *SyncPreflight) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case AuditInfoMsg: + m.auditInfo, m.step = msg, confirmSync + + if len(m.auditInfo.Missing) == 0 { + m.step = noSync + } + + return m, nil + case PreflightFinishedMsg: + return m, nil + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + if m.ConfirmCh == nil { + return m, nil + } + + close(m.ConfirmCh) + m.ConfirmCh = nil + } + return m, nil + default: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } +} + +func (m *SyncPreflight) View() string { + var b strings.Builder + + if m.step == diff { + fmt.Fprintln(&b, ui.StepInProgress(fmt.Sprintf("Comparing local stores to expected CA certificates…%s", m.spinner.View()))) + return b.String() + } + + switch m.step { + case confirmSync: + fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Compared local stores to expected CA certificates: need to install %d missing certificates.", len(m.auditInfo.Missing)))) + + if m.NonInteractive { + fmt.Fprintln(&b, ui.StepAlert(fmt.Sprintf("Installing %d missing certificates. (%s)", len(m.auditInfo.Missing), ui.Accentuate("requires sudo")))) + } else if m.ConfirmCh != nil { + fmt.Fprintln(&b, ui.StepAlert(fmt.Sprintf("%s to install %d missing certificates. (%s)", ui.Action("Press Enter"), len(m.auditInfo.Missing), ui.Accentuate("requires sudo")))) + } + case noSync: + fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Compared local stores to expected CA certificates: no changes needed."))) + default: + panic("impossible") + } + return b.String() +} + +type SyncInstallCA struct { + CA *truststore.CA + + stores []truststore.Store + installed map[truststore.Store]struct{} + + spinner spinner.Model +} + +func (m *SyncInstallCA) Init() tea.Cmd { + m.spinner = ui.Spinner() + + return m.spinner.Tick +} + +type ( + SyncInstallingCAMsg struct { + truststore.Store + } + + SyncInstalledCAMsg struct { + truststore.Store + } +) + +func (m *SyncInstallCA) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case SyncInstalledCAMsg: + if m.installed == nil { + m.installed = map[truststore.Store]struct{}{} + } + m.installed[msg.Store] = struct{}{} + return m, nil + case SyncInstallingCAMsg: + m.stores = append(m.stores, msg.Store) + return m, nil + } + + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd +} + +func (m *SyncInstallCA) View() string { + commonName := m.CA.Subject.CommonName + algo := m.CA.PublicKeyAlgorithm + + var b strings.Builder + + var installed []string + var installing []string + + for _, store := range m.stores { + if _, ok := m.installed[store]; ok { + installed = append(installed, ui.Emphasize(store.Description())) + } else { + installing = append(installing, ui.Emphasize(store.Description())) + } + } + + if len(installed) > 0 { + fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Installed \"%s\" %s in %s.", + ui.Underline(commonName), + algo, + strings.Join(installed, ", "), + ))) + } + if len(installing) > 0 { + fmt.Fprintln(&b, ui.StepInProgress(fmt.Sprintf("Installing \"%s\" %s in %s.", + ui.Underline(commonName), + algo, + strings.Join(installing, ", "), + ))) + } + + return b.String() +} diff --git a/trust/models/trust.go b/trust/models/trust.go new file mode 100644 index 0000000..de5e49a --- /dev/null +++ b/trust/models/trust.go @@ -0,0 +1,20 @@ +package models + +import "github.com/anchordotdev/cli/truststore" + +type preflightStep int + +const ( + diff preflightStep = iota + finishedPreflight + + confirmSync + noSync +) + +type ( + HandleMsg string + + ExpectedCAsMsg []*truststore.CA + TargetCAsMsg []*truststore.CA +) diff --git a/trust/sync.go b/trust/sync.go new file mode 100644 index 0000000..5372d99 --- /dev/null +++ b/trust/sync.go @@ -0,0 +1,110 @@ +package trust + +import ( + "context" + "os" + + "github.com/anchordotdev/cli" + "github.com/anchordotdev/cli/api" + "github.com/anchordotdev/cli/trust/models" + "github.com/anchordotdev/cli/truststore" + "github.com/anchordotdev/cli/ui" +) + +type Sync struct { + Config *cli.Config + + Anc *api.Session + OrgSlug, RealmSlug string +} + +func (s Sync) UI() cli.UI { + return cli.UI{ + RunTUI: s.runTUI, + } +} + +func (s *Sync) runTUI(ctx context.Context, drv *ui.Driver) error { + confirmc := make(chan struct{}) + drv.Activate(ctx, &models.SyncPreflight{ + NonInteractive: s.Config.NonInteractive, + ConfirmCh: confirmc, + }) + + cas, err := fetchExpectedCAs(ctx, s.Anc, s.OrgSlug, s.RealmSlug) + if err != nil { + return err + } + + stores, sudoMgr, err := loadStores(s.Config) + if err != nil { + return err + } + + // TODO: handle nosudo + + sudoMgr.AroundSudo = func(sudo func()) { + unpausec := drv.Pause() + defer close(unpausec) + + sudo() + } + + audit := &truststore.Audit{ + Expected: cas, + Stores: stores, + SelectFn: checkAnchorCert, + } + + info, err := audit.Perform() + if err != nil { + return err + } + drv.Send(models.AuditInfoMsg(info)) + + if len(info.Missing) == 0 { + drv.Send(models.PreflightFinishedMsg{}) + + return nil + } + + if !s.Config.NonInteractive { + select { + case <-confirmc: + case <-ctx.Done(): + return ctx.Err() + } + } + + tmpDir, err := os.MkdirTemp("", "anchor-trust-sync") + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + + for _, ca := range info.Missing { + if err := writeCAFile(ca, tmpDir); err != nil { + return err + } + + drv.Activate(ctx, &models.SyncInstallCA{ + CA: ca, + }) + + for _, store := range stores { + if info.IsPresent(ca, store) { + continue + } + drv.Send(models.SyncInstallingCAMsg{Store: store}) + + if ok, err := store.InstallCA(ca); err != nil { + return err + } else if !ok { + panic("impossible") + } + drv.Send(models.SyncInstalledCAMsg{Store: store}) + } + } + + return nil +} diff --git a/trust/trust.go b/trust/trust.go index c9d34f5..25063d3 100644 --- a/trust/trust.go +++ b/trust/trust.go @@ -11,58 +11,51 @@ import ( "net/http" "net/url" "os" + "os/exec" "path/filepath" "github.com/muesli/termenv" "github.com/anchordotdev/cli" "github.com/anchordotdev/cli/api" + "github.com/anchordotdev/cli/ext509" + "github.com/anchordotdev/cli/ext509/oid" "github.com/anchordotdev/cli/truststore" ) const ( - sudoWarning = "! Anchor needs sudo permission to add the specified certificates to your local trust stores." + sudoWarning = "Anchor needs sudo access to install certificates in your local trust stores." ) type Command struct { Config *cli.Config } -func (c Command) TUI() cli.TUI { - return cli.TUI{ - Run: c.run, +func (c Command) UI() cli.UI { + return cli.UI{ + RunTTY: c.run, } } func (c *Command) run(ctx context.Context, tty termenv.File) error { - anc, err := api.Client(c.Config) + output := termenv.DefaultOutput() + cp := output.ColorProfile() + + fmt.Fprintln(tty, + output.String("# Run `anchor trust`").Bold(), + ) + + anc, err := api.NewClient(c.Config) if err != nil { return err } - res, err := anc.Get("") + org, realm, err := fetchOrgAndRealm(ctx, c.Config, anc) if err != nil { return err } - if res.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected response code: %d", res.StatusCode) - } - - org, realm := c.Config.Trust.Org, c.Config.Trust.Realm - if (org == "") != (realm == "") { - return errors.New("--org and --realm flags must both be present or absent") - } - if org == "" && realm == "" { - // TODO: use personal org value from API check-in call - var userInfo *api.Root - if err = json.NewDecoder(res.Body).Decode(&userInfo); err != nil { - return err - } - org = *userInfo.PersonalOrg.Slug - realm = "localhost" - } - res, err = anc.Get("/orgs/" + url.QueryEscape(org) + "/realms/" + url.QueryEscape(realm) + "/x509/credentials") + res, err := anc.Get("/orgs/" + url.QueryEscape(org) + "/realms/" + url.QueryEscape(realm) + "/x509/credentials") if err != nil { return err } @@ -70,49 +63,29 @@ func (c *Command) run(ctx context.Context, tty termenv.File) error { return fmt.Errorf("unexpected response code: %d", res.StatusCode) } - var certs struct { - Items *[]api.Credential `json:"items,omitempty"` - } + var certs *api.Credentials if err := json.NewDecoder(res.Body).Decode(&certs); err != nil { return err } rootDir, err := os.MkdirTemp("", "add-cert") if err != nil { - log.Fatal(err) + return err } defer os.RemoveAll(rootDir) - homeDir, err := os.UserHomeDir() - if err != nil { - log.Fatal(err) - } - - fmt.Fprintln(tty, sudoWarning) - - rootFS := truststore.RootFS() - systemStore := &truststore.Platform{ - HomeDir: homeDir, - - DataFS: rootFS, - SysFS: rootFS, - } - - nssStore := &truststore.NSS{ - HomeDir: homeDir, - - DataFS: rootFS, - SysFS: rootFS, - } - - brewStore := &truststore.Brew{ - RootDir: "/", + fmt.Fprintln(tty, + " ", + output.String("!").Background(cp.Color("#7000ff")), + sudoWarning, + ) - DataFS: rootFS, - SysFS: rootFS, + stores, _, err := loadStores(c.Config) + if err != nil { + return err } - for _, cert := range *certs.Items { + for _, cert := range certs.Items { blk, _ := pem.Decode([]byte(cert.TextualEncoding)) cert, err := x509.ParseCertificate(blk.Bytes) @@ -140,61 +113,260 @@ func (c *Command) run(ctx context.Context, tty termenv.File) error { UniqueName: uniqueName, } + fmt.Fprintln(tty, + " ", + "# Installing", + "\""+output.String(ca.Subject.CommonName).Underline().String()+"\"", + ca.PublicKeyAlgorithm, + output.String("("+uniqueName+")").Faint(), + "certificate", + ) + if ca.SignatureAlgorithm == x509.PureEd25519 { - fmt.Fprintf(tty, "Installing \"%s\" %s (%s) certificate:\n", ca.Subject.CommonName, ca.PublicKeyAlgorithm, uniqueName) - fmt.Fprintf(tty, " - skipped awaiting broader support.\n") + fmt.Fprintf(tty, " - skipped awaiting broader support.\n") continue } - fmt.Fprintf(tty, "Installing \"%s\" %s (%s) certificate:\n", ca.Subject.CommonName, ca.PublicKeyAlgorithm, uniqueName) - if c.Config.Trust.MockMode { - fmt.Fprintf(tty, " - installed in the mock store.\n") + fmt.Fprintf(tty, " - installed in the mock store.\n") continue } - if err := install(tty, ca, systemStore, "system"); err != nil { - return err - } - if err := install(tty, ca, nssStore, "Network Security Services (NSS)"); err != nil { - return err - } - if err := install(tty, ca, brewStore, "Homebrew OpenSSL (ca-certificates)"); err != nil { - return err + for _, store := range stores { + if err := install(tty, ca, store); err != nil { + return err + } } } return nil } -type trustStore interface { - Check() (bool, error) - CheckCA(*truststore.CA) (bool, error) - InstallCA(*truststore.CA) (bool, error) -} - -func install(tty termenv.File, ca *truststore.CA, store trustStore, name string) error { +func install(tty termenv.File, ca *truststore.CA, store truststore.Store) error { if ok, err := store.Check(); !ok { if err != nil { - fmt.Fprintf(tty, " - skipping the %s store: %s\n", name, err) + fmt.Fprintf(tty, " - skipping the %s store: %s\n", store.Description(), err) } else { - fmt.Fprintf(tty, " - skipping the %s store\n", name) + fmt.Fprintf(tty, " - skipping the %s store\n", store.Description()) } return nil } if ok, err := store.CheckCA(ca); err != nil { - fmt.Fprintf(tty, " - skipping the %s store: %s\n", name, err) + fmt.Fprintf(tty, " - skipping the %s store: %s\n", store.Description(), err) return nil } else if ok { - fmt.Fprintf(tty, " - already installed in the %s store.\n", name) + fmt.Fprintf(tty, " - already installed in the %s store.\n", store.Description()) return nil } if installed, err := store.InstallCA(ca); err != nil { return err } else if installed { - fmt.Fprintf(tty, " - installed in the %s store.\n", name) + fmt.Fprintf(tty, " - installed in the %s store.\n", store.Description()) + } + return nil +} + +func fetchOrgAndRealm(ctx context.Context, cfg *cli.Config, anc *api.Session) (string, string, error) { + org, realm := cfg.Trust.Org, cfg.Trust.Realm + if (org == "") != (realm == "") { + return "", "", errors.New("--org and --realm flags must both be present or absent") + } + if org == "" && realm == "" { + userInfo, err := anc.UserInfo(ctx) + if err != nil { + return "", "", err + } + org = userInfo.PersonalOrg.Slug + + // TODO: use personal org's default realm value from API check-in call, + // instead of hard-coding "localhost" here + realm = "localhost" + } + + return org, realm, nil +} + +func PerformAudit(ctx context.Context, cfg *cli.Config, anc *api.Session, org string, realm string) (*truststore.AuditInfo, error) { + cas, err := fetchExpectedCAs(ctx, anc, org, realm) + if err != nil { + return nil, err + } + + stores, _, err := loadStores(cfg) + if err != nil { + return nil, err + } + + audit := &truststore.Audit{ + Expected: cas, + Stores: stores, + SelectFn: checkAnchorCert, + } + auditInfo, err := audit.Perform() + if err != nil { + return nil, err + } + + return auditInfo, nil +} + +func fetchExpectedCAs(ctx context.Context, anc *api.Session, org, realm string) ([]*truststore.CA, error) { + creds, err := anc.FetchCredentials(ctx, org, realm) + if err != nil { + return nil, err } + + var cas []*truststore.CA + for _, item := range creds { + blk, _ := pem.Decode([]byte(item.TextualEncoding)) + + cert, err := x509.ParseCertificate(blk.Bytes) + if err != nil { + return nil, err + } + + uniqueName := cert.SerialNumber.Text(16) + + ca := &truststore.CA{ + Certificate: cert, + UniqueName: uniqueName, + } + + // TODO: make this variable based on cli.Config + if ca.PublicKeyAlgorithm == x509.Ed25519 { + continue + } + + cas = append(cas, ca) + } + return cas, nil +} + +func getHandle(ctx context.Context, anc *api.Session) (string, error) { + userInfo, err := anc.UserInfo(ctx) + if err != nil { + return "", err + } + return userInfo.Whoami, nil +} + +func loadStores(cfg *cli.Config) ([]truststore.Store, *SudoManager, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + log.Fatal(err) + } + rootFS := truststore.RootFS() + + sysFS := &SudoManager{ + CmdFS: rootFS, + NoSudo: cfg.Trust.NoSudo, + } + + var stores []truststore.Store + for _, storeName := range cfg.Trust.Stores { + switch storeName { + case "system": + systemStore := &truststore.Platform{ + HomeDir: homeDir, + + DataFS: rootFS, + SysFS: sysFS, + } + + stores = append(stores, systemStore) + case "nss": + nssStore := &truststore.NSS{ + HomeDir: homeDir, + + DataFS: rootFS, + SysFS: sysFS, + } + + stores = append(stores, nssStore) + case "homebrew": + brewStore := &truststore.Brew{ + RootDir: "/", + + DataFS: rootFS, + SysFS: sysFS, + } + + stores = append(stores, brewStore) + case "mock": + stores = append(stores, new(truststore.Mock)) + } + } + + return stores, sysFS, nil +} + +func checkAnchorCert(ca *truststore.CA) (bool, error) { + for _, ext := range ca.Extensions { + if ext.Id.Equal(oid.AnchorCertificateExtension) { + var ac ext509.AnchorCertificate + if err := ac.Unmarshal(ext); err != nil { + return false, err + } + + return true, nil + } + } + + return false, nil +} + +func writeCAFile(ca *truststore.CA, dir string) error { + fileName := filepath.Join(ca.UniqueName + ".pem") + file, err := os.Create(filepath.Join(dir, fileName)) + if err != nil { + return err + } + defer file.Close() + + blk := &pem.Block{ + Type: "CERTIFICATE", + Bytes: ca.Raw, + } + + if err := pem.Encode(file, blk); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + + ca.FilePath = file.Name() return nil } + +type SudoManager struct { + truststore.CmdFS + + NoSudo bool + + AroundSudo func(sudoExec func()) +} + +func (s *SudoManager) SudoExec(cmd *exec.Cmd) ([]byte, error) { + sudoFn := s.CmdFS.SudoExec + if s.NoSudo { + sudoFn = s.CmdFS.Exec + } + + if s.AroundSudo == nil { + return sudoFn(cmd) + } + + var ( + out []byte + err error + ) + + s.AroundSudo(func() { + out, err = sudoFn(cmd) + }) + + return out, err +} diff --git a/trust/trust_test.go b/trust/trust_test.go index 990e9a4..a2e078a 100644 --- a/trust/trust_test.go +++ b/trust/trust_test.go @@ -12,6 +12,7 @@ import ( "github.com/anchordotdev/cli" "github.com/anchordotdev/cli/api" "github.com/anchordotdev/cli/api/apitest" + "github.com/anchordotdev/cli/truststore" ) var srv = &apitest.Server{ @@ -37,7 +38,7 @@ func TestTrust(t *testing.T) { cfg := new(cli.Config) cfg.API.URL = srv.URL - cfg.Trust.MockMode = true + cfg.Trust.Stores = []string{"mock"} cfg.Trust.NoSudo = true var err error @@ -45,16 +46,19 @@ func TestTrust(t *testing.T) { t.Fatal(err) } - headerPattern := regexp.MustCompile(`Installing "[^"]+ - AnchorCA" \w+ \([a-z0-9]+\) certificate:$`) - installPattern := regexp.MustCompile(` - installed in the mock store.$`) - skipPattern := regexp.MustCompile(` - skipped awaiting broader support.$`) + header := "# Run `anchor trust`" + subheaderPattern := regexp.MustCompile(` # Installing "[^"]+ - AnchorCA" \w+ \([a-z0-9]+\) certificate$`) + installPattern := regexp.MustCompile(` - installed in the Mock store.$`) + skipPattern := regexp.MustCompile(` - skipped awaiting broader support.$`) t.Run("default to personal org and localhost realm", func(t *testing.T) { + defer func() { truststore.MockCAs = nil }() + cmd := &Command{ Config: cfg, } - buf, err := apitest.RunTUI(ctx, cmd.TUI()) + buf, err := apitest.RunTTY(ctx, cmd.UI()) if err != nil { t.Fatal(err) } @@ -63,12 +67,20 @@ func TestTrust(t *testing.T) { if !scanner.Scan() { t.Fatalf("want sudo warning line got %q %v (nil is EOF)", scanner.Err(), scanner.Err()) } - if line := scanner.Text(); line != sudoWarning { - t.Errorf("want output %q to match %q", line, sudoWarning) + if line := scanner.Text(); line != header { + t.Errorf("want output %q to match %q", line, header) } + + if !scanner.Scan() { + t.Fatalf("want sudo warning line got %q %v (nil is EOF)", scanner.Err(), scanner.Err()) + } + if line := scanner.Text(); line != " ! "+sudoWarning { + t.Errorf("want output %q to match %q", line, " ! "+sudoWarning) + } + for scanner.Scan() { - if line := scanner.Text(); !headerPattern.MatchString(line) { - t.Errorf("want output %q to match %q", line, headerPattern) + if line := scanner.Text(); !subheaderPattern.MatchString(line) { + t.Errorf("want output %q to match %q", line, subheaderPattern) } if !scanner.Scan() { @@ -82,6 +94,8 @@ func TestTrust(t *testing.T) { }) t.Run("specified org and realm", func(t *testing.T) { + defer func() { truststore.MockCAs = nil }() + cfg.Trust.Org = mustFetchPersonalOrgSlug(cfg) cfg.Trust.Realm = "localhost" @@ -89,7 +103,7 @@ func TestTrust(t *testing.T) { Config: cfg, } - buf, err := apitest.RunTUI(ctx, cmd.TUI()) + buf, err := apitest.RunTTY(ctx, cmd.UI()) if err != nil { t.Fatal(err) } @@ -98,12 +112,19 @@ func TestTrust(t *testing.T) { if !scanner.Scan() { t.Fatalf("want sudo warning line got %q %v (nil is EOF)", scanner.Err(), scanner.Err()) } - if line := scanner.Text(); line != sudoWarning { - t.Errorf("want output %q to match %q", line, sudoWarning) + if line := scanner.Text(); line != header { + t.Errorf("want output %q to match %q", line, header) + } + + if !scanner.Scan() { + t.Fatalf("want sudo warning line got %q %v (nil is EOF)", scanner.Err(), scanner.Err()) + } + if line := scanner.Text(); line != " ! "+sudoWarning { + t.Errorf("want output %q to match %q", line, " ! "+sudoWarning) } for scanner.Scan() { - if line := scanner.Text(); !headerPattern.MatchString(line) { - t.Errorf("want output %q to match %q", line, headerPattern) + if line := scanner.Text(); !subheaderPattern.MatchString(line) { + t.Errorf("want output %q to match %q", line, subheaderPattern) } if !scanner.Scan() { @@ -118,7 +139,7 @@ func TestTrust(t *testing.T) { } func mustFetchPersonalOrgSlug(cfg *cli.Config) string { - anc, err := api.Client(cfg) + anc, err := api.NewClient(cfg) if err != nil { panic(err) } @@ -133,5 +154,5 @@ func mustFetchPersonalOrgSlug(cfg *cli.Config) string { panic(err) } - return *userInfo.PersonalOrg.Slug + return userInfo.PersonalOrg.Slug } diff --git a/truststore/audit.go b/truststore/audit.go new file mode 100644 index 0000000..3e3d0ee --- /dev/null +++ b/truststore/audit.go @@ -0,0 +1,139 @@ +package truststore + +import ( + "slices" + "time" +) + +type Audit struct { + Expected []*CA + + Stores []Store + + At time.Time + + SelectFn func(*CA) (bool, error) +} + +type AuditInfo struct { + Valid, Missing, Rotate, Expired, PreValid, Extra []*CA + + casByStore map[Store]map[string]*CA +} + +func (i *AuditInfo) AllCAs(states ...string) []*CA { + var cas []*CA + if slices.Contains(states, "valid") || slices.Contains(states, "all") { + cas = append(cas, i.Valid...) + } + if slices.Contains(states, "rotate") || slices.Contains(states, "all") { + cas = append(cas, i.Rotate...) + } + if slices.Contains(states, "expired") || slices.Contains(states, "all") { + cas = append(cas, i.Expired...) + } + if slices.Contains(states, "prevalid") || slices.Contains(states, "all") { + cas = append(cas, i.PreValid...) + } + if slices.Contains(states, "extra") || slices.Contains(states, "all") { + cas = append(cas, i.Extra...) + } + return cas +} + +func (i *AuditInfo) IsPresent(ca *CA, store Store) bool { + _, ok := i.casByStore[store][ca.UniqueName] + return ok +} + +func (a *Audit) Perform() (*AuditInfo, error) { + if a.At.IsZero() { + a.At = time.Now() + } + + info := &AuditInfo{ + casByStore: make(map[Store]map[string]*CA), + } + + casByName := make(map[string]*CA) + storesByCA := make(map[string][]Store) + for _, store := range a.Stores { + cas, err := store.ListCAs() + if err != nil { + return nil, err + } + + for _, ca := range cas { + if a.SelectFn != nil { + if keep, err := a.SelectFn(ca); err != nil { + return nil, err + } else if !keep { + continue + } + } + + casByName[ca.UniqueName] = ca + storesByCA[ca.UniqueName] = append(storesByCA[ca.UniqueName], store) + + set, ok := info.casByStore[store] + if !ok { + set = make(map[string]*CA) + } + set[ca.UniqueName] = ca + + info.casByStore[store] = set + } + } + + partialValid := make(map[string]*CA) + for _, ca := range a.Expected { + if _, ok := casByName[ca.UniqueName]; ok { + switch { + case a.isExpired(ca): + info.Expired = append(info.Expired, ca) + case a.isPreValid(ca): + info.Expired = append(info.PreValid, ca) + case a.isRotate(ca): + info.Rotate = append(info.Rotate, ca) + default: + partialValid[ca.UniqueName] = ca + } + + delete(casByName, ca.UniqueName) + } else { + info.Missing = append(info.Missing, ca) + } + } + + for _, ca := range casByName { + info.Extra = append(info.Extra, ca) + + for _, store := range storesByCA[ca.UniqueName] { + set, ok := info.casByStore[store] + if !ok { + set = make(map[string]*CA) + } + set[ca.UniqueName] = ca + + info.casByStore[store] = set + } + } + + for _, ca := range partialValid { + if len(storesByCA[ca.UniqueName]) < len(a.Stores) { + info.Missing = append(info.Missing, ca) + } else { + info.Valid = append(info.Valid, ca) + } + } + return info, nil +} + +func (a *Audit) isExpired(ca *CA) bool { return a.At.After(ca.NotAfter.Add(1 * time.Second)) } + +func (a *Audit) isPreValid(ca *CA) bool { return a.At.Before(ca.NotBefore) } + +func (a *Audit) isRotate(ca *CA) bool { + // TODO: lookup renew value from the extension + return false +} diff --git a/truststore/audit_test.go b/truststore/audit_test.go new file mode 100644 index 0000000..fbcea33 --- /dev/null +++ b/truststore/audit_test.go @@ -0,0 +1,80 @@ +package truststore + +import ( + "crypto/x509" + "crypto/x509/pkix" + "reflect" + "testing" + + "github.com/anchordotdev/cli/internal/must" +) + +func TestAudit(t *testing.T) { + MockCAs = []*CA{ + validCA, + extraCA, + } + defer func() { MockCAs = nil }() + + store := new(Mock) + + aud := Audit{ + Expected: []*CA{validCA, missingCA}, + + Stores: []Store{store}, + } + + info, err := aud.Perform() + if err != nil { + t.Fatal(err) + } + + if want, got := []*CA{validCA}, info.Valid; !reflect.DeepEqual(want, got) { + t.Errorf("want valid cas %+v, got %+v", want, got) + } + + if want, got := []*CA{missingCA}, info.Missing; !reflect.DeepEqual(want, got) { + t.Errorf("want missing cas %+v, got %+v", want, got) + } + + if want, got := []*CA{extraCA}, info.Extra; !reflect.DeepEqual(want, got) { + t.Errorf("want missing cas %+v, got %+v", want, got) + } + + if !info.IsPresent(validCA, store) { + t.Errorf("want present ca %+v in store %+v, but was not", validCA, store) + } + if !info.IsPresent(extraCA, store) { + t.Errorf("want extra ca %+v in store %+v, but was not", extraCA, store) + } + + if info.IsPresent(missingCA, store) { + t.Errorf("want missing ca %+v not in store %+v, but was", missingCA, store) + } +} + +var ( + validCA = mustCA(must.CA(&x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Valid CA", + Organization: []string{"Example, Inc"}, + }, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + })) + + missingCA = mustCA(must.CA(&x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Missing CA", + Organization: []string{"Example, Inc"}, + }, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + })) + + extraCA = mustCA(must.CA(&x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Extra CA", + Organization: []string{"Example, Inc"}, + }, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + })) +) diff --git a/truststore/brew.go b/truststore/brew.go index 7eaf93c..8be7e49 100644 --- a/truststore/brew.go +++ b/truststore/brew.go @@ -2,7 +2,9 @@ package truststore import ( "bytes" + "crypto/x509" "encoding/pem" + "os" "path/filepath" "strings" ) @@ -71,6 +73,36 @@ func (s *Brew) CheckCA(ca *CA) (bool, error) { return false, nil } +func (s *Brew) Description() string { return "Homebrew OpenSSL (ca-certificates)" } + +func (s *Brew) ListCAs() ([]*CA, error) { + if ok, err := s.Check(); !ok { + return nil, err + } + + buf, err := s.DataFS.ReadFile(s.certPath) + if err != nil { + return nil, err + } + + var cas []*CA + for p, buf := pem.Decode(buf); p != nil; p, buf = pem.Decode(buf) { + cert, err := x509.ParseCertificate(p.Bytes) + if err != nil { + return nil, err + } + + ca := &CA{ + Certificate: cert, + UniqueName: cert.SerialNumber.Text(16), + } + + cas = append(cas, ca) + } + + return cas, nil +} + func (s *Brew) InstallCA(ca *CA) (bool, error) { if ok, err := s.Check(); !ok { return ok, err @@ -86,3 +118,49 @@ func (s *Brew) InstallCA(ca *CA) (bool, error) { } return true, nil } + +func (s *Brew) UninstallCA(ca *CA) (bool, error) { + if ok, err := s.Check(); !ok { + return false, err + } + + tmpf := s.certPath + ".tmp" + if _, err := s.DataFS.Stat(tmpf); err != nil && !os.IsNotExist(err) { + return false, err + } + + odata, err := s.DataFS.ReadFile(s.certPath) + if err != nil { + return false, err + } + ndata := make([]byte, 0, len(odata)) + + for buf := odata; len(buf) > 0; { + blk, rem := pem.Decode(buf) + if blk == nil { + break + } + + data := buf[:len(buf)-len(rem)] + buf = rem + + cert, err := x509.ParseCertificate(blk.Bytes) + if err != nil { + return false, err + } + if bytes.Equal(cert.Raw, ca.Raw) { + continue + } + + ndata = append(ndata, data...) + } + + if err := s.DataFS.AppendToFile(tmpf, ndata); err != nil { + s.DataFS.Remove(tmpf) + return false, err + } + if err := s.DataFS.Rename(tmpf, s.certPath); err != nil { + return false, err + } + return true, nil +} diff --git a/truststore/brew_test.go b/truststore/brew_test.go index 9bd674a..3e1d512 100644 --- a/truststore/brew_test.go +++ b/truststore/brew_test.go @@ -1,76 +1,23 @@ +//go:build darwin +// +build darwin + package truststore import ( - "crypto/x509" - "crypto/x509/pkix" - "flag" "testing" - - "github.com/anchordotdev/cli/truststore/internal/must" -) - -var ( - _ = flag.Bool("prism-verbose", false, "ignored") - _ = flag.Bool("prism-proxy", false, "ignored") ) -func TestBrewCheck(t *testing.T) { - t.Skip("pending mock filesystem") +func TestBrew(t *testing.T) { + testFS := make(TestFS, 1) + testFS.AppendToFile("cert.pem", nil) - brew := &Brew{ + store := &Brew{ RootDir: "/", - DataFS: RootFS(), + DataFS: testFS, SysFS: RootFS(), - } - ok, err := brew.Check() - if err != nil { - t.Fatal(err) - } - if want, got := true, ok; want != got { - t.Errorf("want check %t, got %t", want, got) + certPath: "cert.pem", } - if ok, err = brew.CheckCA(ca); err != nil { - t.Fatal(err) - } - if want, got := false, ok; want != got { - t.Errorf("want check ca %t, got %t", want, got) - } - - if ok, err = brew.InstallCA(ca); err != nil { - t.Fatal(err) - } - if want, got := true, ok; want != got { - t.Errorf("want install ca %t, got %t", want, got) - } - - if ok, err = brew.CheckCA(ca); err != nil { - t.Fatal(err) - } - if want, got := true, ok; want != got { - t.Errorf("want check ca %t, got %t", want, got) - } -} - -var ( - ca = mustCA(must.CA(&x509.Certificate{ - Subject: pkix.Name{ - CommonName: "Example CA", - Organization: []string{"Example, Inc"}, - }, - KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, - - ExtraExtensions: []pkix.Extension{}, - })) -) - -func mustCA(cert *must.Certificate) *CA { - uniqueName := cert.Leaf.SerialNumber.Text(16) - - return &CA{ - Certificate: cert.Leaf, - FilePath: "example-ca-" + uniqueName + ".pem", - UniqueName: uniqueName, - } + testStore(t, store) } diff --git a/truststore/errors.go b/truststore/errors.go index 59b05fc..3260914 100644 --- a/truststore/errors.go +++ b/truststore/errors.go @@ -20,6 +20,7 @@ type Op string const ( OpCheck Op = "check" OpInstall = "install" + OpList = "list" OpSudo = "sudo" OpUninstall = "uninstall" ) diff --git a/truststore/fs.go b/truststore/fs.go index 1ded62d..a1d496a 100644 --- a/truststore/fs.go +++ b/truststore/fs.go @@ -2,8 +2,8 @@ package truststore import ( "errors" + "io" "io/fs" - "io/ioutil" "os" "os/exec" "os/user" @@ -27,6 +27,8 @@ type DataFS interface { fs.ReadFileFS AppendToFile(name string, p []byte) error + Rename(oldpath, newpath string) error + Remove(name string) error } type FS interface { @@ -90,13 +92,15 @@ func (r *rootFS) LookPath(cmd string) (string, error) { } func (r *rootFS) AppendToFile(name string, p []byte) error { - if err := r.checkFile(name); err != nil { - return err - } - f, err := os.OpenFile(filepath.Join(r.rootPath, name), os.O_APPEND|os.O_WRONLY, 0644) if err != nil { - return err + if !os.IsNotExist(err) { + return err + } + + if f, err = os.Create(filepath.Join(r.rootPath, name)); err != nil { + return err + } } defer f.Close() @@ -106,6 +110,15 @@ func (r *rootFS) AppendToFile(name string, p []byte) error { return f.Close() } +func (r *rootFS) Rename(oldpath, newpath string) error { + src, dst := filepath.Join(r.rootPath, oldpath), filepath.Join(r.rootPath, newpath) + return os.Rename(src, dst) +} + +func (r *rootFS) Remove(name string) error { + return os.Remove(filepath.Join(r.rootPath, name)) +} + func (r *rootFS) ReadFile(name string) ([]byte, error) { if err := r.checkFile(name); err != nil { return nil, err @@ -117,7 +130,7 @@ func (r *rootFS) ReadFile(name string) ([]byte, error) { } defer f.Close() - return ioutil.ReadAll(f) + return io.ReadAll(f) } func (r *rootFS) checkFile(name string) error { diff --git a/truststore/mock.go b/truststore/mock.go new file mode 100644 index 0000000..14cec6f --- /dev/null +++ b/truststore/mock.go @@ -0,0 +1,35 @@ +package truststore + +import "slices" + +var MockCAs []*CA + +type Mock struct{} + +func (Mock) Check() (bool, error) { return true, nil } + +func (Mock) CheckCA(ca *CA) (bool, error) { + for _, ca2 := range MockCAs { + if ca.UniqueName == ca2.UniqueName { + return true, nil + } + } + + return false, nil +} + +func (Mock) Description() string { return "Mock" } + +func (Mock) InstallCA(ca *CA) (bool, error) { + MockCAs = append(MockCAs, ca) + return true, nil +} + +func (Mock) ListCAs() ([]*CA, error) { + return MockCAs, nil +} + +func (Mock) UninstallCA(ca *CA) (bool, error) { + MockCAs = slices.DeleteFunc(MockCAs, func(ca2 *CA) bool { return ca.Equal(ca2) }) + return true, nil +} diff --git a/truststore/mock_test.go b/truststore/mock_test.go new file mode 100644 index 0000000..62c5e28 --- /dev/null +++ b/truststore/mock_test.go @@ -0,0 +1,5 @@ +package truststore + +import "testing" + +func TestMock(t *testing.T) { testStore(t, new(Mock)) } diff --git a/truststore/nss.go b/truststore/nss.go index cb943a6..eb5e3f1 100644 --- a/truststore/nss.go +++ b/truststore/nss.go @@ -2,6 +2,8 @@ package truststore import ( "bytes" + "crypto/x509" + "encoding/pem" "errors" "io/fs" "os" @@ -144,6 +146,8 @@ func (s *NSS) CheckCA(ca *CA) (installed bool, err error) { return count != 0, err } +func (s *NSS) Description() string { return "Network Security Services (NSS)" } + func (s *NSS) InstallCA(ca *CA) (installed bool, err error) { s.init() @@ -207,6 +211,97 @@ func (s *NSS) InstallCA(ca *CA) (installed bool, err error) { return true, nil } +func (s *NSS) ListCAs() ([]*CA, error) { + s.init() + + if s.certutilPath == "" { + return nil, NSSError{ + Err: ErrNoNSS, + + CertutilInstallHelp: s.certutilInstallHelp, + NSSBrowsers: nssBrowsers, + } + } + + var pemData []byte + _, err := s.forEachNSSProfile(func(profile string) error { + out, err := s.SysFS.Exec(s.SysFS.Command(s.certutilPath, "-L", "-d", profile)) + if err != nil { + return err + } + + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) < 2 { + return NSSError{ + Err: errors.New("unexpected certutil output"), + + CertutilInstallHelp: s.certutilInstallHelp, + NSSBrowsers: nssBrowsers, + } + } + + padLen := strings.Index(lines[0], "Trust Attributes") + if padLen <= 0 { + return NSSError{ + Err: errors.New("unexpected certutil output format"), + + CertutilInstallHelp: s.certutilInstallHelp, + NSSBrowsers: nssBrowsers, + } + } + + if len(lines) == 2 { + return nil // no certs in the output + } + + var nicks []string + for _, line := range lines[3:] { + if len(line) < padLen { + return NSSError{ + Err: errors.New("unexpected certutil line format"), + + CertutilInstallHelp: s.certutilInstallHelp, + NSSBrowsers: nssBrowsers, + } + } + + nicks = append(nicks, strings.TrimSpace(line[:padLen])) + } + + for _, nick := range nicks { + out, err := s.SysFS.Exec(s.SysFS.Command(s.certutilPath, "-L", "-d", profile, "-n", nick, "-a")) + if err != nil { + return err + } + + pemData = append(pemData, out...) + } + + return nil + }) + + if err != nil { + return nil, err + } + + var cas []*CA + for p, buf := pem.Decode(pemData); p != nil; p, buf = pem.Decode(buf) { + cert, err := x509.ParseCertificate(p.Bytes) + if err != nil { + return nil, err + } + + ca := &CA{ + Certificate: cert, + UniqueName: cert.SerialNumber.Text(16), + } + + cas = append(cas, ca) + } + + return cas, nil +} + func (s *NSS) UninstallCA(ca *CA) (bool, error) { s.init() @@ -257,6 +352,7 @@ func (s *NSS) UninstallCA(ca *CA) (bool, error) { } return nil }) + return err == nil, err } diff --git a/truststore/platform.go b/truststore/platform.go index 99bedd2..b0a85c5 100644 --- a/truststore/platform.go +++ b/truststore/platform.go @@ -54,6 +54,24 @@ func (s *Platform) InstallCA(ca *CA) (installed bool, err error) { return s.installCA(ca) } +func (s *Platform) ListCAs() (cas []*CA, err error) { + if _, cerr := s.check(); cerr != nil { + defer func() { + err = Error{ + Op: OpList, + + Warning: PlatformError{ + Err: cerr, + + NSSBrowsers: nssBrowsers, + }, + } + }() + } + + return s.listCAs() +} + func (s *Platform) UninstallCA(ca *CA) (uninstalled bool, err error) { if _, cerr := s.check(); cerr != nil { defer func() { diff --git a/truststore/platform_darwin.go b/truststore/platform_darwin.go index 287e9c4..575b0dc 100644 --- a/truststore/platform_darwin.go +++ b/truststore/platform_darwin.go @@ -2,11 +2,13 @@ package truststore import ( "bytes" + "crypto/sha256" + "crypto/x509" "encoding/asn1" + "encoding/hex" "encoding/pem" "fmt" "io/fs" - "io/ioutil" "os" "path/filepath" "sync" @@ -66,6 +68,8 @@ type Platform struct { firefoxProfiles []string } +func (s *Platform) Description() string { return "System (MacOS Keychain)" } + func (s *Platform) check() (bool, error) { s.inito.Do(func() { s.certutilInstallHelp = certutilInstallHelp(s.SysFS) @@ -112,7 +116,7 @@ func (s *Platform) installCA(ca *CA) (bool, error) { // Make trustSettings explicit, as older Go does not know the defaults. // https://github.com/golang/go/issues/24652 - plistFile, err := ioutil.TempFile("", "trust-settings") + plistFile, err := os.CreateTemp("", "trust-settings") if err != nil { return false, fatalErr(err, "failed to create temp file") } @@ -126,7 +130,7 @@ func (s *Platform) installCA(ca *CA) (bool, error) { return false, fatalCmdErr(err, "security trust-settings-export", out) } - plistData, err := ioutil.ReadFile(plistFile.Name()) + plistData, err := os.ReadFile(plistFile.Name()) if err != nil { return false, fatalErr(err, "failed to read trust settings") } @@ -161,7 +165,7 @@ func (s *Platform) installCA(ca *CA) (bool, error) { if plistData, err = plist.MarshalIndent(plistRoot, plist.XMLFormat, "\t"); err != nil { return false, fatalErr(err, "failed to serialize trust settings") } - if err = ioutil.WriteFile(plistFile.Name(), plistData, 0600); err != nil { + if err = os.WriteFile(plistFile.Name(), plistData, 0600); err != nil { return false, fatalErr(err, "failed to write trust settings") } @@ -176,13 +180,65 @@ func (s *Platform) installCA(ca *CA) (bool, error) { return true, nil } +func (s *Platform) listCAs() ([]*CA, error) { + args := []string{ + "find-certificate", "-a", "-p", + } + + out, err := s.SysFS.Exec(s.SysFS.Command("security", args...)) + if err != nil { + return nil, fatalCmdErr(err, "security add-trusted-cert", out) + } + + var cas []*CA + for p, buf := pem.Decode(out); p != nil; p, buf = pem.Decode(buf) { + cert, err := x509.ParseCertificate(p.Bytes) + if err != nil { + if isDupExtErr(err) { + continue + } + return nil, err + } + + ca := &CA{ + Certificate: cert, + UniqueName: cert.SerialNumber.Text(16), + } + + cas = append(cas, ca) + } + + return cas, nil +} + +const untrustedCertOutput = "SecTrustSettingsRemoveTrustSettings: The specified item could not be found in the keychain.\n" + func (s *Platform) uninstallCA(ca *CA) (bool, error) { args := []string{ "remove-trusted-cert", "-d", ca.FilePath, } if out, err := s.SysFS.SudoExec(s.SysFS.Command("security", args...)); err != nil { - return false, fatalCmdErr(err, "security remove-trusted-cert", out) + if !bytes.Equal(out, []byte(untrustedCertOutput)) { + return false, fatalCmdErr(err, "security remove-trusted-cert", out) + } + } + + sum256 := sha256.Sum256(ca.Raw) + + args = []string{ + "delete-certificate", + "-Z", hex.EncodeToString(sum256[:]), + } + + if out, err := s.SysFS.SudoExec(s.SysFS.Command("security", args...)); err != nil { + if !bytes.Equal(out, []byte(untrustedCertOutput)) { + return false, fatalCmdErr(err, "security delete-certificate", out) + } } return true, nil } + +func isDupExtErr(err error) bool { + return err.Error() == "x509: certificate contains duplicate extensions" +} diff --git a/truststore/platform_linux.go b/truststore/platform_linux.go index ea3b9b4..ab91568 100644 --- a/truststore/platform_linux.go +++ b/truststore/platform_linux.go @@ -2,6 +2,7 @@ package truststore import ( "bytes" + "crypto/x509" "encoding/pem" "errors" "fmt" @@ -25,6 +26,7 @@ type Platform struct { certutilInstallHelp string trustFilenamePattern string trustCommand []string + caBundleFileName string } func firefoxProfiles(homeDir string) []string { @@ -46,6 +48,8 @@ func certutilInstallHelp(sysFS CmdFS) string { return "" } +func (s *Platform) Description() string { return "System (Linux)" } + func (s *Platform) init() { s.inito.Do(func() { s.certutilInstallHelp = certutilInstallHelp(s.SysFS) @@ -53,15 +57,19 @@ func (s *Platform) init() { switch { case pathExists(s.DataFS, "/etc/pki/ca-trust/source/anchors/"): s.trustFilenamePattern = "/etc/pki/ca-trust/source/anchors/%s.pem" + s.caBundleFileName = "/etc/pki/tls/certs/ca-bundle.crt" s.trustCommand = []string{"update-ca-trust", "extract"} case pathExists(s.DataFS, "/usr/local/share/ca-certificates/"): s.trustFilenamePattern = "/usr/local/share/ca-certificates/%s.crt" + s.caBundleFileName = "/etc/ssl/certs/ca-certificates.crt" s.trustCommand = []string{"update-ca-certificates"} case pathExists(s.DataFS, "/etc/ca-certificates/trust-source/anchors/"): s.trustFilenamePattern = "/etc/ca-certificates/trust-source/anchors/%s.crt" + s.caBundleFileName = "/etc/ssl/certs/ca-certificates.crt" s.trustCommand = []string{"trust", "extract-compat"} case pathExists(s.DataFS, "/usr/share/pki/trust/anchors"): s.trustFilenamePattern = "/usr/share/pki/trust/anchors/%s.pem" + s.caBundleFileName = "/etc/ssl/ca-bundle.pem" s.trustCommand = []string{"update-ca-certificates"} } }) @@ -122,6 +130,36 @@ func (s *Platform) installCA(ca *CA) (bool, error) { return true, nil } +func (s *Platform) listCAs() ([]*CA, error) { + if ok, err := s.check(); !ok { + return nil, err + } + + data, err := s.DataFS.ReadFile(s.caBundleFileName) + if err != nil { + if errors.Is(err, syscall.ENOENT) { + return nil, nil + } + return nil, err + } + + var cas []*CA + for p, buf := pem.Decode(data); p != nil; p, buf = pem.Decode(buf) { + cert, err := x509.ParseCertificate(p.Bytes) + if err != nil { + return nil, err + } + + ca := &CA{ + Certificate: cert, + UniqueName: cert.SerialNumber.Text(16), + } + + cas = append(cas, ca) + } + return cas, nil +} + func (s *Platform) uninstallCA(ca *CA) (bool, error) { cmd := s.SysFS.Command("rm", "-f", s.trustFilenamePath(ca)) if out, err := s.SysFS.SudoExec(cmd); err != nil { diff --git a/truststore/platform_windows.go b/truststore/platform_windows.go index 91d0330..acc5d77 100644 --- a/truststore/platform_windows.go +++ b/truststore/platform_windows.go @@ -3,6 +3,7 @@ package truststore import ( "crypto/x509" "encoding/pem" + "errors" "fmt" "io/fs" "io/ioutil" @@ -41,6 +42,8 @@ type Platform struct { SysFS CmdFS } +func (s *Platform) Description() string { return "System (Windows)" } + func (s *Platform) check() (bool, error) { return true, nil } @@ -74,6 +77,12 @@ func (s *Platform) installCA(ca *CA) (bool, error) { return true, nil } +func (s *Platform) listCAs() ([]*CA, error) { + // https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/nf-wincrypt-certenumcertificatesinstore + + return nil, fatalErr(errors.New("unsupported"), "enumerate certs") +} + func (s *Platform) uninstallCA(ca *CA) (bool, error) { // We'll just remove all certs with the same serial number // Open root store diff --git a/truststore/truststore.go b/truststore/truststore.go index 25b7bd1..baea111 100644 --- a/truststore/truststore.go +++ b/truststore/truststore.go @@ -1,14 +1,24 @@ package truststore import ( + "crypto/subtle" "crypto/x509" "fmt" "io/fs" "os" "strings" - "sync" ) +type Store interface { + Check() (bool, error) + Description() string + + CheckCA(*CA) (bool, error) + InstallCA(*CA) (bool, error) + ListCAs() ([]*CA, error) + UninstallCA(*CA) (bool, error) +} + type CA struct { *x509.Certificate @@ -16,39 +26,8 @@ type CA struct { UniqueName string } -type Store struct { - CAROOT string - HOME string - - DataFS fs.StatFS - SysFS CmdFS - - hasNSS bool - hasCertutil bool - certutilPath string - initNSSOnce sync.Once - - systemTrustFilenameTemplate string - systemTrustCommand []string - certutilInstallHelp string - nssBrowsers string - - hasJava bool - hasKeytool bool - - javaHome string - cacertsPath string - keytoolPath string -} - -func (s *Store) binaryExists(name string) bool { - _, err := s.SysFS.LookPath(name) - return err == nil -} - -func (s *Store) pathExists(path string) bool { - _, err := s.DataFS.Stat(strings.Trim(path, string(os.PathSeparator))) - return err == nil +func (c *CA) Equal(ca *CA) bool { + return c.UniqueName == ca.UniqueName && subtle.ConstantTimeCompare(c.Raw, ca.Raw) == 1 } func fatalErr(err error, msg string) error { diff --git a/truststore/truststore_test.go b/truststore/truststore_test.go new file mode 100644 index 0000000..9f64671 --- /dev/null +++ b/truststore/truststore_test.go @@ -0,0 +1,147 @@ +package truststore + +import ( + "crypto/x509" + "crypto/x509/pkix" + "flag" + "io/fs" + "os" + "path" + "slices" + "testing" + "testing/fstest" + "time" + + "github.com/anchordotdev/cli/internal/must" +) + +var ( + _ = flag.Bool("prism-verbose", false, "ignored") + _ = flag.Bool("prism-proxy", false, "ignored") +) + +func testStore(t *testing.T, store Store) { + if ok, err := store.Check(); err != nil { + t.Fatal(err) + } else if !ok { + t.Fatalf("%q: initial check failed", store.Description()) + } + + if initialCAs, err := store.ListCAs(); err != nil { + t.Fatal(err) + } else if slices.ContainsFunc(initialCAs, ca.Equal) { + t.Fatalf("%q: initial ca list already contains %+v ca", store, ca) + } + + ok, err := store.CheckCA(ca) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatalf("%q: check ca with %+v unexpectedly passed", store.Description(), ca) + } + + if ok, err = store.InstallCA(ca); err != nil { + t.Fatal(err) + } + if !ok { + t.Fatalf("%q: install ca with %+v failed", store.Description(), ca) + } + + if ok, err = store.CheckCA(ca); err != nil { + t.Fatal(err) + } + if !ok { + t.Fatalf("%q: check ca with %+v failed", store.Description(), ca) + } + + if allCAs, err := store.ListCAs(); err != nil { + t.Fatal(err) + } else if !slices.ContainsFunc(allCAs, ca.Equal) { + t.Fatalf("%q: ca list does not contain %+v ca", store.Description(), ca) + } + + if ok, err := store.UninstallCA(ca); err != nil { + t.Fatal(err) + } else if !ok { + t.Fatalf("%q: uninstall ca with %+v failed", store.Description(), ca) + } + + if allCAs, err := store.ListCAs(); err != nil { + t.Fatal(err) + } else if slices.ContainsFunc(allCAs, ca.Equal) { + t.Fatalf("%q: ca list still contains %+v ca", store.Description(), ca) + } +} + +var ca = mustCA(must.CA(&x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Example CA", + Organization: []string{"Example, Inc"}, + }, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + + ExtraExtensions: []pkix.Extension{}, +})) + +func mustCA(cert *must.Certificate) *CA { + uniqueName := cert.Leaf.SerialNumber.Text(16) + + return &CA{ + Certificate: cert.Leaf, + FilePath: "example-ca-" + uniqueName + ".pem", + UniqueName: uniqueName, + } +} + +type TestFS fstest.MapFS + +func (fsys TestFS) Open(name string) (fs.File, error) { return fstest.MapFS(fsys).Open(name) } +func (fsys TestFS) ReadFile(name string) ([]byte, error) { return fstest.MapFS(fsys).ReadFile(name) } +func (fsys TestFS) Stat(name string) (fs.FileInfo, error) { return fstest.MapFS(fsys).Stat(name) } + +func (fsys TestFS) AppendToFile(name string, p []byte) error { + f, ok := fsys[name] + if !ok { + f = new(fstest.MapFile) + fsys[name] = f + } + + f.Data = append(f.Data, p...) + f.Sys = mapFI{ + name: name, + size: len(f.Data), + } + + return nil +} + +func (fsys TestFS) Remove(name string) error { + delete(fsys, name) + return nil +} + +func (fsys TestFS) Rename(oldpath, newpath string) error { + fsys[newpath] = fsys[oldpath] + return fsys.Remove(oldpath) +} + +// golang.org/x/tools/godoc/vfs/mapfs + +type mapFI struct { + name string + size int + dir bool +} + +func (fi mapFI) IsDir() bool { return fi.dir } +func (fi mapFI) ModTime() time.Time { return time.Time{} } +func (fi mapFI) Mode() os.FileMode { + if fi.IsDir() { + return 0755 | os.ModeDir + } + return 0444 +} +func (fi mapFI) Name() string { return path.Base(fi.name) } +func (fi mapFI) Size() int64 { return int64(fi.size) } +func (fi mapFI) Sys() interface{} { return nil } diff --git a/ui/driver.go b/ui/driver.go new file mode 100644 index 0000000..b17a710 --- /dev/null +++ b/ui/driver.go @@ -0,0 +1,132 @@ +package ui + +import ( + "context" + "reflect" + + tea "github.com/charmbracelet/bubbletea" + "github.com/muesli/termenv" +) + +type Program interface { + Quit() + Run() (tea.Model, error) + Send(tea.Msg) +} + +type Driver struct { + Program // TUI mode + + TTY termenv.File + + models []tea.Model + active tea.Model +} + +func NewDriverTUI(ctx context.Context) (*Driver, Program) { + drv := new(Driver) + + opts := []tea.ProgramOption{ + tea.WithInputTTY(), + tea.WithContext(ctx), + tea.WithoutCatchPanics(), // TODO: remove + } + drv.Program = tea.NewProgram(drv, opts...) + + return drv, drv.Program +} + +func NewDriverTTY(ctx context.Context) *Driver { + return &Driver{ + TTY: termenv.DefaultOutput().TTY(), + } +} + +type activateMsg struct { + tea.Model + + donec chan<- struct{} +} + +func (d *Driver) Activate(ctx context.Context, model tea.Model) { + donec := make(chan struct{}) + + d.Send(activateMsg{ + Model: model, + donec: donec, + }) + + select { + case <-donec: + case <-ctx.Done(): + } +} + +type stopMsg struct{} + +func (d *Driver) Stop() { d.Send(stopMsg{}) } + +type pauseMsg chan chan struct{} + +func (d *Driver) Pause() chan<- struct{} { + unpausec := make(chan struct{}) + pausedc := make(chan chan struct{}) + + d.Send(pauseMsg(pausedc)) + + pausedc <- unpausec + + return unpausec +} + +func (d *Driver) Init() tea.Cmd { + return nil +} + +func (d *Driver) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case activateMsg: + d.models = append(d.models, msg.Model) + d.active = msg.Model + + close(msg.donec) + + return d, d.active.Init() + case pauseMsg: + unpausec := <-msg + <-unpausec + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC: + return d, tea.Quit + } + } + + if d.active == nil { + if cmd, ok := msg.(tea.Cmd); ok && isQuit(cmd) { + return d, tea.Quit + } + return d, nil + } + + _, cmd := d.active.Update(msg) + if isQuit(cmd) { + d.active = nil + return d, nil + } + return d, cmd +} + +func (d *Driver) View() string { + var out string + for _, mdl := range d.models { + out += mdl.View() + } + return out +} + +var quitPtr = reflect.ValueOf(tea.Quit).Pointer() + +func isQuit(cmd tea.Cmd) bool { + return reflect.ValueOf(cmd).Pointer() == quitPtr +} diff --git a/ui/styles.go b/ui/styles.go new file mode 100644 index 0000000..c39f627 --- /dev/null +++ b/ui/styles.go @@ -0,0 +1,99 @@ +package ui + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + hint = lipgloss.NewStyle().Faint(true).SetString("|") + + Header = lipgloss.NewStyle().Bold(true).SetString("#").Render + Hint = hint.Copy().Render + Underline = lipgloss.NewStyle().Underline(true).Render + + // https://github.com/charmbracelet/lipgloss/blob/v0.9.1/style.go#L149 + + StepAlert = lipgloss.NewStyle().SetString(" " + Announce("!")).Render + StepDone = lipgloss.NewStyle().SetString(" -").Render + StepHint = hint.Copy().SetString(" |").Render + StepInProgress = lipgloss.NewStyle().SetString(" *").Render + StepPrompt = lipgloss.NewStyle().SetString(" " + Prompt.Render("?")).Render + + Accentuate = lipgloss.NewStyle().Italic(true).Render + Action = lipgloss.NewStyle().Bold(true).Foreground(colorBrandPrimary).Render + Announce = lipgloss.NewStyle().Background(colorBrandSecondary).Render + Emphasize = lipgloss.NewStyle().Bold(true).Render + Titlize = lipgloss.NewStyle().Bold(true).Render + URL = lipgloss.NewStyle().Faint(true).Underline(true).Render + Whisper = lipgloss.NewStyle().Faint(true).Render + + colorBrandPrimary = lipgloss.Color("#ff6000") + colorBrandSecondary = lipgloss.Color("#7000ff") + + Prompt = lipgloss.NewStyle().Foreground(colorBrandPrimary) +) + +func Spinner() spinner.Model { + return spinner.New( + spinner.WithSpinner(spinner.MiniDot), + spinner.WithStyle(lipgloss.NewStyle().Foreground(colorBrandSecondary)), + ) +} + +type ListItem[T any] struct { + Key T + Value string +} + +func (li ListItem[T]) FilterValue() string { return li.Value } + +type itemDelegate[T any] struct{} + +func (d itemDelegate[T]) Height() int { return 1 } +func (d itemDelegate[T]) Spacing() int { return 0 } +func (d itemDelegate[T]) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } +func (d itemDelegate[T]) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + i, ok := listItem.(ListItem[T]) + if !ok { + return + } + + if index == m.Index() { + fmt.Fprintf(w, Action(fmt.Sprintf(" > %s", i.Value))) + } else { + fmt.Fprintf(w, fmt.Sprintf(" %s", i.Value)) + } +} + +func List[T any](items []ListItem[T]) list.Model { + var lis []list.Item + for _, item := range items { + lis = append(lis, item) + } + + l := list.New(lis, itemDelegate[T]{}, 80, len(items)) + l.SetShowFilter(false) + l.SetShowHelp(false) + l.SetShowPagination(false) + l.SetShowStatusBar(false) + l.SetShowTitle(false) + + return l +} + +func Domains(domains []string) string { + var styled_domains []string + + for _, domain := range domains { + styled_domains = append(styled_domains, URL(domain)) + } + + return strings.Join(styled_domains, ", ") +} diff --git a/ui/uitest/uitest.go b/ui/uitest/uitest.go new file mode 100644 index 0000000..4c3100a --- /dev/null +++ b/ui/uitest/uitest.go @@ -0,0 +1,32 @@ +package uitest + +import ( + "context" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/teatest" + + "github.com/anchordotdev/cli/ui" +) + +func TestTUI(ctx context.Context, t *testing.T) (*ui.Driver, *teatest.TestModel) { + drv := new(ui.Driver) + tm := teatest.NewTestModel(t, drv, teatest.WithInitialTermSize(800, 600)) + + drv.Program = program{tm} + + return drv, tm +} + +type program struct { + *teatest.TestModel +} + +func (p program) Quit() { + panic("TODO") +} + +func (p program) Run() (tea.Model, error) { + panic("TODO") +}