1// Package remote provides an HTTP client for connecting to remote Gordon instances.
2package remote
3
4import (
5 "bytes"
6 "context"
7 "crypto/tls"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "io"
12 "net/http"
13 "net/url"
14 "os"
15 "strings"
16 "time"
17
18 "github.com/bnema/gordon/internal/adapters/dto"
19 "github.com/bnema/gordon/internal/domain"
20)
21
22// Client is an HTTP client for the Gordon admin API.
23type Client struct {
24 baseURL string
25 token string
26 httpClient *http.Client
27 insecureTLS bool
28}
29
30var (
31 retryMaxAttempts = 4
32 retryBaseDelay = 250 * time.Millisecond
33)
34
35// ClientOption configures the Client.
36type ClientOption func(*Client)
37
38// NewClient creates a new remote Gordon client.
39func NewClient(baseURL string, opts ...ClientOption) *Client {
40 // Normalize base URL
41 baseURL = strings.TrimSuffix(baseURL, "/")
42
43 c := &Client{
44 baseURL: baseURL,
45 httpClient: &http.Client{
46 Timeout: 2 * time.Minute,
47 },
48 }
49
50 for _, opt := range opts {
51 opt(c)
52 }
53
54 c.applyTLSConfig()
55
56 return c
57}
58
59// WithToken sets the authentication token.
60func WithToken(token string) ClientOption {
61 return func(c *Client) {
62 c.token = token
63 }
64}
65
66// WithHTTPClient sets a custom HTTP client.
67func WithHTTPClient(client *http.Client) ClientOption {
68 return func(c *Client) {
69 c.httpClient = client
70 }
71}
72
73// WithTimeout sets the HTTP client timeout.
74func WithTimeout(timeout time.Duration) ClientOption {
75 return func(c *Client) {
76 c.httpClient.Timeout = timeout
77 }
78}
79
80// WithInsecureTLS disables TLS certificate verification for remote admin API requests.
81func WithInsecureTLS(insecure bool) ClientOption {
82 return func(c *Client) {
83 c.insecureTLS = insecure
84 }
85}
86
87func (c *Client) applyTLSConfig() {
88 if !c.insecureTLS {
89 return
90 }
91
92 fmt.Fprintf(os.Stderr, "WARNING: TLS certificate verification disabled for %s\n", c.baseURL)
93
94 var transport *http.Transport
95 switch t := c.httpClient.Transport.(type) {
96 case *http.Transport:
97 transport = t.Clone()
98 case nil:
99 transport = http.DefaultTransport.(*http.Transport).Clone()
100 default:
101 // Cannot apply InsecureSkipVerify to a non-*http.Transport.
102 // Log a warning and leave the transport intact rather than
103 // silently replacing it (which would drop caller-provided behavior).
104 fmt.Fprintf(os.Stderr, "WARNING: --insecure requires *http.Transport, got %T; TLS override not applied\n", t)
105 return
106 }
107
108 if transport.TLSClientConfig == nil {
109 transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
110 } else {
111 transport.TLSClientConfig.MinVersion = tls.VersionTLS12
112 }
113 //nolint:gosec // Explicit CLI opt-in via --insecure for self-signed/private cert deployments.
114 transport.TLSClientConfig.InsecureSkipVerify = true
115 c.httpClient.Transport = transport
116}
117
118// request performs an HTTP request to the admin API.
119func (c *Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) {
120 url := c.baseURL + "/admin" + path
121
122 var bodyReader io.Reader
123 if body != nil {
124 jsonBody, err := json.Marshal(body)
125 if err != nil {
126 return nil, fmt.Errorf("failed to marshal request body: %w", err)
127 }
128 bodyReader = bytes.NewReader(jsonBody)
129 }
130
131 req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
132 if err != nil {
133 return nil, fmt.Errorf("failed to create request: %w", err)
134 }
135
136 req.Header.Set("Content-Type", "application/json")
137 req.Header.Set("Accept", "application/json")
138
139 if c.token != "" {
140 req.Header.Set("Authorization", "Bearer "+c.token)
141 }
142
143 return c.httpClient.Do(req)
144}
145
146// parseResponse parses a JSON response into the given target.
147func parseResponse(resp *http.Response, target any) error {
148 defer resp.Body.Close()
149
150 if resp.StatusCode >= 400 {
151 body, _ := io.ReadAll(resp.Body)
152 return parseErrorResponse(resp, body)
153 }
154
155 if target != nil {
156 if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
157 return fmt.Errorf("failed to decode response: %w", err)
158 }
159 }
160
161 return nil
162}
163
164func parseErrorResponse(resp *http.Response, body []byte) error {
165 var errResp struct {
166 Error string `json:"error"`
167 }
168 if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
169 return fmt.Errorf("%s: %s", resp.Status, errResp.Error)
170 }
171 return fmt.Errorf("%s: %s", resp.Status, string(body))
172}
173
174func isRetryableRequestError(err error) bool {
175 return !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded)
176}
177
178func isRetryableStatus(status int) bool {
179 // Only retry gateway errors; retrying 500 on non-idempotent POSTs
180 // (deploy, restart, reload) can cause duplicate state changes.
181 return status == 502 || status == 503 || status == 504
182}
183
184func retryDelay(attempt int) time.Duration {
185 if attempt <= 1 {
186 return retryBaseDelay
187 }
188 return retryBaseDelay * time.Duration(1<<(attempt-1))
189}
190
191// requestWithRetry performs an HTTP request and retries transient failures.
192// Retries occur on transport errors and gateway errors (502, 503, 504).
193func (c *Client) requestWithRetry(ctx context.Context, method, path string, body any) (*http.Response, error) {
194 var lastErr error
195
196 for attempt := 1; attempt <= retryMaxAttempts; attempt++ {
197 resp, err := c.request(ctx, method, path, body)
198 if err != nil {
199 lastErr = err
200 if attempt == retryMaxAttempts || !isRetryableRequestError(err) {
201 return nil, err
202 }
203 } else {
204 if !isRetryableStatus(resp.StatusCode) {
205 return resp, nil
206 }
207
208 respBody, _ := io.ReadAll(resp.Body)
209 _ = resp.Body.Close()
210 lastErr = parseErrorResponse(resp, respBody)
211 if attempt == retryMaxAttempts {
212 return nil, lastErr
213 }
214 }
215
216 delay := retryDelay(attempt)
217 timer := time.NewTimer(delay)
218 select {
219 case <-ctx.Done():
220 timer.Stop()
221 return nil, ctx.Err()
222 case <-timer.C:
223 }
224 }
225
226 if lastErr != nil {
227 return nil, lastErr
228 }
229 return nil, fmt.Errorf("request failed after retries")
230}
231
232// Routes API
233
234// Type aliases for API types using shared DTO package.
235type RouteInfo = dto.RouteInfo
236type Attachment = dto.Attachment
237
238// ListRoutes returns all configured routes.
239func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
240 resp, err := c.request(ctx, http.MethodGet, "/routes", nil)
241 if err != nil {
242 return nil, err
243 }
244
245 var result struct {
246 Routes []domain.Route `json:"routes"`
247 }
248 if err := parseResponse(resp, &result); err != nil {
249 return nil, err
250 }
251
252 return result.Routes, nil
253}
254
255// ListRoutesWithDetails returns routes with network and attachment info.
256func (c *Client) ListRoutesWithDetails(ctx context.Context) ([]RouteInfo, error) {
257 resp, err := c.request(ctx, http.MethodGet, "/routes?detailed=true", nil)
258 if err != nil {
259 return nil, err
260 }
261
262 var result struct {
263 Routes []RouteInfo `json:"routes"`
264 }
265 if err := parseResponse(resp, &result); err != nil {
266 return nil, err
267 }
268
269 return result.Routes, nil
270}
271
272// ListNetworks returns Gordon-managed networks.
273func (c *Client) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
274 resp, err := c.request(ctx, http.MethodGet, "/networks", nil)
275 if err != nil {
276 return nil, err
277 }
278
279 var result struct {
280 Networks []*domain.NetworkInfo `json:"networks"`
281 }
282 if err := parseResponse(resp, &result); err != nil {
283 return nil, err
284 }
285
286 return result.Networks, nil
287}
288
289// ListAttachments returns attachments for a domain.
290func (c *Client) ListAttachments(ctx context.Context, routeDomain string) ([]Attachment, error) {
291 if routeDomain == "" {
292 return nil, fmt.Errorf("domain is required")
293 }
294 path := "/routes/" + url.PathEscape(routeDomain) + "/attachments"
295 resp, err := c.request(ctx, http.MethodGet, path, nil)
296 if err != nil {
297 return nil, err
298 }
299
300 var result struct {
301 Attachments []Attachment `json:"attachments"`
302 }
303 if err := parseResponse(resp, &result); err != nil {
304 return nil, err
305 }
306
307 return result.Attachments, nil
308}
309
310// GetRoute returns a specific route by domain.
311func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
312 resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain), nil)
313 if err != nil {
314 return nil, err
315 }
316
317 var route domain.Route
318 if err := parseResponse(resp, &route); err != nil {
319 return nil, err
320 }
321
322 return &route, nil
323}
324
325// FindRoutesByImage returns all routes associated with the given image name.
326func (c *Client) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
327 resp, err := c.request(ctx, http.MethodGet, "/routes/by-image/"+url.PathEscape(imageName), nil)
328 if err != nil {
329 return nil, err
330 }
331
332 var result struct {
333 Image string `json:"image"`
334 Routes []domain.Route `json:"routes"`
335 }
336 if err := parseResponse(resp, &result); err != nil {
337 return nil, err
338 }
339
340 return result.Routes, nil
341}
342
343// AddRoute adds a new route.
344func (c *Client) AddRoute(ctx context.Context, route domain.Route) error {
345 resp, err := c.request(ctx, http.MethodPost, "/routes", route)
346 if err != nil {
347 return err
348 }
349 return parseResponse(resp, nil)
350}
351
352// UpdateRoute updates an existing route.
353func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error {
354 resp, err := c.request(ctx, http.MethodPut, "/routes/"+url.PathEscape(route.Domain), route)
355 if err != nil {
356 return err
357 }
358 return parseResponse(resp, nil)
359}
360
361// RemoveRoute removes a route by domain.
362func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
363 resp, err := c.request(ctx, http.MethodDelete, "/routes/"+url.PathEscape(routeDomain), nil)
364 if err != nil {
365 return err
366 }
367 return parseResponse(resp, nil)
368}
369
370// Secrets API
371
372// AttachmentSecrets represents secrets for an attachment container.
373type AttachmentSecrets struct {
374 Service string `json:"service"`
375 Keys []string `json:"keys"`
376}
377
378// SecretsListResult contains domain secrets and any attachment secrets.
379type SecretsListResult struct {
380 Domain string `json:"domain"`
381 Keys []string `json:"keys"`
382 Attachments []AttachmentSecrets `json:"attachments,omitempty"`
383}
384
385// ListSecrets returns the list of secret keys for a domain.
386func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) {
387 result, err := c.ListSecretsWithAttachments(ctx, secretDomain)
388 if err != nil {
389 return nil, err
390 }
391 return result.Keys, nil
392}
393
394// ListSecretsWithAttachments returns domain secrets and attachment secrets.
395func (c *Client) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*SecretsListResult, error) {
396 resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil)
397 if err != nil {
398 return nil, err
399 }
400
401 var result SecretsListResult
402 if err := parseResponse(resp, &result); err != nil {
403 return nil, err
404 }
405
406 return &result, nil
407}
408
409// SetSecrets sets secrets for a domain.
410func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
411 resp, err := c.request(ctx, http.MethodPost, "/secrets/"+url.PathEscape(secretDomain), secrets)
412 if err != nil {
413 return err
414 }
415 return parseResponse(resp, nil)
416}
417
418// DeleteSecret removes a secret from a domain.
419func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) error {
420 resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+url.PathEscape(secretDomain)+"/"+url.PathEscape(key), nil)
421 if err != nil {
422 return err
423 }
424 return parseResponse(resp, nil)
425}
426
427// SetAttachmentSecrets sets secrets for an attachment container.
428func (c *Client) SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error {
429 path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service)
430 resp, err := c.request(ctx, http.MethodPost, path, secrets)
431 if err != nil {
432 return err
433 }
434 return parseResponse(resp, nil)
435}
436
437// DeleteAttachmentSecret removes a secret from an attachment container.
438func (c *Client) DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error {
439 path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) + "/" + url.PathEscape(key)
440 resp, err := c.request(ctx, http.MethodDelete, path, nil)
441 if err != nil {
442 return err
443 }
444 return parseResponse(resp, nil)
445}
446
447// Status API
448
449// Status represents the Gordon server status.
450type Status struct {
451 Routes int `json:"routes"`
452 RegistryDomain string `json:"registry_domain"`
453 RegistryPort int `json:"registry_port"`
454 ServerPort int `json:"server_port"`
455 AutoRoute bool `json:"auto_route"`
456 NetworkIsolation bool `json:"network_isolation"`
457 ContainerStatus map[string]string `json:"container_status"`
458}
459
460// GetStatus returns the Gordon server status.
461func (c *Client) GetStatus(ctx context.Context) (*Status, error) {
462 resp, err := c.request(ctx, http.MethodGet, "/status", nil)
463 if err != nil {
464 return nil, err
465 }
466
467 var status Status
468 if err := parseResponse(resp, &status); err != nil {
469 return nil, err
470 }
471
472 return &status, nil
473}
474
475// RouteHealth represents the health status of a route.
476type RouteHealth struct {
477 ContainerStatus string `json:"container_status"`
478 HTTPStatus int `json:"http_status"`
479 ResponseTimeMs int64 `json:"response_time_ms"`
480 Healthy bool `json:"healthy"`
481 Error string `json:"error"`
482}
483
484// Backups API
485
486// Images API
487
488// ListImages returns runtime images and registry tags from the admin API.
489func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) {
490 resp, err := c.request(ctx, http.MethodGet, "/images", nil)
491 if err != nil {
492 return nil, err
493 }
494
495 var result dto.ImagesResponse
496 if err := parseResponse(resp, &result); err != nil {
497 return nil, err
498 }
499
500 return result.Images, nil
501}
502
503// PruneImages prunes runtime and registry images.
504func (c *Client) PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error) {
505 if keepLast < 0 {
506 return nil, fmt.Errorf("keepLast must be >= 0")
507 }
508
509 resp, err := c.request(ctx, http.MethodPost, "/images/prune", dto.ImagePruneRequest{KeepLast: &keepLast})
510 if err != nil {
511 return nil, err
512 }
513
514 var result dto.ImagePruneResponse
515 if err := parseResponse(resp, &result); err != nil {
516 return nil, err
517 }
518
519 return &result, nil
520}
521
522// ListBackups returns backups globally or for a domain.
523func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
524 path := "/backups"
525 if backupDomain != "" {
526 path += "/" + url.PathEscape(backupDomain)
527 }
528
529 resp, err := c.request(ctx, http.MethodGet, path, nil)
530 if err != nil {
531 return nil, err
532 }
533
534 var result dto.BackupsResponse
535 if err := parseResponse(resp, &result); err != nil {
536 return nil, err
537 }
538
539 return result.Backups, nil
540}
541
542// BackupStatus returns aggregate backup status.
543func (c *Client) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
544 resp, err := c.request(ctx, http.MethodGet, "/backups/status", nil)
545 if err != nil {
546 return nil, err
547 }
548
549 var result dto.BackupsResponse
550 if err := parseResponse(resp, &result); err != nil {
551 return nil, err
552 }
553
554 return result.Backups, nil
555}
556
557// RunBackup triggers a backup for a domain.
558func (c *Client) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) {
559 if backupDomain == "" {
560 return nil, fmt.Errorf("domain cannot be empty")
561 }
562
563 resp, err := c.request(ctx, http.MethodPost, "/backups/"+url.PathEscape(backupDomain), dto.BackupRunRequest{DB: dbName})
564 if err != nil {
565 return nil, err
566 }
567
568 var result dto.BackupRunResponse
569 if err := parseResponse(resp, &result); err != nil {
570 return nil, err
571 }
572
573 return &result, nil
574}
575
576// DetectDatabases detects supported databases for a domain.
577func (c *Client) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) {
578 if backupDomain == "" {
579 return nil, fmt.Errorf("domain cannot be empty")
580 }
581
582 resp, err := c.request(ctx, http.MethodGet, "/backups/"+url.PathEscape(backupDomain)+"/detect", nil)
583 if err != nil {
584 return nil, err
585 }
586
587 var result dto.BackupDetectResponse
588 if err := parseResponse(resp, &result); err != nil {
589 return nil, err
590 }
591
592 return result.Databases, nil
593}
594
595// GetHealth returns health status for all routes with HTTP probing.
596func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) {
597 resp, err := c.request(ctx, http.MethodGet, "/health", nil)
598 if err != nil {
599 return nil, err
600 }
601
602 var result struct {
603 Health map[string]*RouteHealth `json:"health"`
604 }
605 if err := parseResponse(resp, &result); err != nil {
606 return nil, err
607 }
608
609 return result.Health, nil
610}
611
612// Reload triggers a configuration reload.
613func (c *Client) Reload(ctx context.Context) error {
614 resp, err := c.requestWithRetry(ctx, http.MethodPost, "/reload", nil)
615 if err != nil {
616 return err
617 }
618 return parseResponse(resp, nil)
619}
620
621// Config API
622
623// Config represents the Gordon configuration.
624type Config struct {
625 Server struct {
626 Port int `json:"port"`
627 RegistryPort int `json:"registry_port"`
628 RegistryDomain string `json:"registry_domain"`
629 DataDir string `json:"data_dir"`
630 } `json:"server"`
631 AutoRoute struct {
632 Enabled bool `json:"enabled"`
633 } `json:"auto_route"`
634 NetworkIsolation struct {
635 Enabled bool `json:"enabled"`
636 Prefix string `json:"prefix"`
637 } `json:"network_isolation"`
638 Routes []domain.Route `json:"routes"`
639 ExternalRoutes map[string]string `json:"external_routes"`
640}
641
642// GetConfig returns the Gordon configuration.
643func (c *Client) GetConfig(ctx context.Context) (*Config, error) {
644 resp, err := c.request(ctx, http.MethodGet, "/config", nil)
645 if err != nil {
646 return nil, err
647 }
648
649 var config Config
650 if err := parseResponse(resp, &config); err != nil {
651 return nil, err
652 }
653
654 return &config, nil
655}
656
657// Ping checks if the remote Gordon instance is reachable.
658func (c *Client) Ping(ctx context.Context) error {
659 _, err := c.GetStatus(ctx)
660 return err
661}
662
663// Deploy API
664
665// DeployResult contains the result of a deployment.
666type DeployResult struct {
667 Status string `json:"status"`
668 ContainerID string `json:"container_id"`
669 Domain string `json:"domain"`
670}
671
672// Deploy triggers a deployment for the specified domain.
673func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult, error) {
674 resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy/"+url.PathEscape(deployDomain), nil)
675 if err != nil {
676 return nil, err
677 }
678
679 var result DeployResult
680 if err := parseResponse(resp, &result); err != nil {
681 return nil, err
682 }
683
684 return &result, nil
685}
686
687// Restart API
688
689// RestartResult contains the result of a restart.
690type RestartResult struct {
691 Status string `json:"status"`
692 Domain string `json:"domain"`
693}
694
695// Restart triggers a container restart for the specified domain.
696func (c *Client) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*RestartResult, error) {
697 if restartDomain == "" {
698 return nil, fmt.Errorf("domain cannot be empty")
699 }
700 path := "/restart/" + url.PathEscape(restartDomain)
701 if withAttachments {
702 path += "?attachments=true"
703 }
704 resp, err := c.requestWithRetry(ctx, http.MethodPost, path, nil)
705 if err != nil {
706 return nil, err
707 }
708
709 var result RestartResult
710 if err := parseResponse(resp, &result); err != nil {
711 return nil, err
712 }
713
714 return &result, nil
715}
716
717// Tags API
718
719// ListTags returns available tags for a repository.
720func (c *Client) ListTags(ctx context.Context, repository string) ([]string, error) {
721 if repository == "" {
722 return nil, fmt.Errorf("repository cannot be empty")
723 }
724 resp, err := c.request(ctx, http.MethodGet, "/tags/"+url.PathEscape(repository), nil)
725 if err != nil {
726 return nil, err
727 }
728 var result struct {
729 Repository string `json:"repository"`
730 Tags []string `json:"tags"`
731 }
732 if err := parseResponse(resp, &result); err != nil {
733 return nil, err
734 }
735 return result.Tags, nil
736}
737
738// Logs API
739
740// GetProcessLogs returns Gordon process logs.
741func (c *Client) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
742 path := fmt.Sprintf("/logs?lines=%d", lines)
743 resp, err := c.request(ctx, http.MethodGet, path, nil)
744 if err != nil {
745 return nil, err
746 }
747
748 var result struct {
749 Lines []string `json:"lines"`
750 }
751 if err := parseResponse(resp, &result); err != nil {
752 return nil, err
753 }
754
755 return result.Lines, nil
756}
757
758// GetContainerLogs returns container logs for a specific domain.
759func (c *Client) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
760 path := fmt.Sprintf("/logs/%s?lines=%d", url.PathEscape(logDomain), lines)
761 resp, err := c.request(ctx, http.MethodGet, path, nil)
762 if err != nil {
763 return nil, err
764 }
765
766 var result struct {
767 Domain string `json:"domain"`
768 Lines []string `json:"lines"`
769 }
770 if err := parseResponse(resp, &result); err != nil {
771 return nil, err
772 }
773
774 return result.Lines, nil
775}
776
777// StreamProcessLogs returns a channel that streams Gordon process log lines via SSE.
778// The caller is responsible for reading from the channel until it's closed.
779func (c *Client) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
780 path := fmt.Sprintf("/logs?lines=%d&follow=true", lines)
781 return c.streamLogs(ctx, path)
782}
783
784// StreamContainerLogs returns a channel that streams container log lines via SSE.
785// The caller is responsible for reading from the channel until it's closed.
786func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
787 path := fmt.Sprintf("/logs/%s?lines=%d&follow=true", url.PathEscape(logDomain), lines)
788 return c.streamLogs(ctx, path)
789}
790
791// Attachments Config API
792
793// GetAllAttachmentsConfig returns all configured attachments.
794func (c *Client) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
795 resp, err := c.request(ctx, http.MethodGet, "/attachments", nil)
796 if err != nil {
797 return nil, err
798 }
799
800 var result struct {
801 Attachments map[string][]string `json:"attachments"`
802 }
803 if err := parseResponse(resp, &result); err != nil {
804 return nil, err
805 }
806
807 return result.Attachments, nil
808}
809
810// GetAttachmentsConfig returns attachments for a specific domain/group from config.
811func (c *Client) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
812 if domainOrGroup == "" {
813 return nil, fmt.Errorf("domain or group is required")
814 }
815 path := "/attachments/" + url.PathEscape(domainOrGroup)
816 resp, err := c.request(ctx, http.MethodGet, path, nil)
817 if err != nil {
818 return nil, err
819 }
820
821 var result struct {
822 Target string `json:"target"`
823 Images []string `json:"images"`
824 }
825 if err := parseResponse(resp, &result); err != nil {
826 return nil, err
827 }
828
829 return result.Images, nil
830}
831
832// AddAttachment adds an attachment to a domain/group.
833func (c *Client) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
834 if domainOrGroup == "" {
835 return fmt.Errorf("domain or group is required")
836 }
837 if image == "" {
838 return fmt.Errorf("image is required")
839 }
840 path := "/attachments/" + url.PathEscape(domainOrGroup)
841 resp, err := c.request(ctx, http.MethodPost, path, struct {
842 Image string `json:"image"`
843 }{Image: image})
844 if err != nil {
845 return err
846 }
847 return parseResponse(resp, nil)
848}
849
850// RemoveAttachment removes an attachment from a domain/group.
851func (c *Client) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
852 if domainOrGroup == "" {
853 return fmt.Errorf("domain or group is required")
854 }
855 if image == "" {
856 return fmt.Errorf("image is required")
857 }
858 path := "/attachments/" + url.PathEscape(domainOrGroup) + "/" + url.PathEscape(image)
859 resp, err := c.request(ctx, http.MethodDelete, path, nil)
860 if err != nil {
861 return err
862 }
863 return parseResponse(resp, nil)
864}
865
866// streamLogs handles SSE streaming for log endpoints.
867func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {
868 url := c.baseURL + "/admin" + path
869
870 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
871 if err != nil {
872 return nil, fmt.Errorf("failed to create request: %w", err)
873 }
874
875 req.Header.Set("Accept", "text/event-stream")
876 if c.token != "" {
877 req.Header.Set("Authorization", "Bearer "+c.token)
878 }
879
880 // Use the same transport as the main client (honoring TLS config and
881 // custom transports) but without a timeout so streaming doesn't get cut off.
882 streamClient := &http.Client{
883 Transport: c.httpClient.Transport,
884 }
885 resp, err := streamClient.Do(req)
886 if err != nil {
887 return nil, fmt.Errorf("failed to connect: %w", err)
888 }
889
890 if resp.StatusCode >= 400 {
891 body, _ := io.ReadAll(resp.Body)
892 resp.Body.Close()
893 return nil, fmt.Errorf("%s: %s", resp.Status, string(body))
894 }
895
896 ch := make(chan string, 100)
897
898 go func() {
899 defer close(ch)
900 defer resp.Body.Close()
901
902 buf := make([]byte, 4096)
903 var lineBuffer strings.Builder
904
905 for {
906 select {
907 case <-ctx.Done():
908 return
909 default:
910 }
911
912 n, err := resp.Body.Read(buf)
913 if n > 0 {
914 lineBuffer.Write(buf[:n])
915 }
916 if err != nil {
917 return // EOF or context cancellation — clean exit
918 }
919
920 // Process complete SSE events
921 for {
922 data := lineBuffer.String()
923 idx := strings.Index(data, "\n\n")
924 if idx == -1 {
925 break
926 }
927
928 event := data[:idx]
929 lineBuffer.Reset()
930 lineBuffer.WriteString(data[idx+2:])
931
932 // Parse SSE data lines
933 for _, line := range strings.Split(event, "\n") {
934 if strings.HasPrefix(line, "data: ") {
935 logLine := strings.TrimPrefix(line, "data: ")
936 select {
937 case ch <- logLine:
938 case <-ctx.Done():
939 return
940 }
941 }
942 }
943 }
944 }
945 }()
946
947 return ch, nil
948}
949
950// VerifyAuth checks if authentication session is valid.
951func (c *Client) VerifyAuth(ctx context.Context) (*dto.AuthVerifyResponse, error) {
952 resp, err := c.request(ctx, http.MethodGet, "/auth/verify", nil)
953 if err != nil {
954 return nil, err
955 }
956
957 var result dto.AuthVerifyResponse
958 if err := parseResponse(resp, &result); err != nil {
959 return nil, err
960 }
961
962 return &result, nil
963}