package cli import ( "bytes" "context" "encoding/json" "fmt" "net" "net/http" "net/url" "postern/internal/model" ) func newUnixHTTPClient(socketPath string) *http.Client { return &http.Client{ Transport: &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { var d net.Dialer return d.DialContext(ctx, "unix", socketPath) }, }, } } // clientListUsers fetches the list of users from the daemon. func (c *Command) clientListUsers(ctx context.Context) (_ []model.User, err error) { resp, err := c.httpClient.Get("http://unix/users") if err != nil { return nil, fmt.Errorf("failed to list users: %w", err) } defer model.Wrap(resp.Body.Close, &err, "clientListUsers closing response body") if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to list users: %d", resp.StatusCode) } var users []model.User if err := json.NewDecoder(resp.Body).Decode(&users); err != nil { return nil, fmt.Errorf("failed to decode users response: %w", err) } return users, nil } // clientCreateUser creates a new user via the daemon. func (c *Command) clientCreateUser(ctx context.Context, username string) (_ model.User, err error) { reqBody := map[string]string{"username": username} body, err := json.Marshal(reqBody) if err != nil { return model.User{}, fmt.Errorf("failed to marshal user request: %w", err) } resp, err := c.httpClient.Post("http://unix/users", "application/json", bytes.NewReader(body)) if err != nil { return model.User{}, fmt.Errorf("failed to create user: %w", err) } defer model.Wrap(resp.Body.Close, &err, "clientCreateUser closing response body") if resp.StatusCode != http.StatusCreated { return model.User{}, fmt.Errorf("failed to create user: %d", resp.StatusCode) } var user model.User if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { return model.User{}, fmt.Errorf("failed to decode user response: %w", err) } return user, nil } // clientRotateUserPassword rotates a user's password via the daemon. func (c *Command) clientRotateUserPassword(ctx context.Context, username string) (_ model.User, err error) { path, err := url.JoinPath("http://unix/users", username, "rotate-password") if err != nil { return model.User{}, fmt.Errorf("failed to create rotate password request path: %w", err) } resp, err := c.httpClient.Post(path, "application/json", nil) if err != nil { return model.User{}, fmt.Errorf("failed to rotate user password: %w", err) } defer model.Wrap(resp.Body.Close, &err, "clientRotateUserPassword closing response body") if resp.StatusCode != http.StatusOK { return model.User{}, fmt.Errorf("failed to rotate user password: %d", resp.StatusCode) } var user model.User if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { return model.User{}, fmt.Errorf("failed to decode user response: %w", err) } return user, nil } // clientRemoveUser removes a user via the daemon. func (c *Command) clientRemoveUser(ctx context.Context, username string) (err error) { req, err := http.NewRequestWithContext(ctx, http.MethodDelete, "http://unix/users/"+username, nil) if err != nil { return fmt.Errorf("failed to create delete user request: %w", err) } resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to remove user: %w", err) } defer model.Wrap(resp.Body.Close, &err, "clientRemoveUser closing response body") if resp.StatusCode != http.StatusNoContent { return fmt.Errorf("failed to remove user: %d", resp.StatusCode) } return nil } // clientAddUserAddress adds an address for a user via the daemon. func (c *Command) clientAddUserAddress(ctx context.Context, username, address string) error { reqBody := map[string]string{"address": address} body, err := json.Marshal(reqBody) if err != nil { return fmt.Errorf("failed to marshal user address request: %w", err) } path, err := url.JoinPath("http://unix/users/", username, "addresses") if err != nil { return fmt.Errorf("failed to create address request: %w", err) } resp, err := c.httpClient.Post(path, "application/json", bytes.NewReader(body)) if err != nil { return fmt.Errorf("failed to add user address: %w", err) } defer model.Wrap(resp.Body.Close, &err, "clientAddUserAddress closing response body") if resp.StatusCode != http.StatusCreated { return fmt.Errorf("failed to add user address: %d", resp.StatusCode) } return nil } // clientRemoveUserAddress removes an address for a user via the daemon. func (c *Command) clientRemoveUserAddress(ctx context.Context, username, address string) (err error) { path, err := url.JoinPath("http://unix/users/", username, "addresses", address) if err != nil { return fmt.Errorf("failed to create address request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil) if err != nil { return fmt.Errorf("failed to create delete address request: %w", err) } resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to remove user address: %w", err) } defer model.Wrap(resp.Body.Close, &err, "clientRemoveUserAddress closing response body") if resp.StatusCode != http.StatusNoContent { return fmt.Errorf("failed to remove user address: %d", resp.StatusCode) } return nil } // clientListUserAddresses fetches the list of addresses for a user from the daemon. func (c *Command) clientListUserAddresses(ctx context.Context, username string) (_ []string, err error) { path, err := url.JoinPath("http://unix/users/", username, "addresses") if err != nil { return nil, fmt.Errorf("failed to create address request: %w", err) } resp, err := c.httpClient.Get(path) if err != nil { return nil, fmt.Errorf("failed to list user addresses: %w", err) } defer model.Wrap(resp.Body.Close, &err, "clientListUserAddresses closing response body") if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to list user addresses: %d", resp.StatusCode) } var addresses []string if err := json.NewDecoder(resp.Body).Decode(&addresses); err != nil { return nil, fmt.Errorf("failed to decode user addresses response: %w", err) } return addresses, nil } // clientListSMTPAuth fetches the list of SMTP auth users from the daemon. func (c *Command) clientListSMTPAuth(ctx context.Context) ([]model.SMTPAuth, error) { resp, err := c.httpClient.Get("http://unix/smtp-auth") if err != nil { return nil, fmt.Errorf("failed to list smtp auth: %w", err) } defer model.Wrap(resp.Body.Close, &err, "clientListSMTPAuth closing response body") if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to list smtp auth: %d", resp.StatusCode) } var auths []model.SMTPAuth if err := json.NewDecoder(resp.Body).Decode(&auths); err != nil { return nil, fmt.Errorf("failed to decode smtp auth response: %w", err) } return auths, nil } // clientCreateSMTPAuth creates a new SMTP auth user via the daemon. func (c *Command) clientCreateSMTPAuth(_ context.Context, name string) (model.SMTPAuth, error) { reqBody := map[string]string{"name": name} body, err := json.Marshal(reqBody) if err != nil { return model.SMTPAuth{}, fmt.Errorf("failed to marshal smtp auth request: %w", err) } resp, err := c.httpClient.Post("http://unix/smtp-auth", "application/json", bytes.NewReader(body)) if err != nil { return model.SMTPAuth{}, fmt.Errorf("failed to create smtp auth: %w", err) } defer model.Wrap(resp.Body.Close, &err, "clientCreateSMTPAuth closing response body") if resp.StatusCode != http.StatusCreated { return model.SMTPAuth{}, fmt.Errorf("failed to create smtp auth: %d", resp.StatusCode) } var auth model.SMTPAuth if err := json.NewDecoder(resp.Body).Decode(&auth); err != nil { return model.SMTPAuth{}, fmt.Errorf("failed to decode smtp auth response: %w", err) } return auth, nil } // clientRemoveSMTPAuth removes an SMTP auth user via the daemon. func (c *Command) clientRemoveSMTPAuth(ctx context.Context, name string) error { path, err := url.JoinPath("http://unix/smtp-auth", name) if err != nil { return fmt.Errorf("failed to create remove smtp auth request path: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil) if err != nil { return fmt.Errorf("failed to create delete smtp auth request: %w", err) } resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to remove smtp auth: %w", err) } defer model.Wrap(resp.Body.Close, &err, "clientRemoveSMTPAuth closing response body") if resp.StatusCode != http.StatusNoContent { return fmt.Errorf("failed to remove smtp auth: %d", resp.StatusCode) } return nil } // clientListDKIM fetches the list of DKIM selectors from the daemon. func (c *Command) clientListDKIM(ctx context.Context) ([]model.DKIM, error) { resp, err := c.httpClient.Get("http://unix/dkim") if err != nil { return nil, fmt.Errorf("failed to list dkim: %w", err) } defer model.Wrap(resp.Body.Close, &err, "clientListDKIM closing response body") if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to list dkim: %d", resp.StatusCode) } var dkims []model.DKIM if err := json.NewDecoder(resp.Body).Decode(&dkims); err != nil { return nil, fmt.Errorf("failed to decode dkim response: %w", err) } return dkims, nil } // clientAddDKIM adds a new DKIM selector via the daemon. func (c *Command) clientAddDKIM(ctx context.Context, selector, domain string) (string, error) { reqBody := map[string]string{"selector": selector, "domain": domain} body, err := json.Marshal(reqBody) if err != nil { return "", fmt.Errorf("failed to marshal dkim request: %w", err) } resp, err := c.httpClient.Post("http://unix/dkim", "application/json", bytes.NewReader(body)) if err != nil { return "", fmt.Errorf("failed to add dkim: %w", err) } defer model.Wrap(resp.Body.Close, &err, "clientAddDKIM closing response body") if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("failed to add dkim: %d", resp.StatusCode) } var result map[string]string if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", fmt.Errorf("failed to decode dkim response: %w", err) } return result["dns_record"], nil } // clientGetDKIM gets the public DNS record for a DKIM selector via the daemon. func (c *Command) clientGetDKIM(_ context.Context, selector, domain string) (string, error) { path, err := url.JoinPath("http://unix/dkim", selector, domain) if err != nil { return "", fmt.Errorf("failed to create dkim request path: %w", err) } resp, err := c.httpClient.Get(path) if err != nil { return "", fmt.Errorf("failed to get dkim: %w", err) } defer model.Wrap(resp.Body.Close, &err, "clientGetDKIM closing response body") if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("failed to get dkim: %d", resp.StatusCode) } var result map[string]string if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", fmt.Errorf("failed to decode dkim response: %w", err) } return result["dns_record"], nil } // clientSetDKIMEnabled enables/disables a DKIM selector via the daemon. func (c *Command) clientSetDKIMEnabled(_ context.Context, selector, domain string, enable bool, force bool) error { enableKeyword := "enable" if !enable { enableKeyword = "disable" } path, err := url.JoinPath("http://unix/dkim", selector, domain, enableKeyword) if err != nil { return fmt.Errorf("failed to create dkim request path: %w", err) } // Include force parameter in query string if !force { path += "?force=false" } resp, err := c.httpClient.Post(path, "application/json", nil) if err != nil { return fmt.Errorf("failed to set dkim enabled: %w", err) } defer model.Wrap(resp.Body.Close, &err, "clientSetDKIMEnabled closing response body") if resp.StatusCode != http.StatusNoContent { return fmt.Errorf("failed to set dkim enabled: %d", resp.StatusCode) } return nil } // clientRemoveDKIM removes a DKIM selector via the daemon. func (c *Command) clientRemoveDKIM(ctx context.Context, selector, domain string) error { path, err := url.JoinPath("http://unix/dkim", selector, domain) if err != nil { return fmt.Errorf("failed to create dkim request path: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil) if err != nil { return fmt.Errorf("failed to create delete dkim request: %w", err) } resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to remove dkim: %w", err) } defer model.Wrap(resp.Body.Close, &err, "clientRemoveDKIM closing response body") if resp.StatusCode != http.StatusNoContent { return fmt.Errorf("failed to remove dkim: %d", resp.StatusCode) } return nil } // clientPruneMails prunes orphaned mails via the daemon. func (c *Command) clientPruneMails(_ context.Context) (int, error) { resp, err := c.httpClient.Post("http://unix/prune", "application/json", nil) if err != nil { return 0, fmt.Errorf("failed to prune mails: %w", err) } defer model.Wrap(resp.Body.Close, &err, "clientPruneMails closing response body") if resp.StatusCode != http.StatusOK { return 0, fmt.Errorf("failed to prune mails: %d", resp.StatusCode) } var result map[string]int if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return 0, fmt.Errorf("failed to decode prune response: %w", err) } return result["pruned"], nil } // clientKVList fetches all key-value pairs from the daemon. func (c *Command) clientKVList(_ context.Context) (map[string]any, error) { resp, err := c.httpClient.Get("http://unix/kv") if err != nil { return nil, fmt.Errorf("failed to list kv: %w", err) } defer model.Wrap(resp.Body.Close, &err, "clientKVList closing response body") if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to list kv: %d", resp.StatusCode) } var pairs map[string]any if err := json.NewDecoder(resp.Body).Decode(&pairs); err != nil { return nil, fmt.Errorf("failed to decode kv response: %w", err) } return pairs, nil } // clientKVGet fetches the value stored under key from the daemon. // The key is URL-encoded so keys containing slashes work. func (c *Command) clientKVGet(_ context.Context, key string) (any, error) { path := "http://unix/kv/" + url.PathEscape(key) resp, err := c.httpClient.Get(path) if err != nil { return nil, fmt.Errorf("failed to get kv: %w", err) } defer model.Wrap(resp.Body.Close, &err, "clientKVGet closing response body") if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to get kv: %d", resp.StatusCode) } var result struct { Value any `json:"value"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode kv response: %w", err) } return result.Value, nil } // clientKVPop removes the value stored under key via the daemon and returns it. // The key is URL-encoded so keys containing slashes work. func (c *Command) clientKVPop(ctx context.Context, key string) (any, error) { path := "http://unix/kv/" + url.PathEscape(key) req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil) if err != nil { return nil, fmt.Errorf("failed to create pop kv request: %w", err) } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to pop kv: %w", err) } defer model.Wrap(resp.Body.Close, &err, "clientKVPop closing response body") if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to pop kv: %d", resp.StatusCode) } var result struct { Value any `json:"value"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode kv response: %w", err) } return result.Value, nil }