package release import ( "context" "crypto/ed25519" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" ) // Client — клиент артефактории: скачивает подписанный манифест и артефакты, // проверяет подпись зашитым публичным ключом и sha256 каждого файла. type Client struct { BaseURL string // напр. https://updates.example.com Channel string // "stable" | "beta" Pub ed25519.PublicKey // публичный ключ издателя (зашит в bj-server) HTTP *http.Client // Token — лицензионный токен установки (Authorization: Bearer). Сервер обновлений на // lic.zetit.ru отдаёт файлы только действующим лицензиям, модули — по признакам лицензии. Token string } func (c *Client) authorize(req *http.Request) { if c.Token != "" { req.Header.Set("Authorization", "Bearer "+c.Token) } } // NewClient собирает клиент. pubB64 — публичный ключ в base64 (зашитый). func NewClient(baseURL, channel, pubB64 string) (*Client, error) { pub, err := ParsePublicKey(pubB64) if err != nil { return nil, fmt.Errorf("release: публичный ключ: %w", err) } return &Client{ BaseURL: strings.TrimRight(baseURL, "/"), Channel: channel, Pub: pub, HTTP: &http.Client{Timeout: 60 * time.Second}, }, nil } // FetchManifest скачивает /v1//manifest.json и проверяет подпись. // Возвращает доверенный Manifest (или ошибку, если подпись не сошлась). func (c *Client) FetchManifest(ctx context.Context) (*Manifest, error) { _, m, err := c.FetchSigned(ctx) return m, err } // FetchSigned — подписанный манифест как есть (для повторной проверки root-установщиком) и // проверенный Manifest. func (c *Client) FetchSigned(ctx context.Context) (*SignedManifest, *Manifest, error) { url := fmt.Sprintf("%s/v1/%s/manifest.json", c.BaseURL, c.Channel) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, nil, err } c.authorize(req) resp, err := c.HTTP.Do(req) if err != nil { return nil, nil, fmt.Errorf("release: запрос манифеста: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return nil, nil, fmt.Errorf("release: манифест HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(msg))) } var sm SignedManifest if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&sm); err != nil { return nil, nil, fmt.Errorf("release: разбор манифеста: %w", err) } m, err := Verify(&sm, c.Pub) // здесь проверяется подпись return &sm, m, err } // DownloadArtifact скачивает артефакт в destDir, проверяет sha256/размер по // манифесту, выставляет +x при необходимости. Возвращает путь к файлу. // Скачивает во временный файл и переименовывает атомарно. func (c *Client) DownloadArtifact(ctx context.Context, a Artifact, destDir string) (string, error) { url := fmt.Sprintf("%s/v1/%s/files/%s", c.BaseURL, c.Channel, a.File) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return "", err } c.authorize(req) hc := c.HTTP if hc.Timeout > 0 && a.Size > 16<<20 { // большой файл (дистрибутив ИШ — 160 МБ): общий предел запроса — по размеру, из расчёта 256 КБ/с cp := *hc cp.Timeout = hc.Timeout + time.Duration(a.Size/(256<<10))*time.Second hc = &cp } resp, err := hc.Do(req) if err != nil { return "", fmt.Errorf("release: скачивание %s: %w", a.Name, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return "", fmt.Errorf("release: %s HTTP %d: %s", a.Name, resp.StatusCode, strings.TrimSpace(string(msg))) } tmp, err := os.CreateTemp(destDir, "."+a.File+".dl-*") if err != nil { return "", err } tmpName := tmp.Name() defer os.Remove(tmpName) // если что-то пойдёт не так — подчистим if _, err := io.Copy(tmp, resp.Body); err != nil { tmp.Close() return "", err } if err := tmp.Close(); err != nil { return "", err } // Проверка целостности по манифесту (подпись манифеста уже проверена). if err := VerifyArtifact(tmpName, a); err != nil { return "", err } mode := os.FileMode(0o644) if a.Exec { mode = 0o755 } if err := os.Chmod(tmpName, mode); err != nil { return "", err } final := filepath.Join(destDir, a.File) if err := os.Rename(tmpName, final); err != nil { // атомарно в пределах destDir return "", err } return final, nil }