internal/cli/client.go (view raw)
1package cli
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "net"
9 "net/http"
10 "net/url"
11
12 "postern/internal/model"
13)
14
15func newUnixHTTPClient(socketPath string) *http.Client {
16 return &http.Client{
17 Transport: &http.Transport{
18 DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
19 var d net.Dialer
20 return d.DialContext(ctx, "unix", socketPath)
21 },
22 },
23 }
24}
25
26// clientListUsers fetches the list of users from the daemon.
27func (c *Command) clientListUsers(ctx context.Context) (_ []model.User, err error) {
28 resp, err := c.httpClient.Get("http://unix/users")
29 if err != nil {
30 return nil, fmt.Errorf("failed to list users: %w", err)
31 }
32 defer model.Wrap(resp.Body.Close, &err, "clientListUsers closing response body")
33
34 if resp.StatusCode != http.StatusOK {
35 return nil, fmt.Errorf("failed to list users: %d", resp.StatusCode)
36 }
37
38 var users []model.User
39 if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
40 return nil, fmt.Errorf("failed to decode users response: %w", err)
41 }
42
43 return users, nil
44}
45
46// clientCreateUser creates a new user via the daemon.
47func (c *Command) clientCreateUser(ctx context.Context, username string) (_ model.User, err error) {
48 reqBody := map[string]string{"username": username}
49 body, err := json.Marshal(reqBody)
50 if err != nil {
51 return model.User{}, fmt.Errorf("failed to marshal user request: %w", err)
52 }
53
54 resp, err := c.httpClient.Post("http://unix/users", "application/json", bytes.NewReader(body))
55 if err != nil {
56 return model.User{}, fmt.Errorf("failed to create user: %w", err)
57 }
58 defer model.Wrap(resp.Body.Close, &err, "clientCreateUser closing response body")
59
60 if resp.StatusCode != http.StatusCreated {
61 return model.User{}, fmt.Errorf("failed to create user: %d", resp.StatusCode)
62 }
63
64 var user model.User
65 if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
66 return model.User{}, fmt.Errorf("failed to decode user response: %w", err)
67 }
68
69 return user, nil
70}
71
72// clientRotateUserPassword rotates a user's password via the daemon.
73func (c *Command) clientRotateUserPassword(ctx context.Context, username string) (_ model.User, err error) {
74 path, err := url.JoinPath("http://unix/users", username, "rotate-password")
75 if err != nil {
76 return model.User{}, fmt.Errorf("failed to create rotate password request path: %w", err)
77 }
78
79 resp, err := c.httpClient.Post(path, "application/json", nil)
80 if err != nil {
81 return model.User{}, fmt.Errorf("failed to rotate user password: %w", err)
82 }
83 defer model.Wrap(resp.Body.Close, &err, "clientRotateUserPassword closing response body")
84
85 if resp.StatusCode != http.StatusOK {
86 return model.User{}, fmt.Errorf("failed to rotate user password: %d", resp.StatusCode)
87 }
88
89 var user model.User
90 if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
91 return model.User{}, fmt.Errorf("failed to decode user response: %w", err)
92 }
93
94 return user, nil
95}
96
97// clientRemoveUser removes a user via the daemon.
98func (c *Command) clientRemoveUser(ctx context.Context, username string) (err error) {
99 req, err := http.NewRequestWithContext(ctx, http.MethodDelete, "http://unix/users/"+username, nil)
100 if err != nil {
101 return fmt.Errorf("failed to create delete user request: %w", err)
102 }
103
104 resp, err := c.httpClient.Do(req)
105 if err != nil {
106 return fmt.Errorf("failed to remove user: %w", err)
107 }
108 defer model.Wrap(resp.Body.Close, &err, "clientRemoveUser closing response body")
109
110 if resp.StatusCode != http.StatusNoContent {
111 return fmt.Errorf("failed to remove user: %d", resp.StatusCode)
112 }
113
114 return nil
115}
116
117// clientAddUserAddress adds an address for a user via the daemon.
118func (c *Command) clientAddUserAddress(ctx context.Context, username, address string) error {
119 reqBody := map[string]string{"address": address}
120 body, err := json.Marshal(reqBody)
121 if err != nil {
122 return fmt.Errorf("failed to marshal user address request: %w", err)
123 }
124
125 path, err := url.JoinPath("http://unix/users/", username, "addresses")
126 if err != nil {
127 return fmt.Errorf("failed to create address request: %w", err)
128 }
129
130 resp, err := c.httpClient.Post(path, "application/json", bytes.NewReader(body))
131 if err != nil {
132 return fmt.Errorf("failed to add user address: %w", err)
133 }
134 defer model.Wrap(resp.Body.Close, &err, "clientAddUserAddress closing response body")
135
136 if resp.StatusCode != http.StatusCreated {
137 return fmt.Errorf("failed to add user address: %d", resp.StatusCode)
138 }
139
140 return nil
141}
142
143// clientListSMTPAuth fetches the list of SMTP auth users from the daemon.
144func (c *Command) clientListSMTPAuth(ctx context.Context) ([]model.SMTPAuth, error) {
145 resp, err := c.httpClient.Get("http://unix/smtp-auth")
146 if err != nil {
147 return nil, fmt.Errorf("failed to list smtp auth: %w", err)
148 }
149 defer model.Wrap(resp.Body.Close, &err, "clientListSMTPAuth closing response body")
150
151 if resp.StatusCode != http.StatusOK {
152 return nil, fmt.Errorf("failed to list smtp auth: %d", resp.StatusCode)
153 }
154
155 var auths []model.SMTPAuth
156 if err := json.NewDecoder(resp.Body).Decode(&auths); err != nil {
157 return nil, fmt.Errorf("failed to decode smtp auth response: %w", err)
158 }
159
160 return auths, nil
161}
162
163// clientCreateSMTPAuth creates a new SMTP auth user via the daemon.
164func (c *Command) clientCreateSMTPAuth(_ context.Context, name string) (model.SMTPAuth, error) {
165 reqBody := map[string]string{"name": name}
166 body, err := json.Marshal(reqBody)
167 if err != nil {
168 return model.SMTPAuth{}, fmt.Errorf("failed to marshal smtp auth request: %w", err)
169 }
170
171 resp, err := c.httpClient.Post("http://unix/smtp-auth", "application/json", bytes.NewReader(body))
172 if err != nil {
173 return model.SMTPAuth{}, fmt.Errorf("failed to create smtp auth: %w", err)
174 }
175 defer model.Wrap(resp.Body.Close, &err, "clientCreateSMTPAuth closing response body")
176
177 if resp.StatusCode != http.StatusCreated {
178 return model.SMTPAuth{}, fmt.Errorf("failed to create smtp auth: %d", resp.StatusCode)
179 }
180
181 var auth model.SMTPAuth
182 if err := json.NewDecoder(resp.Body).Decode(&auth); err != nil {
183 return model.SMTPAuth{}, fmt.Errorf("failed to decode smtp auth response: %w", err)
184 }
185
186 return auth, nil
187}
188
189// clientRemoveSMTPAuth removes an SMTP auth user via the daemon.
190func (c *Command) clientRemoveSMTPAuth(ctx context.Context, name string) error {
191 path, err := url.JoinPath("http://unix/smtp-auth", name)
192 if err != nil {
193 return fmt.Errorf("failed to create remove smtp auth request path: %w", err)
194 }
195 req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil)
196 if err != nil {
197 return fmt.Errorf("failed to create delete smtp auth request: %w", err)
198 }
199
200 resp, err := c.httpClient.Do(req)
201 if err != nil {
202 return fmt.Errorf("failed to remove smtp auth: %w", err)
203 }
204 defer model.Wrap(resp.Body.Close, &err, "clientRemoveSMTPAuth closing response body")
205
206 if resp.StatusCode != http.StatusNoContent {
207 return fmt.Errorf("failed to remove smtp auth: %d", resp.StatusCode)
208 }
209
210 return nil
211}
212
213// clientListDKIM fetches the list of DKIM selectors from the daemon.
214func (c *Command) clientListDKIM(ctx context.Context) ([]model.DKIM, error) {
215 resp, err := c.httpClient.Get("http://unix/dkim")
216 if err != nil {
217 return nil, fmt.Errorf("failed to list dkim: %w", err)
218 }
219 defer model.Wrap(resp.Body.Close, &err, "clientListDKIM closing response body")
220
221 if resp.StatusCode != http.StatusOK {
222 return nil, fmt.Errorf("failed to list dkim: %d", resp.StatusCode)
223 }
224
225 var dkims []model.DKIM
226 if err := json.NewDecoder(resp.Body).Decode(&dkims); err != nil {
227 return nil, fmt.Errorf("failed to decode dkim response: %w", err)
228 }
229
230 return dkims, nil
231}
232
233// clientAddDKIM adds a new DKIM selector via the daemon.
234func (c *Command) clientAddDKIM(ctx context.Context, selector, domain string) (string, error) {
235 reqBody := map[string]string{"selector": selector, "domain": domain}
236 body, err := json.Marshal(reqBody)
237 if err != nil {
238 return "", fmt.Errorf("failed to marshal dkim request: %w", err)
239 }
240
241 resp, err := c.httpClient.Post("http://unix/dkim", "application/json", bytes.NewReader(body))
242 if err != nil {
243 return "", fmt.Errorf("failed to add dkim: %w", err)
244 }
245 defer model.Wrap(resp.Body.Close, &err, "clientAddDKIM closing response body")
246
247 if resp.StatusCode != http.StatusOK {
248 return "", fmt.Errorf("failed to add dkim: %d", resp.StatusCode)
249 }
250
251 var result map[string]string
252 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
253 return "", fmt.Errorf("failed to decode dkim response: %w", err)
254 }
255
256 return result["dns_record"], nil
257}
258
259// clientGetDKIM gets the public DNS record for a DKIM selector via the daemon.
260func (c *Command) clientGetDKIM(_ context.Context, selector, domain string) (string, error) {
261 path, err := url.JoinPath("http://unix/dkim", selector, domain)
262 if err != nil {
263 return "", fmt.Errorf("failed to create dkim request path: %w", err)
264 }
265 resp, err := c.httpClient.Get(path)
266 if err != nil {
267 return "", fmt.Errorf("failed to get dkim: %w", err)
268 }
269 defer model.Wrap(resp.Body.Close, &err, "clientGetDKIM closing response body")
270
271 if resp.StatusCode != http.StatusOK {
272 return "", fmt.Errorf("failed to get dkim: %d", resp.StatusCode)
273 }
274
275 var result map[string]string
276 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
277 return "", fmt.Errorf("failed to decode dkim response: %w", err)
278 }
279
280 return result["dns_record"], nil
281}
282
283// clientSetDKIMEnabled enables/disables a DKIM selector via the daemon.
284func (c *Command) clientSetDKIMEnabled(_ context.Context, selector, domain string, enable bool, force bool) error {
285 enableKeyword := "enable"
286 if !enable {
287 enableKeyword = "disable"
288 }
289
290 path, err := url.JoinPath("http://unix/dkim", selector, domain, enableKeyword)
291 if err != nil {
292 return fmt.Errorf("failed to create dkim request path: %w", err)
293 }
294
295 // Include force parameter in query string
296 if !force {
297 path += "?force=false"
298 }
299
300 resp, err := c.httpClient.Post(path, "application/json", nil)
301 if err != nil {
302 return fmt.Errorf("failed to set dkim enabled: %w", err)
303 }
304 defer model.Wrap(resp.Body.Close, &err, "clientSetDKIMEnabled closing response body")
305
306 if resp.StatusCode != http.StatusNoContent {
307 return fmt.Errorf("failed to set dkim enabled: %d", resp.StatusCode)
308 }
309
310 return nil
311}
312
313// clientRemoveDKIM removes a DKIM selector via the daemon.
314func (c *Command) clientRemoveDKIM(ctx context.Context, selector, domain string) error {
315 path, err := url.JoinPath("http://unix/dkim", selector, domain)
316 if err != nil {
317 return fmt.Errorf("failed to create dkim request path: %w", err)
318 }
319 req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil)
320 if err != nil {
321 return fmt.Errorf("failed to create delete dkim request: %w", err)
322 }
323
324 resp, err := c.httpClient.Do(req)
325 if err != nil {
326 return fmt.Errorf("failed to remove dkim: %w", err)
327 }
328 defer model.Wrap(resp.Body.Close, &err, "clientRemoveDKIM closing response body")
329
330 if resp.StatusCode != http.StatusNoContent {
331 return fmt.Errorf("failed to remove dkim: %d", resp.StatusCode)
332 }
333
334 return nil
335}
336
337// clientPruneMails prunes orphaned mails via the daemon.
338func (c *Command) clientPruneMails(_ context.Context) (int, error) {
339 resp, err := c.httpClient.Post("http://unix/prune", "application/json", nil)
340 if err != nil {
341 return 0, fmt.Errorf("failed to prune mails: %w", err)
342 }
343 defer model.Wrap(resp.Body.Close, &err, "clientPruneMails closing response body")
344
345 if resp.StatusCode != http.StatusOK {
346 return 0, fmt.Errorf("failed to prune mails: %d", resp.StatusCode)
347 }
348
349 var result map[string]int
350 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
351 return 0, fmt.Errorf("failed to decode prune response: %w", err)
352 }
353
354 return result["pruned"], nil
355}
356
357// clientKVList fetches all key-value pairs from the daemon.
358func (c *Command) clientKVList(_ context.Context) (map[string]any, error) {
359 resp, err := c.httpClient.Get("http://unix/kv")
360 if err != nil {
361 return nil, fmt.Errorf("failed to list kv: %w", err)
362 }
363 defer model.Wrap(resp.Body.Close, &err, "clientKVList closing response body")
364
365 if resp.StatusCode != http.StatusOK {
366 return nil, fmt.Errorf("failed to list kv: %d", resp.StatusCode)
367 }
368
369 var pairs map[string]any
370 if err := json.NewDecoder(resp.Body).Decode(&pairs); err != nil {
371 return nil, fmt.Errorf("failed to decode kv response: %w", err)
372 }
373
374 return pairs, nil
375}
376
377// clientKVGet fetches the value stored under key from the daemon.
378// The key is URL-encoded so keys containing slashes work.
379func (c *Command) clientKVGet(_ context.Context, key string) (any, error) {
380 path := "http://unix/kv/" + url.PathEscape(key)
381
382 resp, err := c.httpClient.Get(path)
383 if err != nil {
384 return nil, fmt.Errorf("failed to get kv: %w", err)
385 }
386 defer model.Wrap(resp.Body.Close, &err, "clientKVGet closing response body")
387
388 if resp.StatusCode != http.StatusOK {
389 return nil, fmt.Errorf("failed to get kv: %d", resp.StatusCode)
390 }
391
392 var result struct {
393 Value any `json:"value"`
394 }
395 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
396 return nil, fmt.Errorf("failed to decode kv response: %w", err)
397 }
398
399 return result.Value, nil
400}
401
402// clientKVPop removes the value stored under key via the daemon and returns it.
403// The key is URL-encoded so keys containing slashes work.
404func (c *Command) clientKVPop(ctx context.Context, key string) (any, error) {
405 path := "http://unix/kv/" + url.PathEscape(key)
406 req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil)
407 if err != nil {
408 return nil, fmt.Errorf("failed to create pop kv request: %w", err)
409 }
410
411 resp, err := c.httpClient.Do(req)
412 if err != nil {
413 return nil, fmt.Errorf("failed to pop kv: %w", err)
414 }
415 defer model.Wrap(resp.Body.Close, &err, "clientKVPop closing response body")
416
417 if resp.StatusCode != http.StatusOK {
418 return nil, fmt.Errorf("failed to pop kv: %d", resp.StatusCode)
419 }
420
421 var result struct {
422 Value any `json:"value"`
423 }
424 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
425 return nil, fmt.Errorf("failed to decode kv response: %w", err)
426 }
427
428 return result.Value, nil
429}