ce7cb702218693d08ea6d83464d2457c9250027c
This commit is contained in:
commit
34c5f8963c
1 changed files with 145336 additions and 0 deletions
427
services/stalwart/cmd/controller/app_password.go
Normal file
427
services/stalwart/cmd/controller/app_password.go
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
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
|
||||
}
|
||||
Loading…
Reference in a new issue