Browse Source
- Add ServicesEnabled config to run admin UI without starting services - Add start/stop services API endpoints and dashboard controls - Add IsRunning() method to supervisor for service state tracking - Fix release download URLs to use git.nostrdev.com instead of git.mleku.dev - Change Makefile to use go install (except main relay uses go build for name) - Add orly-certs DNS-01 wildcard certificate manager - Remove libsecp256k1.so from repo (runtime dependency only) Files modified: - cmd/orly-launcher/config.go: Add ServicesEnabled option - cmd/orly-launcher/main.go: Skip services when disabled, update help - cmd/orly-launcher/server.go: Add start/stop endpoints, fix tags API URL - cmd/orly-launcher/supervisor.go: Add IsRunning(), allow restart after stop - cmd/orly-launcher/web/src/api.js: Add startServices/stopServices functions - cmd/orly-launcher/web/src/pages/Dashboard.svelte: Add start/stop buttons - cmd/orly-launcher/web/src/pages/Update.svelte: Fix release base URL - cmd/orly-certs/: New DNS-01 certificate manager - Makefile: Use go install, keep go build for main relay - pkg/version/version: Bump to v0.56.2 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>main
19 changed files with 3405 additions and 238 deletions
@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
|
||||
package main |
||||
|
||||
import ( |
||||
"os" |
||||
"time" |
||||
|
||||
"go-simpler.org/env" |
||||
"lol.mleku.dev/chk" |
||||
"lol.mleku.dev/log" |
||||
) |
||||
|
||||
// Config holds the configuration for the certificate manager.
|
||||
type Config struct { |
||||
// Domain is the wildcard domain to obtain a certificate for (e.g., "*.myapp.com")
|
||||
Domain string `env:"ORLY_CERTS_DOMAIN" required:"true" usage:"wildcard domain (e.g., *.myapp.com)"` |
||||
|
||||
// Email is the email address for the Let's Encrypt account
|
||||
Email string `env:"ORLY_CERTS_EMAIL" required:"true" usage:"email for Let's Encrypt account"` |
||||
|
||||
// DNSProvider is the name of the DNS provider (cloudflare, route53, hetzner, etc.)
|
||||
DNSProvider string `env:"ORLY_CERTS_DNS_PROVIDER" required:"true" usage:"DNS provider name (cloudflare, route53, hetzner, etc.)"` |
||||
|
||||
// OutputDir is the directory where certificates will be stored
|
||||
OutputDir string `env:"ORLY_CERTS_OUTPUT_DIR" default:"/var/cache/orly-certs" usage:"certificate output directory"` |
||||
|
||||
// RenewDays is the number of days before expiry to trigger renewal
|
||||
RenewDays int `env:"ORLY_CERTS_RENEW_DAYS" default:"30" usage:"renew certificate when expiring within N days"` |
||||
|
||||
// CheckInterval is how often to check for renewal
|
||||
CheckInterval time.Duration `env:"ORLY_CERTS_CHECK_INTERVAL" default:"12h" usage:"how often to check for renewal"` |
||||
|
||||
// ACMEServer is the ACME server URL (empty for production Let's Encrypt)
|
||||
ACMEServer string `env:"ORLY_CERTS_ACME_SERVER" default:"" usage:"ACME server URL (empty for production)"` |
||||
|
||||
// LogLevel is the log level
|
||||
LogLevel string `env:"ORLY_CERTS_LOG_LEVEL" default:"info" usage:"log level (trace, debug, info, warn, error)"` |
||||
|
||||
// AccountKeyPath is the path to store the ACME account private key
|
||||
AccountKeyPath string `env:"ORLY_CERTS_ACCOUNT_KEY" default:"" usage:"path to ACME account key (auto-generated if empty)"` |
||||
} |
||||
|
||||
// ProductionACMEServer is the Let's Encrypt production ACME server
|
||||
const ProductionACMEServer = "https://acme-v02.api.letsencrypt.org/directory" |
||||
|
||||
// StagingACMEServer is the Let's Encrypt staging ACME server (for testing)
|
||||
const StagingACMEServer = "https://acme-staging-v02.api.letsencrypt.org/directory" |
||||
|
||||
// loadConfig loads configuration from environment variables.
|
||||
func loadConfig() *Config { |
||||
cfg := &Config{} |
||||
if err := env.Load(cfg, nil); chk.E(err) { |
||||
log.E.F("failed to load config: %v", err) |
||||
os.Exit(1) |
||||
} |
||||
return cfg |
||||
} |
||||
|
||||
// ACMEServerURL returns the ACME server URL to use.
|
||||
func (c *Config) ACMEServerURL() string { |
||||
if c.ACMEServer != "" { |
||||
return c.ACMEServer |
||||
} |
||||
return ProductionACMEServer |
||||
} |
||||
|
||||
// BaseDomain extracts the base domain from the wildcard domain.
|
||||
// e.g., "*.myapp.com" -> "myapp.com"
|
||||
func (c *Config) BaseDomain() string { |
||||
domain := c.Domain |
||||
if len(domain) > 2 && domain[:2] == "*." { |
||||
return domain[2:] |
||||
} |
||||
return domain |
||||
} |
||||
@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
|
||||
// orly-certs is a certificate management service that obtains and renews
|
||||
// wildcard SSL certificates from Let's Encrypt using DNS-01 challenges.
|
||||
//
|
||||
// It supports multiple DNS providers via the lego library and stores
|
||||
// certificates at a conventional file path for web apps to consume.
|
||||
//
|
||||
// Configuration is via environment variables:
|
||||
// - ORLY_CERTS_DOMAIN: Wildcard domain (e.g., "*.myapp.com")
|
||||
// - ORLY_CERTS_EMAIL: Email for Let's Encrypt account
|
||||
// - ORLY_CERTS_DNS_PROVIDER: DNS provider name (cloudflare, route53, etc.)
|
||||
// - ORLY_CERTS_OUTPUT_DIR: Certificate output directory (default: /var/cache/orly-certs)
|
||||
//
|
||||
// Provider-specific credentials are set via standard lego environment variables.
|
||||
// See https://go-acme.github.io/lego/dns/ for documentation.
|
||||
package main |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"os" |
||||
"os/signal" |
||||
"syscall" |
||||
"time" |
||||
|
||||
"lol.mleku.dev" |
||||
"lol.mleku.dev/chk" |
||||
"lol.mleku.dev/log" |
||||
) |
||||
|
||||
func main() { |
||||
cfg := loadConfig() |
||||
lol.SetLogLevel(cfg.LogLevel) |
||||
|
||||
log.I.F("orly-certs starting") |
||||
log.I.F(" domain: %s", cfg.Domain) |
||||
log.I.F(" email: %s", cfg.Email) |
||||
log.I.F(" dns provider: %s", cfg.DNSProvider) |
||||
log.I.F(" output dir: %s", cfg.OutputDir) |
||||
log.I.F(" acme server: %s", cfg.ACMEServerURL()) |
||||
|
||||
ctx, cancel := context.WithCancel(context.Background()) |
||||
defer cancel() |
||||
|
||||
// Set up signal handling
|
||||
sigs := make(chan os.Signal, 1) |
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) |
||||
|
||||
go func() { |
||||
<-sigs |
||||
log.I.F("shutdown signal received") |
||||
cancel() |
||||
}() |
||||
|
||||
// Create certificate manager
|
||||
manager, err := NewCertManager(cfg) |
||||
if chk.E(err) { |
||||
log.F.F("failed to create certificate manager: %v", err) |
||||
} |
||||
|
||||
// Initial certificate check/obtain
|
||||
if err := manager.EnsureCertificate(); chk.E(err) { |
||||
log.F.F("failed to ensure certificate: %v", err) |
||||
} |
||||
|
||||
// Start renewal loop
|
||||
log.I.F("starting renewal check loop (interval: %s)", cfg.CheckInterval) |
||||
ticker := time.NewTicker(cfg.CheckInterval) |
||||
defer ticker.Stop() |
||||
|
||||
for { |
||||
select { |
||||
case <-ticker.C: |
||||
if err := manager.CheckRenewal(); chk.E(err) { |
||||
log.E.F("renewal check failed: %v", err) |
||||
} |
||||
case <-ctx.Done(): |
||||
log.I.F("orly-certs shutting down") |
||||
return |
||||
} |
||||
} |
||||
} |
||||
|
||||
func usage() { |
||||
fmt.Fprintf(os.Stderr, `orly-certs - DNS-01 wildcard certificate manager |
||||
|
||||
Usage: orly-certs [options] |
||||
|
||||
Environment Variables: |
||||
ORLY_CERTS_DOMAIN Wildcard domain (e.g., *.myapp.com) [required] |
||||
ORLY_CERTS_EMAIL Email for Let's Encrypt account [required] |
||||
ORLY_CERTS_DNS_PROVIDER DNS provider name [required] |
||||
ORLY_CERTS_OUTPUT_DIR Certificate output directory [default: /var/cache/orly-certs] |
||||
ORLY_CERTS_RENEW_DAYS Renew when expiring within N days [default: 30] |
||||
ORLY_CERTS_CHECK_INTERVAL Renewal check interval [default: 12h] |
||||
ORLY_CERTS_ACME_SERVER ACME server URL [default: production Let's Encrypt] |
||||
ORLY_CERTS_LOG_LEVEL Log level [default: info] |
||||
|
||||
Supported DNS Providers: |
||||
cloudflare, route53, hetzner, digitalocean, google, namecheap, godaddy, |
||||
ovh, vultr, linode, gandi, dnsimple, duckdns, azure, alidns, and 80+ more. |
||||
|
||||
Provider credentials are set via standard lego environment variables. |
||||
See https://go-acme.github.io/lego/dns/ for documentation.
|
||||
|
||||
Example: |
||||
export CF_API_TOKEN="your-cloudflare-api-token" |
||||
export ORLY_CERTS_DOMAIN="*.myapp.com" |
||||
export ORLY_CERTS_EMAIL="admin@myapp.com" |
||||
export ORLY_CERTS_DNS_PROVIDER="cloudflare" |
||||
./orly-certs |
||||
`) |
||||
} |
||||
@ -0,0 +1,304 @@
@@ -0,0 +1,304 @@
|
||||
package main |
||||
|
||||
import ( |
||||
"crypto" |
||||
"crypto/ecdsa" |
||||
"crypto/elliptic" |
||||
"crypto/rand" |
||||
"crypto/x509" |
||||
"encoding/json" |
||||
"encoding/pem" |
||||
"fmt" |
||||
"os" |
||||
"path/filepath" |
||||
"time" |
||||
|
||||
"github.com/go-acme/lego/v4/certcrypto" |
||||
"github.com/go-acme/lego/v4/certificate" |
||||
"github.com/go-acme/lego/v4/lego" |
||||
"github.com/go-acme/lego/v4/registration" |
||||
"lol.mleku.dev/chk" |
||||
"lol.mleku.dev/log" |
||||
) |
||||
|
||||
// CertManager handles certificate acquisition and renewal.
|
||||
type CertManager struct { |
||||
cfg *Config |
||||
client *lego.Client |
||||
user *User |
||||
certPath string |
||||
keyPath string |
||||
metaPath string |
||||
} |
||||
|
||||
// User implements the lego registration.User interface.
|
||||
type User struct { |
||||
Email string |
||||
Registration *registration.Resource |
||||
key crypto.PrivateKey |
||||
} |
||||
|
||||
func (u *User) GetEmail() string { |
||||
return u.Email |
||||
} |
||||
|
||||
func (u *User) GetRegistration() *registration.Resource { |
||||
return u.Registration |
||||
} |
||||
|
||||
func (u *User) GetPrivateKey() crypto.PrivateKey { |
||||
return u.key |
||||
} |
||||
|
||||
// CertMetadata stores certificate metadata.
|
||||
type CertMetadata struct { |
||||
Domain string `json:"domain"` |
||||
Domains []string `json:"domains"` |
||||
NotBefore time.Time `json:"not_before"` |
||||
NotAfter time.Time `json:"not_after"` |
||||
Issuer string `json:"issuer"` |
||||
RenewedAt time.Time `json:"renewed_at"` |
||||
} |
||||
|
||||
// NewCertManager creates a new certificate manager.
|
||||
func NewCertManager(cfg *Config) (*CertManager, error) { |
||||
// Create output directory
|
||||
domainDir := filepath.Join(cfg.OutputDir, cfg.BaseDomain()) |
||||
if err := os.MkdirAll(domainDir, 0755); chk.E(err) { |
||||
return nil, fmt.Errorf("failed to create output directory: %w", err) |
||||
} |
||||
|
||||
// Generate or load account private key
|
||||
privateKey, err := loadOrCreateAccountKey(cfg) |
||||
if chk.E(err) { |
||||
return nil, fmt.Errorf("failed to load/create account key: %w", err) |
||||
} |
||||
|
||||
user := &User{ |
||||
Email: cfg.Email, |
||||
key: privateKey, |
||||
} |
||||
|
||||
// Create lego config
|
||||
legoCfg := lego.NewConfig(user) |
||||
legoCfg.CADirURL = cfg.ACMEServerURL() |
||||
legoCfg.Certificate.KeyType = certcrypto.EC256 |
||||
|
||||
// Create lego client
|
||||
client, err := lego.NewClient(legoCfg) |
||||
if chk.E(err) { |
||||
return nil, fmt.Errorf("failed to create ACME client: %w", err) |
||||
} |
||||
|
||||
// Set up DNS provider
|
||||
dnsProvider, err := NewDNSProvider(cfg.DNSProvider) |
||||
if chk.E(err) { |
||||
return nil, err |
||||
} |
||||
|
||||
if err := client.Challenge.SetDNS01Provider(dnsProvider); chk.E(err) { |
||||
return nil, fmt.Errorf("failed to set DNS provider: %w", err) |
||||
} |
||||
|
||||
// Register account if needed
|
||||
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) |
||||
if err != nil { |
||||
// Try to recover existing registration
|
||||
reg, err = client.Registration.ResolveAccountByKey() |
||||
if chk.E(err) { |
||||
return nil, fmt.Errorf("failed to register account: %w", err) |
||||
} |
||||
} |
||||
user.Registration = reg |
||||
|
||||
return &CertManager{ |
||||
cfg: cfg, |
||||
client: client, |
||||
user: user, |
||||
certPath: filepath.Join(domainDir, "cert.pem"), |
||||
keyPath: filepath.Join(domainDir, "key.pem"), |
||||
metaPath: filepath.Join(domainDir, "metadata.json"), |
||||
}, nil |
||||
} |
||||
|
||||
// EnsureCertificate obtains a certificate if none exists or if it needs renewal.
|
||||
func (m *CertManager) EnsureCertificate() error { |
||||
// Check if certificate exists and is valid
|
||||
if m.certificateExists() { |
||||
needsRenewal, err := m.needsRenewal() |
||||
if chk.E(err) { |
||||
log.W.F("failed to check renewal status, will obtain new cert: %v", err) |
||||
} else if !needsRenewal { |
||||
log.I.F("certificate is valid, no renewal needed") |
||||
return nil |
||||
} |
||||
log.I.F("certificate needs renewal") |
||||
} |
||||
|
||||
return m.obtainCertificate() |
||||
} |
||||
|
||||
// CheckRenewal checks if the certificate needs renewal and renews if needed.
|
||||
func (m *CertManager) CheckRenewal() error { |
||||
if !m.certificateExists() { |
||||
return m.obtainCertificate() |
||||
} |
||||
|
||||
needsRenewal, err := m.needsRenewal() |
||||
if chk.E(err) { |
||||
return err |
||||
} |
||||
|
||||
if needsRenewal { |
||||
log.I.F("certificate expiring soon, renewing...") |
||||
return m.obtainCertificate() |
||||
} |
||||
|
||||
log.D.F("certificate still valid, no renewal needed") |
||||
return nil |
||||
} |
||||
|
||||
func (m *CertManager) certificateExists() bool { |
||||
_, err := os.Stat(m.certPath) |
||||
return err == nil |
||||
} |
||||
|
||||
func (m *CertManager) needsRenewal() (bool, error) { |
||||
certPEM, err := os.ReadFile(m.certPath) |
||||
if chk.E(err) { |
||||
return true, err |
||||
} |
||||
|
||||
block, _ := pem.Decode(certPEM) |
||||
if block == nil { |
||||
return true, fmt.Errorf("failed to decode certificate PEM") |
||||
} |
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes) |
||||
if chk.E(err) { |
||||
return true, err |
||||
} |
||||
|
||||
// Check if certificate expires within RenewDays
|
||||
renewTime := time.Now().Add(time.Duration(m.cfg.RenewDays) * 24 * time.Hour) |
||||
return cert.NotAfter.Before(renewTime), nil |
||||
} |
||||
|
||||
func (m *CertManager) obtainCertificate() error { |
||||
log.I.F("obtaining certificate for %s", m.cfg.Domain) |
||||
|
||||
request := certificate.ObtainRequest{ |
||||
Domains: []string{m.cfg.Domain, m.cfg.BaseDomain()}, |
||||
Bundle: true, |
||||
} |
||||
|
||||
certificates, err := m.client.Certificate.Obtain(request) |
||||
if chk.E(err) { |
||||
return fmt.Errorf("failed to obtain certificate: %w", err) |
||||
} |
||||
|
||||
// Write certificate chain
|
||||
if err := os.WriteFile(m.certPath, certificates.Certificate, 0644); chk.E(err) { |
||||
return fmt.Errorf("failed to write certificate: %w", err) |
||||
} |
||||
|
||||
// Write private key with restricted permissions
|
||||
if err := os.WriteFile(m.keyPath, certificates.PrivateKey, 0600); chk.E(err) { |
||||
return fmt.Errorf("failed to write private key: %w", err) |
||||
} |
||||
|
||||
// Write issuer certificate if available
|
||||
if len(certificates.IssuerCertificate) > 0 { |
||||
issuerPath := filepath.Join(filepath.Dir(m.certPath), "issuer.pem") |
||||
if err := os.WriteFile(issuerPath, certificates.IssuerCertificate, 0644); chk.E(err) { |
||||
log.W.F("failed to write issuer certificate: %v", err) |
||||
} |
||||
} |
||||
|
||||
// Write metadata
|
||||
if err := m.writeMetadata(certificates.Certificate); chk.E(err) { |
||||
log.W.F("failed to write metadata: %v", err) |
||||
} |
||||
|
||||
log.I.F("certificate obtained successfully for %s", m.cfg.Domain) |
||||
log.I.F(" cert: %s", m.certPath) |
||||
log.I.F(" key: %s", m.keyPath) |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (m *CertManager) writeMetadata(certPEM []byte) error { |
||||
block, _ := pem.Decode(certPEM) |
||||
if block == nil { |
||||
return fmt.Errorf("failed to decode certificate for metadata") |
||||
} |
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes) |
||||
if chk.E(err) { |
||||
return err |
||||
} |
||||
|
||||
meta := CertMetadata{ |
||||
Domain: m.cfg.Domain, |
||||
Domains: cert.DNSNames, |
||||
NotBefore: cert.NotBefore, |
||||
NotAfter: cert.NotAfter, |
||||
Issuer: cert.Issuer.CommonName, |
||||
RenewedAt: time.Now(), |
||||
} |
||||
|
||||
data, err := json.MarshalIndent(meta, "", " ") |
||||
if chk.E(err) { |
||||
return err |
||||
} |
||||
|
||||
return os.WriteFile(m.metaPath, data, 0644) |
||||
} |
||||
|
||||
func loadOrCreateAccountKey(cfg *Config) (crypto.PrivateKey, error) { |
||||
keyPath := cfg.AccountKeyPath |
||||
if keyPath == "" { |
||||
keyPath = filepath.Join(cfg.OutputDir, "account.key") |
||||
} |
||||
|
||||
// Try to load existing key
|
||||
if data, err := os.ReadFile(keyPath); err == nil { |
||||
block, _ := pem.Decode(data) |
||||
if block != nil { |
||||
key, err := x509.ParseECPrivateKey(block.Bytes) |
||||
if err == nil { |
||||
log.D.F("loaded existing account key from %s", keyPath) |
||||
return key, nil |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Generate new key
|
||||
log.I.F("generating new account key") |
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
||||
if chk.E(err) { |
||||
return nil, err |
||||
} |
||||
|
||||
// Save key
|
||||
keyBytes, err := x509.MarshalECPrivateKey(key) |
||||
if chk.E(err) { |
||||
return nil, err |
||||
} |
||||
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{ |
||||
Type: "EC PRIVATE KEY", |
||||
Bytes: keyBytes, |
||||
}) |
||||
|
||||
if err := os.MkdirAll(filepath.Dir(keyPath), 0755); chk.E(err) { |
||||
return nil, err |
||||
} |
||||
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0600); chk.E(err) { |
||||
return nil, err |
||||
} |
||||
|
||||
log.I.F("saved new account key to %s", keyPath) |
||||
return key, nil |
||||
} |
||||
@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
|
||||
package main |
||||
|
||||
import ( |
||||
"fmt" |
||||
|
||||
"github.com/go-acme/lego/v4/challenge" |
||||
"github.com/go-acme/lego/v4/providers/dns" |
||||
) |
||||
|
||||
// NewDNSProvider creates a DNS challenge provider by name.
|
||||
// The provider will be configured using standard environment variables
|
||||
// as documented by lego for each provider.
|
||||
//
|
||||
// Common providers and their environment variables:
|
||||
// - cloudflare: CF_API_TOKEN or CF_API_EMAIL + CF_API_KEY
|
||||
// - route53: AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY + AWS_REGION
|
||||
// - hetzner: HETZNER_API_KEY
|
||||
// - digitalocean: DO_AUTH_TOKEN
|
||||
// - google: GCE_PROJECT + GCE_SERVICE_ACCOUNT_FILE
|
||||
// - namecheap: NAMECHEAP_API_USER + NAMECHEAP_API_KEY
|
||||
// - godaddy: GODADDY_API_KEY + GODADDY_API_SECRET
|
||||
// - ovh: OVH_ENDPOINT + OVH_APPLICATION_KEY + OVH_APPLICATION_SECRET + OVH_CONSUMER_KEY
|
||||
// - vultr: VULTR_API_KEY
|
||||
// - linode: LINODE_TOKEN
|
||||
//
|
||||
// See https://go-acme.github.io/lego/dns/ for full list and documentation.
|
||||
func NewDNSProvider(name string) (challenge.Provider, error) { |
||||
provider, err := dns.NewDNSChallengeProviderByName(name) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to create DNS provider '%s': %w", name, err) |
||||
} |
||||
return provider, nil |
||||
} |
||||
|
||||
// SupportedProviders returns a list of commonly used DNS providers.
|
||||
// This is not exhaustive - lego supports 100+ providers.
|
||||
func SupportedProviders() []string { |
||||
return []string{ |
||||
"cloudflare", |
||||
"route53", |
||||
"hetzner", |
||||
"digitalocean", |
||||
"google", |
||||
"namecheap", |
||||
"godaddy", |
||||
"ovh", |
||||
"vultr", |
||||
"linode", |
||||
"gandi", |
||||
"dnsimple", |
||||
"duckdns", |
||||
"azure", |
||||
"alidns", |
||||
} |
||||
} |
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Loading…
Reference in new issue