feat: signed releases, release feed, notification (read-only)

This commit is contained in:
fsylvain committed 2026-10-01 15:01:53 +02:00
1 parent a49eec909c
commit 834493a4f5
49 files changed
+4191 -18

No files matched your search

@@ -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)
}