// Vikunja is a to-do list application to facilitate your life. // Copyright 2018-present Vikunja and contributors. All rights reserved. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package license import ( "bytes" "context" "encoding/json" "fmt" "io" "math/rand/v2" "net/http" "os" "runtime" "time" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/version" ) var licenseServers = []string{ "https://console.vikunja.io/api/v1/check", "https://check.vikunja.io/api/v1/check", } const ( maxRetries = 3 requestTimeout = 10 * time.Second ) // CheckRequest is the payload sent to the license server. type CheckRequest struct { LicenseKey string `json:"license_key"` InstanceID string `json:"instance_id"` Version string `json:"version"` DatabaseType string `json:"database_type"` UserCounts UserCounts `json:"user_counts"` HostOS string `json:"host_os"` IsContainer bool `json:"is_container"` } // UserCounts holds user counts by status. type UserCounts struct { Active int64 `json:"active"` Disabled int64 `json:"disabled"` EmailConfirmationPending int64 `json:"email_confirmation_pending"` } // Response is the response from the license server. type Response struct { Valid bool `json:"valid"` Message string `json:"message,omitempty"` Features []Feature `json:"features"` MaxUsers int64 `json:"max_users"` ExpiresAt time.Time `json:"expires_at"` } func checkLicense(key string) (*Response, error) { log.Debugf("Starting license check...") payload, err := buildPayload(key) if err != nil { return nil, fmt.Errorf("building license check payload: %w", err) } log.Debugf("License check payload: instance_id=%s, version=%s, db_type=%s, users(active=%d, disabled=%d, pending=%d), os=%s, container=%t", payload.InstanceID, payload.Version, payload.DatabaseType, payload.UserCounts.Active, payload.UserCounts.Disabled, payload.UserCounts.EmailConfirmationPending, payload.HostOS, payload.IsContainer) body, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("marshaling license check payload: %w", err) } for _, server := range licenseServers { log.Debugf("Trying license server %s...", server) resp, err := tryServer(server, body) if err != nil { log.Warningf("License server %s unreachable: %s", server, err) continue } log.Debugf("License server %s responded: valid=%t, max_users=%d, expires_at=%s, features=%v", server, resp.Valid, resp.MaxUsers, resp.ExpiresAt.Format(time.RFC3339), resp.Features) return resp, nil } return nil, fmt.Errorf("all license servers unreachable") } func tryServer(serverURL string, body []byte) (*Response, error) { var lastErr error for attempt := range maxRetries { if attempt > 0 { baseDelay := time.Duration(1) * time.Second for range attempt { baseDelay *= 3 } // Add ±30% jitter jitter := 1.0 + (rand.Float64()*0.6 - 0.3) // #nosec G404 - jitter does not need cryptographic randomness delay := time.Duration(float64(baseDelay) * jitter) log.Debugf("License server %s: attempt %d failed, retrying in %s...", serverURL, attempt, delay) time.Sleep(delay) } resp, err := doRequest(serverURL, body) if err != nil { lastErr = err log.Debugf("License server %s: attempt %d/%d failed: %s", serverURL, attempt+1, maxRetries, err) continue } return resp, nil } return nil, lastErr } func doRequest(serverURL string, body []byte) (*Response, error) { ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodPost, serverURL, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") client := &http.Client{} resp, err := client.Do(req) //nolint:gosec // The URL is not user-controlled, it comes from hardcoded license server constants. if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 500)) log.Debugf("License server returned status %d, body: %s", resp.StatusCode, string(respBody)) return nil, fmt.Errorf("license server returned unexpected status code %d", resp.StatusCode) } var licenseResp Response if err := json.NewDecoder(resp.Body).Decode(&licenseResp); err != nil { return nil, fmt.Errorf("decoding response: %w", err) } return &licenseResp, nil } func buildPayload(key string) (*CheckRequest, error) { userCounts, err := getUserCounts() if err != nil { return nil, fmt.Errorf("getting user counts: %w", err) } return &CheckRequest{ LicenseKey: key, InstanceID: instanceID, Version: version.Version, DatabaseType: config.DatabaseType.GetString(), UserCounts: userCounts, HostOS: runtime.GOOS, IsContainer: detectContainer(), }, nil } func getUserCounts() (UserCounts, error) { s := db.NewSession() defer s.Close() var counts UserCounts active, err := s.Table("users").Where("status = ?", user.StatusActive).Count() if err != nil { return counts, err } counts.Active = active disabled, err := s.Table("users").Where("status = ?", user.StatusDisabled).Count() if err != nil { return counts, err } counts.Disabled = disabled pending, err := s.Table("users").Where("status = ?", user.StatusEmailConfirmationRequired).Count() if err != nil { return counts, err } counts.EmailConfirmationPending = pending return counts, nil } func detectContainer() bool { if _, err := os.Stat("/.dockerenv"); err == nil { return true } if _, err := os.Stat("/run/.containerenv"); err == nil { return true } return false } func parseResponse(raw string) (*Response, error) { var resp Response if err := json.Unmarshal([]byte(raw), &resp); err != nil { return nil, fmt.Errorf("parsing cached license response: %w", err) } return &resp, nil } func serializeResponse(resp *Response) (string, error) { data, err := json.Marshal(resp) if err != nil { return "", fmt.Errorf("serializing license response: %w", err) } return string(data), nil }