94 lines
2.9 KiB
Go
94 lines
2.9 KiB
Go
package devicefleet
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/base64"
|
||
|
|
"errors"
|
||
|
|
"io"
|
||
|
|
"net/url"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/jackc/pgx/v5"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
downloadTokenBytes = 16
|
||
|
|
downloadTicketTTL = 30 * time.Minute
|
||
|
|
)
|
||
|
|
|
||
|
|
var ErrDownloadTicketUnavailable = errors.New("firmware download ticket is unavailable")
|
||
|
|
|
||
|
|
type DownloadGrant struct {
|
||
|
|
ObjectRef string
|
||
|
|
Size int64
|
||
|
|
SHA256 string
|
||
|
|
CreatedAt time.Time
|
||
|
|
ExpiresAt time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
func newDownloadTicket(baseURL string) (string, []byte, string, time.Time, error) {
|
||
|
|
raw := make([]byte, downloadTokenBytes)
|
||
|
|
if _, err := rand.Read(raw); err != nil {
|
||
|
|
return "", nil, "", time.Time{}, err
|
||
|
|
}
|
||
|
|
token := base64.RawURLEncoding.EncodeToString(raw)
|
||
|
|
digest := sha256.Sum256([]byte(token))
|
||
|
|
address, err := shortDownloadURL(baseURL, token)
|
||
|
|
if err != nil {
|
||
|
|
return "", nil, "", time.Time{}, err
|
||
|
|
}
|
||
|
|
return token, digest[:], address, time.Now().UTC().Add(downloadTicketTTL), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func shortDownloadURL(baseURL, token string) (string, error) {
|
||
|
|
parsed, err := url.Parse(strings.TrimSpace(baseURL))
|
||
|
|
if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||
|
|
return "", errors.New("firmware download base URL must be an HTTP(S) origin")
|
||
|
|
}
|
||
|
|
parsed.Path = "/f/" + token
|
||
|
|
return parsed.String(), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func downloadTokenHash(token string) ([]byte, error) {
|
||
|
|
raw, err := base64.RawURLEncoding.DecodeString(token)
|
||
|
|
if err != nil || len(raw) != downloadTokenBytes || base64.RawURLEncoding.EncodeToString(raw) != token {
|
||
|
|
return nil, ErrDownloadTicketUnavailable
|
||
|
|
}
|
||
|
|
digest := sha256.Sum256([]byte(token))
|
||
|
|
return digest[:], nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) ResolveDownload(ctx context.Context, token string) (DownloadGrant, error) {
|
||
|
|
var grant DownloadGrant
|
||
|
|
digest, err := downloadTokenHash(strings.TrimSpace(token))
|
||
|
|
if err != nil {
|
||
|
|
return grant, err
|
||
|
|
}
|
||
|
|
err = s.Pool.QueryRow(ctx, `
|
||
|
|
SELECT f.object_ref,f.size,f.sha256,f.created_at,t.expires_at
|
||
|
|
FROM fleet_firmware_download_ticket t
|
||
|
|
JOIN fleet_firmware f ON f.id=t.release_id
|
||
|
|
LEFT JOIN fleet_ota o ON o.id=t.ota_id
|
||
|
|
WHERE t.token_hash=$1
|
||
|
|
AND t.revoked_at IS NULL
|
||
|
|
AND t.expires_at>now()
|
||
|
|
AND (t.ota_id IS NULL OR
|
||
|
|
(o.status='pending' AND o.created_at>now()-interval '30 seconds') OR
|
||
|
|
(o.status='accepted' AND o.created_at>now()-interval '20 minutes'))
|
||
|
|
AND f.status='READY'`, digest).Scan(&grant.ObjectRef, &grant.Size, &grant.SHA256, &grant.CreatedAt, &grant.ExpiresAt)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return DownloadGrant{}, ErrDownloadTicketUnavailable
|
||
|
|
}
|
||
|
|
return grant, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) OpenDownload(ctx context.Context, grant DownloadGrant) (io.ReadSeekCloser, error) {
|
||
|
|
if s.Store == nil || grant.ObjectRef == "" {
|
||
|
|
return nil, ErrDownloadTicketUnavailable
|
||
|
|
}
|
||
|
|
return s.Store.OpenDownload(ctx, grant.ObjectRef)
|
||
|
|
}
|