package main import ( "bytes" "context" "crypto/tls" "crypto/x509" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "regexp" "strings" "time" ) var appPasswordPattern = regexp.MustCompile(`app_[A-Za-z0-9]+`) type vaultClient struct { address string token string client *http.Client } func newVaultHTTPClient(caFile string) (*http.Client, error) { certificate, err := os.ReadFile(caFile) if err != nil { return nil, err } roots, err := x509.SystemCertPool() if err != nil { return nil, err } if !roots.AppendCertsFromPEM(certificate) { return nil, errors.New("vault CA file contains no certificates") } transport := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots}} return &http.Client{Timeout: 15 * time.Second, Transport: transport}, nil } func vaultLogin(ctx context.Context, value options) (vaultClient, error) { client, err := newVaultHTTPClient(value.vaultCACertFile) if err != nil { return vaultClient{}, err } jwt, err := readSecret(value.vaultTokenFile) if err != nil { return vaultClient{}, err } payload, err := json.Marshal(map[string]string{"role": value.vaultRole, "jwt": jwt}) if err != nil { return vaultClient{}, err } target := strings.TrimRight(value.vaultAddress, "/") + "/v1/auth/kubernetes/login" request, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(payload)) if err != nil { return vaultClient{}, err } request.Header.Set("Content-Type", "application/json") response, err := client.Do(request) if err != nil { return vaultClient{}, err } defer func() { _ = response.Body.Close() }() if response.StatusCode != http.StatusOK { return vaultClient{}, responseStatusError("vault Kubernetes login", response, jwt) } var result struct { Auth struct { ClientToken string `json:"client_token"` } `json:"auth"` } if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&result); err != nil { return vaultClient{}, err } if result.Auth.ClientToken == "" { return vaultClient{}, errors.New("vault login response has no client token") } return vaultClient{address: strings.TrimRight(value.vaultAddress, "/"), token: result.Auth.ClientToken, client: client}, nil } func (client vaultClient) request(ctx context.Context, method, path string, payload []byte) (*http.Response, error) { request, err := http.NewRequestWithContext(ctx, method, client.address+"/v1/"+path, bytes.NewReader(payload)) if err != nil { return nil, err } request.Header.Set("X-Vault-Token", client.token) if len(payload) != 0 { request.Header.Set("Content-Type", "application/json") } return client.client.Do(request) } type storedAppPassword struct { ID string Password string } func (client vaultClient) appPassword(ctx context.Context, path, key string) (storedAppPassword, bool, error) { response, err := client.request(ctx, http.MethodGet, path, nil) if err != nil { return storedAppPassword{}, false, fmt.Errorf("read app password from vault: %w", err) } defer func() { _ = response.Body.Close() }() if response.StatusCode == http.StatusNotFound { return storedAppPassword{}, false, nil } if response.StatusCode != http.StatusOK { return storedAppPassword{}, false, fmt.Errorf("read app password from vault: HTTP %d", response.StatusCode) } var result struct { Data struct { Data map[string]string `json:"data"` } `json:"data"` } if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&result); err != nil { return storedAppPassword{}, false, fmt.Errorf("decode app password from vault: %w", err) } credential := storedAppPassword{ ID: result.Data.Data["stalwart_app_password_id"], Password: result.Data.Data[key], } return credential, credential.Password != "", nil } func (client vaultClient) writeAppPassword(ctx context.Context, path, key string, credential storedAppPassword) error { payload, err := json.Marshal(map[string]any{"data": map[string]string{ key: credential.Password, "stalwart_app_password_id": credential.ID, }}) if err != nil { return fmt.Errorf("encode app password for vault: %w", err) } response, err := client.request(ctx, http.MethodPost, path, payload) if err != nil { return fmt.Errorf("write app password to vault: %w", err) } defer func() { _ = response.Body.Close() }() if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusNoContent { return fmt.Errorf("write app password to vault: HTTP %d", response.StatusCode) } return nil } type appPassword struct { ID string `json:"id"` Description string `json:"description"` } func appPasswords(output []byte) ([]appPassword, error) { decoder := json.NewDecoder(bytes.NewReader(output)) var items []appPassword for { var item appPassword err := decoder.Decode(&item) if errors.Is(err, io.EOF) { break } if err != nil { return nil, err } if item.ID == "" { return nil, errors.New("AppPassword query returned an empty id") } items = append(items, item) } return items, nil } func createdAppPassword(output []byte) (string, error) { matches := appPasswordPattern.FindAll(output, -1) if len(matches) != 1 { return "", errors.New("AppPassword creation did not return one credential") } return string(matches[0]), nil } func appPasswordToken(ctx context.Context, value options, clientIDFile, clientSecretFile string) (string, error) { return oidcTokenFromFiles(ctx, value.oidcIssuerFile, value.oidcAudienceFile, clientIDFile, clientSecretFile) } func appPasswordWorks(ctx context.Context, baseURL, username, password string) (bool, error) { target, err := url.Parse(baseURL) if err != nil || target.Scheme == "" || target.Host == "" { return false, errors.New("stalwart URL is invalid") } target.Path = "/jmap/session" request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil) if err != nil { return false, fmt.Errorf("build app-password verification request: %w", err) } request.SetBasicAuth(username, password) client := &http.Client{Timeout: 15 * time.Second} response, err := client.Do(request) if err != nil { return false, fmt.Errorf("verify app password: %w", err) } defer func() { _ = response.Body.Close() }() switch response.StatusCode { case http.StatusOK: return true, nil case http.StatusUnauthorized, http.StatusForbidden: return false, nil default: return false, fmt.Errorf("verify app password: HTTP %d", response.StatusCode) } } type appPasswordIdentity struct { ClientIDFile string `json:"clientIdFile"` ClientSecretFile string `json:"clientSecretFile"` Username string `json:"username"` Description string `json:"description"` VaultPath string `json:"vaultPath"` VaultKey string `json:"vaultKey"` Permissions json.RawMessage `json:"permissions"` } func loadAppPasswordIdentities(path string) ([]appPasswordIdentity, error) { content, err := os.ReadFile(path) if err != nil { return nil, err } var identities []appPasswordIdentity if err := json.Unmarshal(content, &identities); err != nil { return nil, err } for index, identity := range identities { if identity.ClientIDFile == "" || identity.ClientSecretFile == "" || identity.Username == "" || identity.Description == "" || identity.VaultPath == "" || identity.VaultKey == "" || len(identity.Permissions) == 0 { return nil, fmt.Errorf("app-password identity %d is incomplete", index) } var permissions map[string]any if err := json.Unmarshal(identity.Permissions, &permissions); err != nil || len(permissions) == 0 { return nil, fmt.Errorf("app-password identity %d has invalid permissions", index) } } return identities, nil } type appPasswordOperations struct { query func() ([]appPassword, error) create func([]appPassword) (storedAppPassword, error) delete func([]string) error verify func(string) (bool, error) read func() (storedAppPassword, bool, error) write func(storedAppPassword) error } func queryAppPasswords(ctx context.Context, value options, environment []string) ([]appPassword, error) { output, err := runCLI(ctx, value.cli, environment, true, "query", "AppPassword", "--fields", "id,description", "--json", "--no-color") if err != nil { return nil, err } items, err := appPasswords(output) if err != nil { return nil, fmt.Errorf("decode app-password inventory: %w", err) } return items, nil } func appPasswordIDs(items []appPassword, except string) []string { ids := make([]string, 0, len(items)) for _, item := range items { if item.ID != except { ids = append(ids, item.ID) } } return ids } func deleteAppPasswords(ctx context.Context, value options, environment []string, ids []string) error { if len(ids) == 0 { return nil } _, err := runCLI(ctx, value.cli, environment, false, "delete", "AppPassword", "--ids", strings.Join(ids, ","), "--no-color") return err } func createAppPassword( ctx context.Context, value options, environment []string, identity appPasswordIdentity, existing []appPassword, ) (storedAppPassword, error) { output, err := runCLI( ctx, value.cli, environment, true, "create", "AppPassword", "--field", "description="+identity.Description, "--field", "permissions="+string(identity.Permissions), "--field", "allowedIps={}", "--no-color", ) if err != nil { return storedAppPassword{}, err } password, err := createdAppPassword(output) if err != nil { return storedAppPassword{}, fmt.Errorf("decode created app password: %w", err) } existingIDs := make(map[string]struct{}, len(existing)) for _, item := range existing { existingIDs[item.ID] = struct{}{} } current, err := queryAppPasswords(ctx, value, environment) if err != nil { return storedAppPassword{}, err } var created []appPassword for _, item := range current { if _, existed := existingIDs[item.ID]; !existed && item.Description == identity.Description { created = append(created, item) } } if len(created) != 1 { return storedAppPassword{}, errors.New("created app password is not uniquely identifiable") } return storedAppPassword{ID: created[0].ID, Password: password}, nil } func convergeAppPassword(description string, operations appPasswordOperations) error { items, err := operations.query() if err != nil { return err } credential, present, err := operations.read() if err != nil { return err } if present { valid, verifyErr := operations.verify(credential.Password) if verifyErr != nil { return verifyErr } if valid { if credential.ID != "" { for _, item := range items { if item.ID == credential.ID && item.Description == description { return operations.delete(appPasswordIDs(items, credential.ID)) } } } if len(items) == 1 && items[0].Description == description { credential.ID = items[0].ID return operations.write(credential) } } } created, err := operations.create(items) if err != nil { return err } valid, verifyErr := operations.verify(created.Password) if verifyErr != nil { cleanupErr := operations.delete([]string{created.ID}) return errors.Join(verifyErr, cleanupErr) } if !valid { cleanupErr := operations.delete([]string{created.ID}) return errors.Join(errors.New("new app password failed authentication"), cleanupErr) } if err := operations.write(created); err != nil { cleanupErr := operations.delete([]string{created.ID}) return errors.Join(err, cleanupErr) } return operations.delete(appPasswordIDs(items, created.ID)) } func reconcileAppPassword(ctx context.Context, value options, vault vaultClient, identity appPasswordIdentity) error { token, err := appPasswordToken(ctx, value, identity.ClientIDFile, identity.ClientSecretFile) if err != nil { return fmt.Errorf("issue app-password token: %w", err) } environment := []string{"STALWART_TOKEN=" + token, "STALWART_URL=" + value.stalwartURL} operations := appPasswordOperations{ query: func() ([]appPassword, error) { return queryAppPasswords(ctx, value, environment) }, create: func(existing []appPassword) (storedAppPassword, error) { return createAppPassword(ctx, value, environment, identity, existing) }, delete: func(ids []string) error { return deleteAppPasswords(ctx, value, environment, ids) }, verify: func(password string) (bool, error) { return appPasswordWorks(ctx, value.stalwartURL, identity.Username, password) }, read: func() (storedAppPassword, bool, error) { return vault.appPassword(ctx, identity.VaultPath, identity.VaultKey) }, write: func(credential storedAppPassword) error { return vault.writeAppPassword(ctx, identity.VaultPath, identity.VaultKey, credential) }, } return convergeAppPassword(identity.Description, operations) } func reconcileAppPasswords(ctx context.Context, value options) error { if value.appPasswords == "" { return nil } identities, err := loadAppPasswordIdentities(value.appPasswords) if err != nil { return fmt.Errorf("load app-password identities: %w", err) } vault, err := vaultLogin(ctx, value) if err != nil { return fmt.Errorf("log in to vault: %w", err) } for _, identity := range identities { if err := reconcileAppPassword(ctx, value, vault, identity); err != nil { return fmt.Errorf("reconcile app-password identity %q: %w", identity.Description, err) } } return nil }