feat(license): Lizenz-System mit Ed25519-Verify gegen license.netcell-it.com
Portiert mail-gateway/internal/license (Verify, Cache, Trial, Signature) + DB-Mirror (internal/services/license) + REST-Handler (status/verify/key/clear) + UI-Page /license (Activate, Status, Limits, Features, Re-verify) + <LicenseBanner /> neben UpdateBanner (trial-expiring, expired, verify-failed) + Scheduler: täglich Re-verify (24h-Tick) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
78
internal/license/keystore.go
Normal file
78
internal/license/keystore.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// KeyStore holds the per-node license key on disk. Every node in a
|
||||
// cluster runs its own verification against license.netcell-it.com
|
||||
// with its own fingerprint + key, so the file lives under
|
||||
// /var/lib/edgeguard/ (node-local, never replicated via the config
|
||||
// fan-out).
|
||||
//
|
||||
// Shape on disk: a single-line text file, leading/trailing whitespace
|
||||
// stripped. Mode 0600, owner nmg so a compromised read of a
|
||||
// checked-out working copy never leaks the key.
|
||||
type KeyStore struct {
|
||||
Path string
|
||||
|
||||
// cached holds the last-loaded key so Get() is cheap; Reload()
|
||||
// flushes it.
|
||||
cached atomic.Value // string
|
||||
}
|
||||
|
||||
const DefaultKeyStorePath = "/var/lib/edgeguard/license_key"
|
||||
|
||||
// NewKeyStore returns a store writing to /var/lib/edgeguard/license_key.
|
||||
func NewKeyStore() *KeyStore { return &KeyStore{Path: DefaultKeyStorePath} }
|
||||
|
||||
// Get returns the currently persisted key, or "" when the file is
|
||||
// absent / empty. Never returns an error so callers can happily fall
|
||||
// through to the trial-mode path.
|
||||
func (s *KeyStore) Get() string {
|
||||
if v, ok := s.cached.Load().(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
data, err := os.ReadFile(s.Path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
k := strings.TrimSpace(string(data))
|
||||
s.cached.Store(k)
|
||||
return k
|
||||
}
|
||||
|
||||
// Save writes the key atomically (tmp + rename) with mode 0600 and
|
||||
// primes the in-memory cache. Empty key removes the file — that's
|
||||
// how an admin returns the node to trial mode.
|
||||
func (s *KeyStore) Save(key string) error {
|
||||
key = strings.TrimSpace(key)
|
||||
dir := filepath.Dir(s.Path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if key == "" {
|
||||
if err := os.Remove(s.Path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
s.cached.Store("")
|
||||
return nil
|
||||
}
|
||||
tmp := s.Path + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(key+"\n"), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmp, 0o600); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, s.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
s.cached.Store(key)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user