diff --git a/.golangci.yml b/.golangci.yml index a3edd7c..ab61008 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -99,3 +99,7 @@ issues: linters: - funlen - gosec + - path: mailinabox_test.go + linters: + - unparam + text: "testHandler` - `statusCode` always receives `http.StatusOK` \\(`200`\\)" diff --git a/fixtures/system/getSystemBackupConfig.json b/fixtures/system/getSystemBackupConfig.json new file mode 100644 index 0000000..f01f0a1 --- /dev/null +++ b/fixtures/system/getSystemBackupConfig.json @@ -0,0 +1,9 @@ +{ + "enc_pw_file": "/home/user-data/backup/secret_key.txt", + "file_target_directory": "/home/user-data/backup/encrypted", + "min_age_in_days": 3, + "ssh_pub_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDb root@box.example.com\\n", + "target": "s3://s3.eu-central-1.amazonaws.com/box-example-com", + "target_user": "string", + "target_pass": "string" +} diff --git a/fixtures/system/getSystemBackupStatus.json b/fixtures/system/getSystemBackupStatus.json new file mode 100644 index 0000000..4d3cde5 --- /dev/null +++ b/fixtures/system/getSystemBackupStatus.json @@ -0,0 +1,15 @@ +{ + "backups": [ + { + "date": "20200801T023706Z", + "date_delta": "15 hours, 40 minutes", + "date_str": "2020-08-01 03:37:06 BST", + "deleted_in": "approx. 6 days", + "full": false, + "size": 125332, + "volumes": 1 + } + ], + "unmatched_file_size": 0, + "error": "Something is wrong with the backup" +} diff --git a/fixtures/system/getSystemPrivacyStatus.json b/fixtures/system/getSystemPrivacyStatus.json new file mode 100644 index 0000000..c508d53 --- /dev/null +++ b/fixtures/system/getSystemPrivacyStatus.json @@ -0,0 +1 @@ +false diff --git a/fixtures/system/getSystemRebootStatus.json b/fixtures/system/getSystemRebootStatus.json new file mode 100644 index 0000000..27ba77d --- /dev/null +++ b/fixtures/system/getSystemRebootStatus.json @@ -0,0 +1 @@ +true diff --git a/fixtures/system/getSystemStatus.json b/fixtures/system/getSystemStatus.json new file mode 100644 index 0000000..79d0ed2 --- /dev/null +++ b/fixtures/system/getSystemStatus.json @@ -0,0 +1,17 @@ +[ + { + "type": "heading", + "text": "System", + "extra": [] + }, + { + "type": "warning", + "text": "This domain's DNSSEC DS record is not set", + "extra": [ + { + "monospace": false, + "text": "Digest Type: 2 / SHA-25" + } + ] + } +] diff --git a/fixtures/system/getSystemUpdates.html b/fixtures/system/getSystemUpdates.html new file mode 100644 index 0000000..68bffde --- /dev/null +++ b/fixtures/system/getSystemUpdates.html @@ -0,0 +1,2 @@ +libgnutls30 (3.5.18-1ubuntu1.4) +libxau6 (1:1.0.8-1ubuntu1) diff --git a/fixtures/system/getSystemUpstreamVersion.html b/fixtures/system/getSystemUpstreamVersion.html new file mode 100644 index 0000000..a78cd68 --- /dev/null +++ b/fixtures/system/getSystemUpstreamVersion.html @@ -0,0 +1 @@ +v0.47 diff --git a/fixtures/system/getSystemVersion.html b/fixtures/system/getSystemVersion.html new file mode 100644 index 0000000..c53dc79 --- /dev/null +++ b/fixtures/system/getSystemVersion.html @@ -0,0 +1 @@ +v0.46 diff --git a/fixtures/system/rebootSystem.html b/fixtures/system/rebootSystem.html new file mode 100644 index 0000000..430d1a5 --- /dev/null +++ b/fixtures/system/rebootSystem.html @@ -0,0 +1 @@ +No reboot is required, so it is not allowed. diff --git a/fixtures/system/updateSystemBackupConfig.html b/fixtures/system/updateSystemBackupConfig.html new file mode 100644 index 0000000..d86bac9 --- /dev/null +++ b/fixtures/system/updateSystemBackupConfig.html @@ -0,0 +1 @@ +OK diff --git a/fixtures/system/updateSystemPackages.html b/fixtures/system/updateSystemPackages.html new file mode 100644 index 0000000..34a2f39 --- /dev/null +++ b/fixtures/system/updateSystemPackages.html @@ -0,0 +1,3 @@ +Calculating upgrade... +The following packages will be upgraded: + cloud-init grub-common diff --git a/fixtures/system/updateSystemPrivacy.html b/fixtures/system/updateSystemPrivacy.html new file mode 100644 index 0000000..d86bac9 --- /dev/null +++ b/fixtures/system/updateSystemPrivacy.html @@ -0,0 +1 @@ +OK diff --git a/mailinabox.go b/mailinabox.go index 4c5b255..3ab2ff4 100644 --- a/mailinabox.go +++ b/mailinabox.go @@ -24,9 +24,10 @@ type Client struct { common service // Reuse a single struct instead of allocating one for each service on the heap. - DNS *DNSService - User *UserService - Mail *MailService + DNS *DNSService + User *UserService + Mail *MailService + System *SystemService } // NewClient creates a new Client. @@ -48,6 +49,7 @@ func NewClient(apiURL, email, password string) (*Client, error) { client.DNS = (*DNSService)(&client.common) client.User = (*UserService)(&client.common) client.Mail = (*MailService)(&client.common) + client.System = (*SystemService)(&client.common) return client, nil } diff --git a/system.go b/system.go new file mode 100644 index 0000000..20864c9 --- /dev/null +++ b/system.go @@ -0,0 +1,299 @@ +package mailinabox + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" +) + +// SystemStatus Represents a system status. +type SystemStatus struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + Extra []ExtraStatus `json:"extra,omitempty"` +} + +// ExtraStatus Represents extra status. +type ExtraStatus struct { + Monospace bool `json:"monospace,omitempty"` + Text string `json:"text,omitempty"` +} + +// BackupStatus Represents a backup status. +type BackupStatus struct { + Backups []Backup `json:"backups,omitempty"` + UnmatchedFileSize int `json:"unmatched_file_size,omitempty"` + Error string `json:"error,omitempty"` +} + +// Backup Represents a backup. +type Backup struct { + Date string `json:"date,omitempty"` + DateDelta string `json:"date_delta,omitempty"` + DateStr string `json:"date_str,omitempty"` + DeletedIn string `json:"deleted_in,omitempty"` + Full bool `json:"full,omitempty"` + Size int `json:"size,omitempty"` + Volumes int `json:"volumes,omitempty"` +} + +// BackupConfig Represents a backup configuration. +type BackupConfig struct { + EncPwFile string `json:"enc_pw_file,omitempty"` + FileTargetDirectory string `json:"file_target_directory,omitempty"` + MinAgeInDays int `json:"min_age_in_days,omitempty"` + SSHPubKey string `json:"ssh_pub_key,omitempty"` + Target string `json:"target,omitempty"` + TargetUser string `json:"target_user,omitempty"` + TargetPass string `json:"target_pass,omitempty"` +} + +// SystemService System API. +// https://mailinabox.email/api-docs.html#tag/System +type SystemService service + +// GetStatus Returns an array of statuses which can include headings. +// https://mailinabox.email/api-docs.html#operation/getSystemStatus +func (s *SystemService) GetStatus(ctx context.Context) ([]SystemStatus, error) { + endpoint := s.client.baseURL.JoinPath("admin", "system", "status") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), http.NoBody) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + var results []SystemStatus + + err = s.client.doJSON(req, &results) + if err != nil { + return nil, err + } + + return results, nil +} + +// GetVersion Returns installed Mail-in-a-Box version. +// https://mailinabox.email/api-docs.html#operation/getSystemVersion +func (s *SystemService) GetVersion(ctx context.Context) (string, error) { + endpoint := s.client.baseURL.JoinPath("admin", "system", "version") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + return "", fmt.Errorf("unable to create request: %w", err) + } + + resp, err := s.client.doPlain(req) + if err != nil { + return "", err + } + + return strings.TrimSpace(string(resp)), nil +} + +// GetUpstreamVersion Returns Mail-in-a-Box upstream version. +// https://mailinabox.email/api-docs.html#operation/getSystemUpstreamVersion +func (s *SystemService) GetUpstreamVersion(ctx context.Context) (string, error) { + endpoint := s.client.baseURL.JoinPath("admin", "system", "latest-upstream-version") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), http.NoBody) + if err != nil { + return "", fmt.Errorf("unable to create request: %w", err) + } + + resp, err := s.client.doPlain(req) + if err != nil { + return "", err + } + + return strings.TrimSpace(string(resp)), nil +} + +// GetUpdates Returns system (apt) updates. +// https://mailinabox.email/api-docs.html#operation/getSystemUpdates +func (s *SystemService) GetUpdates(ctx context.Context) ([]string, error) { + endpoint := s.client.baseURL.JoinPath("admin", "system", "updates") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + resp, err := s.client.doPlain(req) + if err != nil { + return nil, err + } + + updates := strings.Split(strings.TrimSpace(string(resp)), "\n") + + return updates, nil +} + +// UpdatePackages Updates system (apt) packages. +// https://mailinabox.email/api-docs.html#operation/getSystemUpdates +func (s *SystemService) UpdatePackages(ctx context.Context) (string, error) { + endpoint := s.client.baseURL.JoinPath("admin", "system", "update-packages") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), http.NoBody) + if err != nil { + return "", fmt.Errorf("unable to create request: %w", err) + } + + resp, err := s.client.doPlain(req) + if err != nil { + return "", err + } + + return strings.TrimSpace(string(resp)), nil +} + +// GetPrivacyStatus Returns system privacy (new-version check) status. +// https://mailinabox.email/api-docs.html#operation/getSystemPrivacyStatus +func (s *SystemService) GetPrivacyStatus(ctx context.Context) (bool, error) { + endpoint := s.client.baseURL.JoinPath("admin", "system", "privacy") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + return false, fmt.Errorf("unable to create request: %w", err) + } + + var result bool + + err = s.client.doJSON(req, &result) + if err != nil { + return false, err + } + + return result, nil +} + +// UpdatePrivacyStatus Updates system privacy (new-version checks). +// - value: `private`: Disable new version checks +// - value: `off`: Enable new version checks +// https://mailinabox.email/api-docs.html#operation/updateSystemPrivacy +func (s *SystemService) UpdatePrivacyStatus(ctx context.Context, value string) (string, error) { + endpoint := s.client.baseURL.JoinPath("admin", "system", "privacy") + + data := url.Values{} + data.Set("value", value) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(data.Encode())) + if err != nil { + return "", fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := s.client.doPlain(req) + if err != nil { + return "", err + } + + return strings.TrimSpace(string(resp)), nil +} + +// GetRebootStatus Returns the system reboot status. +// https://mailinabox.email/api-docs.html#operation/getSystemRebootStatus +func (s *SystemService) GetRebootStatus(ctx context.Context) (bool, error) { + endpoint := s.client.baseURL.JoinPath("admin", "system", "reboot") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + return false, fmt.Errorf("unable to create request: %w", err) + } + + var result bool + + err = s.client.doJSON(req, &result) + if err != nil { + return false, err + } + + return result, nil +} + +// Reboot Reboots the system. +// https://mailinabox.email/api-docs.html#operation/rebootSystem +func (s *SystemService) Reboot(ctx context.Context) (string, error) { + endpoint := s.client.baseURL.JoinPath("admin", "system", "reboot") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), http.NoBody) + if err != nil { + return "", fmt.Errorf("unable to create request: %w", err) + } + + resp, err := s.client.doPlain(req) + if err != nil { + return "", err + } + + return strings.TrimSpace(string(resp)), nil +} + +// GetBackupStatus Returns the system backup status. +// https://mailinabox.email/api-docs.html#operation/getSystemBackupStatus +func (s *SystemService) GetBackupStatus(ctx context.Context) (*BackupStatus, error) { + endpoint := s.client.baseURL.JoinPath("admin", "system", "backup", "status") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + var result BackupStatus + + err = s.client.doJSON(req, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// GetBackupConfig Returns the system backup config. +// https://mailinabox.email/api-docs.html#operation/getSystemBackupConfig +func (s *SystemService) GetBackupConfig(ctx context.Context) (*BackupConfig, error) { + endpoint := s.client.baseURL.JoinPath("admin", "system", "backup", "config") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + var result BackupConfig + + err = s.client.doJSON(req, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// UpdateBackupConfig Updates the system backup config. +// https://mailinabox.email/api-docs.html#operation/updateSystemBackupConfig +func (s *SystemService) UpdateBackupConfig(ctx context.Context, target, targetUser, targetPass string, minAge int) (string, error) { + endpoint := s.client.baseURL.JoinPath("admin", "system", "backup", "config") + + data := url.Values{} + data.Set("target", target) + data.Set("targetUser", targetUser) + data.Set("targetPass", targetPass) + data.Set("minAge", strconv.Itoa(minAge)) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(data.Encode())) + if err != nil { + return "", fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := s.client.doPlain(req) + if err != nil { + return "", err + } + + return strings.TrimSpace(string(resp)), nil +} diff --git a/system_test.go b/system_test.go new file mode 100644 index 0000000..f5f2b8e --- /dev/null +++ b/system_test.go @@ -0,0 +1,191 @@ +package mailinabox + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSystemService_GetStatus(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/admin/system/status", testHandler("system/getSystemStatus.json", http.MethodPost, http.StatusOK)) + + statuses, err := client.System.GetStatus(context.Background()) + require.NoError(t, err) + + expected := []SystemStatus{ + { + Type: "heading", + Text: "System", + Extra: []ExtraStatus{}, + }, + { + Type: "warning", + Text: "This domain's DNSSEC DS record is not set", + Extra: []ExtraStatus{ + { + Monospace: false, + Text: "Digest Type: 2 / SHA-25", + }, + }, + }, + } + + assert.Equal(t, expected, statuses) +} + +func TestSystemService_GetVersion(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/admin/system/version", testHandler("system/getSystemVersion.html", http.MethodGet, http.StatusOK)) + + resp, err := client.System.GetVersion(context.Background()) + require.NoError(t, err) + + assert.Equal(t, "v0.46", resp) +} + +func TestSystemService_GetUpstreamVersion(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/admin/system/latest-upstream-version", testHandler("system/getSystemUpstreamVersion.html", http.MethodPost, http.StatusOK)) + + resp, err := client.System.GetUpstreamVersion(context.Background()) + require.NoError(t, err) + + assert.Equal(t, "v0.47", resp) +} + +func TestSystemService_GetUpdates(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/admin/system/updates", testHandler("system/getSystemUpdates.html", http.MethodGet, http.StatusOK)) + + updates, err := client.System.GetUpdates(context.Background()) + require.NoError(t, err) + + expected := []string{ + "libgnutls30 (3.5.18-1ubuntu1.4)", + "libxau6 (1:1.0.8-1ubuntu1)", + } + + assert.Equal(t, expected, updates) +} + +func TestSystemService_UpdatePackages(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/admin/system/update-packages", testHandler("system/updateSystemPackages.html", http.MethodPost, http.StatusOK)) + + resp, err := client.System.UpdatePackages(context.Background()) + require.NoError(t, err) + + assert.Equal(t, "Calculating upgrade...\nThe following packages will be upgraded:\n cloud-init grub-common", resp) +} + +func TestSystemService_GetPrivacyStatus(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/admin/system/privacy", testHandler("system/getSystemPrivacyStatus.json", http.MethodGet, http.StatusOK)) + + resp, err := client.System.GetPrivacyStatus(context.Background()) + require.NoError(t, err) + + assert.False(t, resp) +} + +func TestSystemService_UpdatePrivacyStatus(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/admin/system/privacy", testHandler("system/updateSystemPrivacy.html", http.MethodPost, http.StatusOK)) + + resp, err := client.System.UpdatePrivacyStatus(context.Background(), "private") + require.NoError(t, err) + + assert.Equal(t, "OK", resp) +} + +func TestSystemService_GetRebootStatus(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/admin/system/reboot", testHandler("system/getSystemRebootStatus.json", http.MethodGet, http.StatusOK)) + + resp, err := client.System.GetRebootStatus(context.Background()) + require.NoError(t, err) + + assert.True(t, resp) +} + +func TestSystemService_Reboot(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/admin/system/reboot", testHandler("system/rebootSystem.html", http.MethodPost, http.StatusOK)) + + resp, err := client.System.Reboot(context.Background()) + require.NoError(t, err) + + assert.Equal(t, "No reboot is required, so it is not allowed.", resp) +} + +func TestSystemService_GetBackupStatus(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/admin/system/backup/status", testHandler("system/getSystemBackupStatus.json", http.MethodGet, http.StatusOK)) + + status, err := client.System.GetBackupStatus(context.Background()) + require.NoError(t, err) + + expected := &BackupStatus{ + Backups: []Backup{ + { + Date: "20200801T023706Z", + DateDelta: "15 hours, 40 minutes", + DateStr: "2020-08-01 03:37:06 BST", + DeletedIn: "approx. 6 days", + Full: false, + Size: 125332, + Volumes: 1, + }, + }, + UnmatchedFileSize: 0, + Error: "Something is wrong with the backup", + } + + assert.Equal(t, expected, status) +} + +func TestSystemService_GetBackupConfig(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/admin/system/backup/config", testHandler("system/getSystemBackupConfig.json", http.MethodGet, http.StatusOK)) + + status, err := client.System.GetBackupConfig(context.Background()) + require.NoError(t, err) + + expected := &BackupConfig{ + EncPwFile: "/home/user-data/backup/secret_key.txt", + FileTargetDirectory: "/home/user-data/backup/encrypted", + MinAgeInDays: 3, + SSHPubKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDb root@box.example.com\\n", + Target: "s3://s3.eu-central-1.amazonaws.com/box-example-com", + TargetUser: "string", + TargetPass: "string", + } + + assert.Equal(t, expected, status) +} + +func TestSystemService_UpdateBackupConfig(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/admin/system/backup/config", testHandler("system/updateSystemBackupConfig.html", http.MethodPost, http.StatusOK)) + + resp, err := client.System.UpdateBackupConfig(context.Background(), "s3://s3.eu-central-1.amazonaws.com/box-example-com", "ACCESS_KEY", "SECRET_ACCESS_KEY", 3) + require.NoError(t, err) + + assert.Equal(t, "OK", resp) +}