427 lines
13 KiB
Go
427 lines
13 KiB
Go
|
|
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
|
||
|
|
}
|