Files
syncwarden/internal/syncthing/client.go
Axel Meyer 59a98843f7
Some checks failed
CI / lint (push) Failing after 27s
CI / test (push) Successful in 30s
Release / build (push) Failing after 2m33s
v0.3.0: fix HTTP client leak, add tests and CI pipeline
Reuse a single long-poll HTTP client instead of creating one per
Events() call (~every 30s). Make TLS skip-verify configurable via
syncthing_insecure_tls. Log previously swallowed config errors.
Add unit tests for all monitor trackers, config, and state logic.
Add CI workflow (vet, golangci-lint, govulncheck, go test -race).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:36:52 +01:00

211 lines
5.2 KiB
Go

package syncthing
import (
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// Client talks to the Syncthing REST API.
type Client struct {
baseURL string
apiKey string
httpClient *http.Client
longPollClient *http.Client
}
// NewClient creates a Syncthing API client.
// When insecureTLS is true, TLS certificate verification is skipped.
// This is the common case for local Syncthing instances that use self-signed certs.
func NewClient(baseURL, apiKey string, insecureTLS bool) *Client {
//nolint:gosec // Syncthing typically uses self-signed certs; controlled by config
tlsCfg := &tls.Config{InsecureSkipVerify: insecureTLS}
return &Client{
baseURL: baseURL,
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{TLSClientConfig: tlsCfg},
},
longPollClient: &http.Client{
Timeout: 40 * time.Second,
Transport: &http.Transport{TLSClientConfig: tlsCfg},
},
}
}
// SetAPIKey updates the API key.
func (c *Client) SetAPIKey(key string) {
c.apiKey = key
}
// SetBaseURL updates the base URL.
func (c *Client) SetBaseURL(url string) {
c.baseURL = url
}
func (c *Client) get(path string, noAuth bool) ([]byte, error) {
req, err := http.NewRequest("GET", c.baseURL+path, nil)
if err != nil {
return nil, err
}
if !noAuth && c.apiKey != "" {
req.Header.Set("X-API-Key", c.apiKey)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
return io.ReadAll(resp.Body)
}
func (c *Client) post(path string) error {
req, err := http.NewRequest("POST", c.baseURL+path, nil)
if err != nil {
return err
}
if c.apiKey != "" {
req.Header.Set("X-API-Key", c.apiKey)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
return nil
}
// Health checks if Syncthing is reachable (no auth required).
func (c *Client) Health() (*HealthStatus, error) {
data, err := c.get("/rest/noauth/health", true)
if err != nil {
return nil, err
}
var s HealthStatus
return &s, json.Unmarshal(data, &s)
}
// SystemStatus returns system status info.
func (c *Client) SystemStatus() (*SystemStatus, error) {
data, err := c.get("/rest/system/status", false)
if err != nil {
return nil, err
}
var s SystemStatus
return &s, json.Unmarshal(data, &s)
}
// SystemVersion returns version info.
func (c *Client) SystemVersion() (*SystemVersion, error) {
data, err := c.get("/rest/system/version", false)
if err != nil {
return nil, err
}
var s SystemVersion
return &s, json.Unmarshal(data, &s)
}
// SystemConnections returns all device connections.
func (c *Client) SystemConnections() (*Connections, error) {
data, err := c.get("/rest/system/connections", false)
if err != nil {
return nil, err
}
var s Connections
return &s, json.Unmarshal(data, &s)
}
// Config returns the full Syncthing config (folders + devices).
func (c *Client) Config() (*FullConfig, error) {
data, err := c.get("/rest/config", false)
if err != nil {
return nil, err
}
var s FullConfig
return &s, json.Unmarshal(data, &s)
}
// FolderStatus returns the sync status for a folder.
func (c *Client) FolderStatus(folderID string) (*FolderStatus, error) {
data, err := c.get("/rest/db/status?folder="+folderID, false)
if err != nil {
return nil, err
}
var s FolderStatus
return &s, json.Unmarshal(data, &s)
}
// Events long-polls for new events since the given ID.
func (c *Client) Events(since int, timeout int) ([]Event, error) {
path := fmt.Sprintf("/rest/events?since=%d&timeout=%d", since, timeout)
req, err := http.NewRequest("GET", c.baseURL+path, nil)
if err != nil {
return nil, err
}
if c.apiKey != "" {
req.Header.Set("X-API-Key", c.apiKey)
}
resp, err := c.longPollClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var events []Event
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return events, json.Unmarshal(data, &events)
}
// PendingDevices returns new device requests.
func (c *Client) PendingDevices() (map[string]PendingDevice, error) {
data, err := c.get("/rest/cluster/pending/devices", false)
if err != nil {
return nil, err
}
var s map[string]PendingDevice
return s, json.Unmarshal(data, &s)
}
// PauseAll pauses all devices.
func (c *Client) PauseAll() error {
return c.post("/rest/system/pause")
}
// ResumeAll resumes all devices.
func (c *Client) ResumeAll() error {
return c.post("/rest/system/resume")
}
// RescanAll triggers a rescan of all folders.
func (c *Client) RescanAll() error {
return c.post("/rest/db/scan")
}
// Restart restarts Syncthing.
func (c *Client) Restart() error {
return c.post("/rest/system/restart")
}
// Shutdown stops Syncthing.
func (c *Client) Shutdown() error {
return c.post("/rest/system/shutdown")
}