feat: signed releases, release feed, notification (read-only)
This commit is contained in:
1 parent
a49eec909c
commit
834493a4f5
49 files changed
+4191
-18
No files matched your search
@@ -0,0 +1,105 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
const StateIdle = "idle"
|
||||
|
||||
var (
|
||||
ErrNoRelease = errors.New("no release found")
|
||||
ErrInvalidVersion = errors.New("invalid version")
|
||||
ErrFeedUnavailable = errors.New("release feed unavailable")
|
||||
)
|
||||
|
||||
type ReleaseNotes struct {
|
||||
Version string `json:"version"`
|
||||
ReleasedAt time.Time `json:"released_at"`
|
||||
Items []string `json:"items"`
|
||||
}
|
||||
|
||||
type Release struct {
|
||||
Tag string
|
||||
URL string
|
||||
PublishedAt time.Time
|
||||
Notes ReleaseNotes
|
||||
}
|
||||
|
||||
type CheckResult struct {
|
||||
Latest string `json:"latest"`
|
||||
ReleaseURL string `json:"release_url"`
|
||||
Notes ReleaseNotes `json:"notes"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
}
|
||||
|
||||
type Progress struct {
|
||||
Percent int `json:"percent"`
|
||||
DownloadedBytes int64 `json:"downloaded_bytes"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
}
|
||||
|
||||
type UpdateStatus struct {
|
||||
Current string `json:"current"`
|
||||
Latest string `json:"latest"`
|
||||
Available bool `json:"available"`
|
||||
ReleaseURL *string `json:"release_url"`
|
||||
Notes *ReleaseNotes `json:"notes"`
|
||||
CheckedAt *time.Time `json:"checked_at"`
|
||||
CanApply bool `json:"can_apply"`
|
||||
State string `json:"state"`
|
||||
Progress *Progress `json:"progress"`
|
||||
Error *string `json:"error"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
func NewResult(release Release, checkedAt time.Time) CheckResult {
|
||||
return CheckResult{
|
||||
Latest: release.Tag,
|
||||
ReleaseURL: release.URL,
|
||||
Notes: release.Notes,
|
||||
CheckedAt: checkedAt.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func NewStatus(current string, result *CheckResult) UpdateStatus {
|
||||
status := UpdateStatus{Current: current, State: StateIdle}
|
||||
if result == nil {
|
||||
return status
|
||||
}
|
||||
releaseURL := result.ReleaseURL
|
||||
notes := result.Notes
|
||||
notes.Items = nonNilItems(notes.Items)
|
||||
checkedAt := result.CheckedAt
|
||||
status.Latest = result.Latest
|
||||
status.Available = IsNewer(current, result.Latest)
|
||||
status.ReleaseURL = &releaseURL
|
||||
status.Notes = ¬es
|
||||
status.CheckedAt = &checkedAt
|
||||
return status
|
||||
}
|
||||
|
||||
func nonNilItems(items []string) []string {
|
||||
if items == nil {
|
||||
return []string{}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func Newest(releases []Release) (Release, error) {
|
||||
var newest Release
|
||||
found := false
|
||||
for _, release := range releases {
|
||||
if _, err := ParseVersion(release.Tag); err != nil {
|
||||
continue
|
||||
}
|
||||
if !found || IsNewer(newest.Tag, release.Tag) {
|
||||
newest = release
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Release{}, ErrNoRelease
|
||||
}
|
||||
return newest, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
bulletLine = regexp.MustCompile(`^[-*+]\s+(.+)$`)
|
||||
markdownLink = regexp.MustCompile(`!?\[([^\]]*)\]\([^)]*\)`)
|
||||
boldText = regexp.MustCompile(`\*\*([^*]+)\*\*|__([^_]+)__`)
|
||||
italicText = regexp.MustCompile(`\*([^*\s][^*]*)\*`)
|
||||
inlineCode = regexp.MustCompile("`([^`]*)`")
|
||||
)
|
||||
|
||||
func BuildNotes(tag string, publishedAt time.Time, body string) ReleaseNotes {
|
||||
items := []string{}
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
match := bulletLine.FindStringSubmatch(strings.TrimSpace(line))
|
||||
if match == nil {
|
||||
continue
|
||||
}
|
||||
if item := plainText(match[1]); item != "" {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return ReleaseNotes{Version: tag, ReleasedAt: publishedAt, Items: items}
|
||||
}
|
||||
|
||||
func plainText(markdown string) string {
|
||||
text := markdownLink.ReplaceAllString(markdown, "$1")
|
||||
text = boldText.ReplaceAllString(text, "$1$2")
|
||||
text = italicText.ReplaceAllString(text, "$1")
|
||||
text = inlineCode.ReplaceAllString(text, "$1")
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package update
|
||||
|
||||
type ReleaseFeed interface {
|
||||
Latest() (Release, error)
|
||||
}
|
||||
|
||||
type StatusStore interface {
|
||||
Load() (CheckResult, bool)
|
||||
Save(result CheckResult) error
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Version struct {
|
||||
numbers []int
|
||||
pre []string
|
||||
}
|
||||
|
||||
func ParseVersion(raw string) (Version, error) {
|
||||
trimmed := strings.TrimPrefix(strings.TrimSpace(raw), "v")
|
||||
core, pre, hasPre := strings.Cut(trimmed, "-")
|
||||
if core == "" || (hasPre && pre == "") {
|
||||
return Version{}, ErrInvalidVersion
|
||||
}
|
||||
var version Version
|
||||
for _, segment := range strings.Split(core, ".") {
|
||||
number, err := strconv.Atoi(segment)
|
||||
if err != nil || number < 0 {
|
||||
return Version{}, ErrInvalidVersion
|
||||
}
|
||||
version.numbers = append(version.numbers, number)
|
||||
}
|
||||
if hasPre {
|
||||
version.pre = strings.Split(pre, ".")
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func IsNewer(current, latest string) bool {
|
||||
currentVersion, err := ParseVersion(current)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
latestVersion, err := ParseVersion(latest)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return compareVersions(latestVersion, currentVersion) > 0
|
||||
}
|
||||
|
||||
func compareVersions(a, b Version) int {
|
||||
if order := compareNumbers(a.numbers, b.numbers); order != 0 {
|
||||
return order
|
||||
}
|
||||
return comparePreRelease(a.pre, b.pre)
|
||||
}
|
||||
|
||||
func compareNumbers(a, b []int) int {
|
||||
for i := range max(len(a), len(b)) {
|
||||
if order := cmp.Compare(segmentAt(a, i), segmentAt(b, i)); order != 0 {
|
||||
return order
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func segmentAt(numbers []int, index int) int {
|
||||
if index < len(numbers) {
|
||||
return numbers[index]
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func comparePreRelease(a, b []string) int {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return 0
|
||||
}
|
||||
if len(a) == 0 {
|
||||
return 1
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return -1
|
||||
}
|
||||
length := min(len(a), len(b))
|
||||
for i := 0; i < length; i++ {
|
||||
if order := compareIdentifiers(a[i], b[i]); order != 0 {
|
||||
return order
|
||||
}
|
||||
}
|
||||
return cmp.Compare(len(a), len(b))
|
||||
}
|
||||
|
||||
func compareIdentifiers(a, b string) int {
|
||||
numberA, errA := strconv.Atoi(a)
|
||||
numberB, errB := strconv.Atoi(b)
|
||||
if errA == nil && errB == nil {
|
||||
return cmp.Compare(numberA, numberB)
|
||||
}
|
||||
if errA == nil {
|
||||
return -1
|
||||
}
|
||||
if errB == nil {
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(a, b)
|
||||
}
|
||||
@@ -32,6 +32,7 @@ type Settings struct {
|
||||
AuthRateLimit AuthRateLimitSettings
|
||||
TrustedProxies []string
|
||||
Version string
|
||||
UpdateFeedURL string
|
||||
}
|
||||
|
||||
func (s Settings) IsProduction() bool {
|
||||
@@ -74,6 +75,7 @@ func NewSettingsFromEnv() (Settings, error) {
|
||||
Window: time.Duration(authRateLimitWindowSeconds) * time.Second,
|
||||
},
|
||||
TrustedProxies: parseSemicolonSeparatedList(os.Getenv("TRUSTED_PROXIES")),
|
||||
UpdateFeedURL: os.Getenv("UPDATE_FEED_URL"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
releasesPath = "/api/v1/repos/fsylvain/RenewCMS/releases"
|
||||
feedRequestTimeout = 10 * time.Second
|
||||
maxFeedBodyBytes = 4 << 20
|
||||
)
|
||||
|
||||
type GiteaReleaseFeed struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
var _ domain.ReleaseFeed = &GiteaReleaseFeed{}
|
||||
|
||||
type giteaRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Body string `json:"body"`
|
||||
Draft bool `json:"draft"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
}
|
||||
|
||||
func NewGiteaReleaseFeed(baseURL string) *GiteaReleaseFeed {
|
||||
return &GiteaReleaseFeed{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
client: &http.Client{Timeout: feedRequestTimeout},
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GiteaReleaseFeed) Latest() (domain.Release, error) {
|
||||
releases, err := g.fetch()
|
||||
if err != nil {
|
||||
return domain.Release{}, err
|
||||
}
|
||||
return domain.Newest(releases)
|
||||
}
|
||||
|
||||
func (g *GiteaReleaseFeed) fetch() ([]domain.Release, error) {
|
||||
response, err := g.client.Get(g.baseURL + releasesPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", domain.ErrFeedUnavailable, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%w: status %d", domain.ErrFeedUnavailable, response.StatusCode)
|
||||
}
|
||||
var raw []giteaRelease
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, maxFeedBodyBytes)).Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", domain.ErrFeedUnavailable, err)
|
||||
}
|
||||
return toReleases(raw), nil
|
||||
}
|
||||
|
||||
func toReleases(raw []giteaRelease) []domain.Release {
|
||||
releases := make([]domain.Release, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
if item.Draft {
|
||||
continue
|
||||
}
|
||||
releases = append(releases, domain.Release{
|
||||
Tag: item.TagName,
|
||||
URL: item.HTMLURL,
|
||||
PublishedAt: item.PublishedAt,
|
||||
Notes: domain.BuildNotes(item.TagName, item.PublishedAt, item.Body),
|
||||
})
|
||||
}
|
||||
return releases
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const statusFilePermissions = 0o600
|
||||
|
||||
type JSONStatusStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var _ domain.StatusStore = &JSONStatusStore{}
|
||||
|
||||
func NewJSONStatusStore(path string) *JSONStatusStore {
|
||||
return &JSONStatusStore{path: path}
|
||||
}
|
||||
|
||||
func (s *JSONStatusStore) Load() (domain.CheckResult, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
content, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return domain.CheckResult{}, false
|
||||
}
|
||||
var result domain.CheckResult
|
||||
if err := json.Unmarshal(content, &result); err != nil || result.CheckedAt.IsZero() {
|
||||
return domain.CheckResult{}, false
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
func (s *JSONStatusStore) Save(result domain.CheckResult) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
content, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
temporary := s.path + ".tmp"
|
||||
if err := os.WriteFile(temporary, content, statusFilePermissions); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temporary, s.path)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed release.pub
|
||||
var embeddedPublicKey string
|
||||
|
||||
var (
|
||||
ErrPublicKeyFormat = errors.New("the public key is not a base64 encoded 32 byte ed25519 key")
|
||||
ErrPlaceholderPublicKey = errors.New("the placeholder public key has not been replaced: run task release:keygen and commit the real key as internal/infrastructure/update/release.pub")
|
||||
)
|
||||
|
||||
func EmbeddedPublicKey() (ed25519.PublicKey, error) {
|
||||
return ParsePublicKey(embeddedPublicKey)
|
||||
}
|
||||
|
||||
func ParsePublicKey(text string) (ed25519.PublicKey, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(text))
|
||||
if err != nil || len(decoded) != ed25519.PublicKeySize {
|
||||
return nil, ErrPublicKeyFormat
|
||||
}
|
||||
return ed25519.PublicKey(decoded), nil
|
||||
}
|
||||
|
||||
func IsPlaceholderPublicKey(key ed25519.PublicKey) bool {
|
||||
return bytes.Equal(key, make([]byte, ed25519.PublicKeySize))
|
||||
}
|
||||
|
||||
func EncodePublicKey(key ed25519.PublicKey) string {
|
||||
return base64.StdEncoding.EncodeToString(key)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
@@ -0,0 +1,292 @@
|
||||
package releasesign
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/infrastructure/update"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
exitOK = 0
|
||||
exitFailure = 1
|
||||
exitUsage = 2
|
||||
|
||||
signingKeyEnvVar = "RELEASE_SIGNING_KEY"
|
||||
signatureSuffix = ".sig"
|
||||
privateKeyFileMode = 0o600
|
||||
publicFileMode = 0o644
|
||||
repositoryMarker = ".git"
|
||||
checksumsFileName = "checksums.txt"
|
||||
usageText = "usage: releasesign keygen <private-key-path> [--pub-out <path>] | sign <file> [--key <path>] [--pub <path>] | verify <file> [--pub <path>] | checksums <dir> [--match <glob>]"
|
||||
publicKeyCommitHint = "commit the public key as internal/infrastructure/update/release.pub"
|
||||
)
|
||||
|
||||
var (
|
||||
errInsideRepository = errors.New("the private key path is inside the repository work tree, choose a path outside it")
|
||||
errKeyMismatch = errors.New("the private key does not match the public key in use")
|
||||
errPrivateKeyFormat = errors.New("the private key file is not a base64 encoded ed25519 private key")
|
||||
errSignatureFormat = errors.New("the signature file is not a base64 encoded ed25519 signature")
|
||||
errBadSignature = errors.New("the signature does not match the file: it was changed or signed with another key")
|
||||
errNoKeyPath = errors.New("no private key given: pass --key or set RELEASE_SIGNING_KEY")
|
||||
)
|
||||
|
||||
func Run(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(stderr, usageText)
|
||||
return exitUsage
|
||||
}
|
||||
commands := map[string]func([]string, io.Writer) error{
|
||||
"keygen": keygen,
|
||||
"sign": sign,
|
||||
"verify": verify,
|
||||
"checksums": checksums,
|
||||
}
|
||||
command, known := commands[args[0]]
|
||||
if !known {
|
||||
fmt.Fprintln(stderr, usageText)
|
||||
return exitUsage
|
||||
}
|
||||
if err := command(args[1:], stdout); err != nil {
|
||||
fmt.Fprintln(stderr, "releasesign:", err)
|
||||
return exitFailure
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
func parseArguments(name string, args []string, define func(*flag.FlagSet)) (string, error) {
|
||||
set := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
set.SetOutput(io.Discard)
|
||||
define(set)
|
||||
positional, flags := splitArguments(args)
|
||||
if err := set.Parse(flags); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(positional) != 1 {
|
||||
return "", errors.New(usageText)
|
||||
}
|
||||
return positional[0], nil
|
||||
}
|
||||
|
||||
func splitArguments(args []string) ([]string, []string) {
|
||||
var positional, flags []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
if strings.HasPrefix(args[i], "--") {
|
||||
flags = append(flags, args[i])
|
||||
if !strings.Contains(args[i], "=") && i+1 < len(args) {
|
||||
i++
|
||||
flags = append(flags, args[i])
|
||||
}
|
||||
continue
|
||||
}
|
||||
positional = append(positional, args[i])
|
||||
}
|
||||
return positional, flags
|
||||
}
|
||||
|
||||
func keygen(args []string, stdout io.Writer) error {
|
||||
var pubOut string
|
||||
keyPath, err := parseArguments("keygen", args, func(set *flag.FlagSet) { set.StringVar(&pubOut, "pub-out", "", "") })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := refuseInsideRepository(keyPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(keyPath); err == nil {
|
||||
return fmt.Errorf("%s already exists, it is not overwritten", keyPath)
|
||||
}
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encodedPrivate := base64.StdEncoding.EncodeToString(privateKey) + "\n"
|
||||
if err := os.WriteFile(keyPath, []byte(encodedPrivate), privateKeyFileMode); err != nil {
|
||||
return err
|
||||
}
|
||||
encodedPublic := update.EncodePublicKey(publicKey)
|
||||
if pubOut != "" {
|
||||
if err := os.WriteFile(pubOut, []byte(encodedPublic+"\n"), publicFileMode); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(stdout, "private key written to %s: keep it outside the repository and never share it\n", keyPath)
|
||||
fmt.Fprintf(stdout, "public key: %s\n%s\n", encodedPublic, publicKeyCommitHint)
|
||||
return nil
|
||||
}
|
||||
|
||||
func refuseInsideRepository(path string) error {
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for directory := filepath.Dir(absolute); ; directory = filepath.Dir(directory) {
|
||||
if _, err := os.Stat(filepath.Join(directory, repositoryMarker)); err == nil {
|
||||
return fmt.Errorf("%w (%s)", errInsideRepository, directory)
|
||||
}
|
||||
if directory == filepath.Dir(directory) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sign(args []string, stdout io.Writer) error {
|
||||
var keyPath, pubPath string
|
||||
file, err := parseArguments("sign", args, func(set *flag.FlagSet) {
|
||||
set.StringVar(&keyPath, "key", "", "")
|
||||
set.StringVar(&pubPath, "pub", "", "")
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
publicKey, err := loadPublicKey(pubPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
privateKey, err := loadPrivateKey(keyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !publicKeyOf(privateKey).Equal(publicKey) {
|
||||
return errKeyMismatch
|
||||
}
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signature := base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, content))
|
||||
if err := os.WriteFile(file+signatureSuffix, []byte(signature+"\n"), publicFileMode); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(stdout, "signed %s into %s\n", file, file+signatureSuffix)
|
||||
return nil
|
||||
}
|
||||
|
||||
func verify(args []string, stdout io.Writer) error {
|
||||
var pubPath string
|
||||
file, err := parseArguments("verify", args, func(set *flag.FlagSet) { set.StringVar(&pubPath, "pub", "", "") })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
publicKey, err := loadPublicKey(pubPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawSignature, err := os.ReadFile(file + signatureSuffix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signature, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(rawSignature)))
|
||||
if err != nil || len(signature) != ed25519.SignatureSize {
|
||||
return errSignatureFormat
|
||||
}
|
||||
if !ed25519.Verify(publicKey, content, signature) {
|
||||
return errBadSignature
|
||||
}
|
||||
fmt.Fprintf(stdout, "%s is signed by the release key\n", file)
|
||||
return nil
|
||||
}
|
||||
|
||||
func checksums(args []string, stdout io.Writer) error {
|
||||
match := "*"
|
||||
dir, err := parseArguments("checksums", args, func(set *flag.FlagSet) { set.StringVar(&match, "match", "*", "") })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paths, err := filepath.Glob(filepath.Join(dir, match))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Strings(paths)
|
||||
var lines strings.Builder
|
||||
for _, path := range paths {
|
||||
line, err := checksumLine(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lines.WriteString(line)
|
||||
}
|
||||
if lines.Len() == 0 {
|
||||
return fmt.Errorf("no file in %s matches %s", dir, match)
|
||||
}
|
||||
target := filepath.Join(dir, checksumsFileName)
|
||||
if err := os.WriteFile(target, []byte(lines.String()), publicFileMode); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(stdout, "wrote", target)
|
||||
return nil
|
||||
}
|
||||
|
||||
func checksumLine(path string) (string, error) {
|
||||
name := filepath.Base(path)
|
||||
if name == checksumsFileName || strings.HasSuffix(name, signatureSuffix) {
|
||||
return "", nil
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.IsDir() {
|
||||
return "", err
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
return fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), name), nil
|
||||
}
|
||||
|
||||
func loadPublicKey(pubPath string) (ed25519.PublicKey, error) {
|
||||
var key ed25519.PublicKey
|
||||
var err error
|
||||
if pubPath == "" {
|
||||
key, err = update.EmbeddedPublicKey()
|
||||
} else {
|
||||
var content []byte
|
||||
content, err = os.ReadFile(pubPath)
|
||||
if err == nil {
|
||||
key, err = update.ParsePublicKey(string(content))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if update.IsPlaceholderPublicKey(key) {
|
||||
return nil, update.ErrPlaceholderPublicKey
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func loadPrivateKey(keyPath string) (ed25519.PrivateKey, error) {
|
||||
if keyPath == "" {
|
||||
keyPath = os.Getenv(signingKeyEnvVar)
|
||||
}
|
||||
if keyPath == "" {
|
||||
return nil, errNoKeyPath
|
||||
}
|
||||
content, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(content)))
|
||||
if err != nil || len(decoded) != ed25519.PrivateKeySize {
|
||||
return nil, errPrivateKeyFormat
|
||||
}
|
||||
return ed25519.PrivateKey(decoded), nil
|
||||
}
|
||||
|
||||
func publicKeyOf(privateKey ed25519.PrivateKey) ed25519.PublicKey {
|
||||
return privateKey.Public().(ed25519.PublicKey)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/update"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CheckForUpdateUseCase struct {
|
||||
feed update.ReleaseFeed
|
||||
store update.StatusStore
|
||||
currentVersion string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewCheckForUpdateUseCase(feed update.ReleaseFeed, store update.StatusStore, currentVersion string, now func() time.Time) *CheckForUpdateUseCase {
|
||||
return &CheckForUpdateUseCase{feed: feed, store: store, currentVersion: currentVersion, now: now}
|
||||
}
|
||||
|
||||
func (c *CheckForUpdateUseCase) Check() (update.UpdateStatus, error) {
|
||||
release, err := c.feed.Latest()
|
||||
if err != nil {
|
||||
return update.UpdateStatus{}, err
|
||||
}
|
||||
result := update.NewResult(release, c.now())
|
||||
if err := c.store.Save(result); err != nil {
|
||||
return update.UpdateStatus{}, err
|
||||
}
|
||||
return update.NewStatus(c.currentVersion, &result), nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/update"
|
||||
|
||||
type GetUpdateStatusUseCase struct {
|
||||
store update.StatusStore
|
||||
currentVersion string
|
||||
}
|
||||
|
||||
func NewGetUpdateStatusUseCase(store update.StatusStore, currentVersion string) *GetUpdateStatusUseCase {
|
||||
return &GetUpdateStatusUseCase{store: store, currentVersion: currentVersion}
|
||||
}
|
||||
|
||||
func (g *GetUpdateStatusUseCase) GetStatus() update.UpdateStatus {
|
||||
result, found := g.store.Load()
|
||||
if !found {
|
||||
return update.NewStatus(g.currentVersion, nil)
|
||||
}
|
||||
return update.NewStatus(g.currentVersion, &result)
|
||||
}
|
||||
Reference in new issue
Block a user