Files
rmm-openwrt/server/cmd/rmm-server/main.go
T
benya c468a3cccb
Build and test / Tests and license checks (push) Has been cancelled
Build and test / OpenWrt 24.10.7 · x86-64 (push) Has been cancelled
Build and test / OpenWrt 25.12.4 · x86-64 (push) Has been cancelled
Build and test / OpenWrt 24.10.7 · ipq40xx-generic (push) Has been cancelled
Build and test / OpenWrt 25.12.4 · ipq40xx-generic (push) Has been cancelled
Build and test / OpenWrt 24.10.7 · mediatek-filogic (push) Has been cancelled
Build and test / OpenWrt 25.12.4 · mediatek-filogic (push) Has been cancelled
Build and test / OpenWrt 24.10.7 · ath79-generic (push) Has been cancelled
Build and test / OpenWrt 25.12.4 · ath79-generic (push) Has been cancelled
Build and test / OpenWrt 24.10.7 · ramips-mt7621 (push) Has been cancelled
Build and test / OpenWrt 25.12.4 · ramips-mt7621 (push) Has been cancelled
Build and test / Publish agent GitHub release assets (push) Has been cancelled
Build and test / Publish signed package repository (push) Has been cancelled
Release server / Test and build server image (push) Has been cancelled
release: prepare server 0.9.2
2026-07-31 11:30:51 +03:00

290 lines
11 KiB
Go

package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"rmm-openwrt/server/internal/httpapi"
"rmm-openwrt/server/internal/store"
"rmm-openwrt/server/internal/updateinfo"
)
var (
serverVersion = "dev"
serverRevision = "unknown"
stableAgentVersion = "0.6.8"
)
func main() {
addr := env("RMM_ADDR", ":8080")
dbPath := env("RMM_DB_PATH", "rmm.db")
insecureDevMode := envBool("RMM_INSECURE_DEV_MODE", false)
operatorPassword := strings.TrimSpace(os.Getenv("RMM_OPERATOR_PASSWORD"))
if operatorPassword == "" {
if !insecureDevMode {
log.Fatal("RMM_OPERATOR_PASSWORD is required (or explicitly enable RMM_INSECURE_DEV_MODE for a local lab)")
}
operatorPassword = "dev-operator-password"
}
if !insecureDevMode && insecurePlaceholder(operatorPassword) {
log.Fatal("RMM_OPERATOR_PASSWORD still contains an insecure example value")
}
operatorToken := strings.TrimSpace(os.Getenv("RMM_OPERATOR_TOKEN"))
if operatorToken != "" && !insecureDevMode && insecurePlaceholder(operatorToken) {
log.Fatal("RMM_OPERATOR_TOKEN still contains an insecure example value")
}
allowLegacyEnrollment := envBool("RMM_ALLOW_LEGACY_ENROLLMENT", false)
enrollmentToken := ""
if allowLegacyEnrollment {
enrollmentToken = strings.TrimSpace(os.Getenv("RMM_ENROLLMENT_TOKEN"))
if enrollmentToken == "" {
log.Fatal("RMM_ENROLLMENT_TOKEN is required when legacy enrollment is enabled")
}
if !insecureDevMode && insecurePlaceholder(enrollmentToken) {
log.Fatal("RMM_ENROLLMENT_TOKEN still contains an insecure example value")
}
}
var passwordResetSender httpapi.PasswordResetSender
var alertEmailSender httpapi.NotificationSender
smtpHost := strings.TrimSpace(os.Getenv("RMM_SMTP_HOST"))
publicURL := strings.TrimRight(strings.TrimSpace(os.Getenv("RMM_PUBLIC_URL")), "/")
if smtpHost != "" {
tlsMode := env("RMM_SMTP_TLS_MODE", "starttls")
if !insecureDevMode && strings.EqualFold(tlsMode, "none") {
log.Fatal("RMM_SMTP_TLS_MODE=none is allowed only in insecure development mode")
}
if publicURL == "" {
log.Fatal("RMM_PUBLIC_URL is required when SMTP password recovery is enabled")
}
if !insecureDevMode && !strings.HasPrefix(strings.ToLower(publicURL), "https://") {
log.Fatal("RMM_PUBLIC_URL must use https when SMTP password recovery is enabled")
}
sender, smtpErr := httpapi.NewSMTPPasswordResetSender(httpapi.SMTPConfig{
Host: smtpHost,
Port: envInt("RMM_SMTP_PORT", 587, 1, 65535),
Username: strings.TrimSpace(os.Getenv("RMM_SMTP_USERNAME")),
Password: os.Getenv("RMM_SMTP_PASSWORD"),
From: strings.TrimSpace(os.Getenv("RMM_SMTP_FROM")),
TLSMode: tlsMode,
ServerName: strings.TrimSpace(os.Getenv("RMM_SMTP_SERVER_NAME")),
})
if smtpErr != nil {
log.Fatalf("invalid SMTP configuration: %v", smtpErr)
}
passwordResetSender = sender
alertEmailSender = sender
}
var telegramSender httpapi.NotificationSender
if telegramToken := strings.TrimSpace(os.Getenv("RMM_TELEGRAM_BOT_TOKEN")); telegramToken != "" {
sender, telegramErr := httpapi.NewTelegramNotificationSender(telegramToken)
if telegramErr != nil {
log.Fatalf("invalid Telegram configuration: %v", telegramErr)
}
telegramSender = sender
}
manifestURL := env("RMM_UPDATE_MANIFEST_URL", "https://benya9669.github.io/openwrt-rmm/update-manifest.json")
manifestSignatureURL := env("RMM_UPDATE_MANIFEST_SIGNATURE_URL", "https://benya9669.github.io/openwrt-rmm/update-manifest.sig")
stableAgentFallback := env("RMM_STABLE_AGENT_VERSION", stableAgentVersion)
var stableAgentVersionProvider func() string
var compatibleAgentFeed func(string, string, string) (string, string, string, bool)
var candidateAgentFeed func(string, string, string) (string, string, string, bool)
var historicalAgentFeed func(context.Context, string, string, string, string, string) (string, string, string, bool)
manifestPublicKey := strings.TrimSpace(os.Getenv("RMM_UPDATE_MANIFEST_PUBLIC_KEY"))
if manifestPublicKey != "" {
resolver, resolverErr := updateinfo.NewResolver(
manifestURL,
manifestSignatureURL,
manifestPublicKey,
stableAgentFallback,
)
if resolverErr != nil {
log.Fatalf("invalid update manifest configuration: %v", resolverErr)
}
go func() {
refreshContext, cancelRefresh := context.WithTimeout(context.Background(), 15*time.Second)
defer cancelRefresh()
if refreshErr := resolver.Refresh(refreshContext); refreshErr != nil {
log.Printf("using stable agent fallback %s: %v", stableAgentFallback, refreshErr)
}
}()
go resolver.Run(context.Background(), 15*time.Minute, func(refreshErr error) {
log.Printf("update manifest refresh failed: %v", refreshErr)
})
stableAgentVersionProvider = resolver.Version
compatibleAgentFeed = func(openWrtRelease, target, packageManager string) (string, string, string, bool) {
pkg, ok := resolver.CompatibleFeed(openWrtRelease, target, packageManager)
return resolver.TargetVersion(), pkg.FeedURL, pkg.PackageVersion, ok
}
historicalAgentFeed = func(ctx context.Context, historicalManifestURL, historicalSignatureURL, openWrtRelease, target, packageManager string) (string, string, string, bool) {
historical, err := updateinfo.NewChannelResolver(historicalManifestURL, historicalSignatureURL, manifestPublicKey, "", "stable")
if err != nil || historical.Refresh(ctx) != nil {
return "", "", "", false
}
pkg, ok := historical.CompatibleFeed(openWrtRelease, target, packageManager)
return historical.TargetVersion(), pkg.FeedURL, pkg.PackageVersion, ok
}
}
if candidateManifestURL := strings.TrimSpace(os.Getenv("RMM_CANDIDATE_UPDATE_MANIFEST_URL")); candidateManifestURL != "" {
candidateSignatureURL := strings.TrimSpace(os.Getenv("RMM_CANDIDATE_UPDATE_MANIFEST_SIGNATURE_URL"))
if manifestPublicKey == "" || candidateSignatureURL == "" {
log.Printf("candidate update manifest is unavailable: signature URL and public key are required")
} else {
candidate, candidateErr := updateinfo.NewChannelResolver(candidateManifestURL, candidateSignatureURL, manifestPublicKey, "", "candidate")
if candidateErr != nil {
log.Printf("candidate update manifest is unavailable: %v", candidateErr)
} else {
refreshContext, cancelRefresh := context.WithTimeout(context.Background(), 15*time.Second)
refreshErr := candidate.Refresh(refreshContext)
cancelRefresh()
if refreshErr != nil {
log.Printf("candidate update manifest is unavailable: %v", refreshErr)
} else {
candidateAgentFeed = func(openWrtRelease, target, packageManager string) (string, string, string, bool) {
if !candidate.Available() {
return "", "", "", false
}
pkg, ok := candidate.CompatibleFeed(openWrtRelease, target, packageManager)
return candidate.TargetVersion(), pkg.FeedURL, pkg.PackageVersion, ok
}
go candidate.Run(context.Background(), 15*time.Minute, func(refreshErr error) { log.Printf("candidate update manifest refresh failed: %v", refreshErr) })
}
}
}
}
st, err := store.OpenSQLite(context.Background(), dbPath)
if err != nil {
log.Fatal(err)
}
defer st.Close()
if err := runMaintenance(context.Background(), st); err != nil {
log.Printf("initial maintenance failed: %v", err)
}
go maintenanceLoop(st)
handler := httpapi.NewHandler(st, httpapi.Config{
EnrollmentToken: enrollmentToken,
AllowLegacyEnrollment: allowLegacyEnrollment,
AllowLegacyLuCIProxy: envBool("RMM_ALLOW_LEGACY_LUCI_PROXY", false),
OperatorToken: operatorToken,
OperatorUsername: env("RMM_OPERATOR_USERNAME", "admin"),
OperatorPassword: operatorPassword,
CookieSecure: envBool("RMM_COOKIE_SECURE", !insecureDevMode),
TunnelHTTPHost: env("RMM_TUNNEL_HTTP_HOST", "tunnel-ssh"),
TunnelPublicHost: strings.TrimSpace(os.Getenv("RMM_TUNNEL_PUBLIC_HOST")),
TunnelPublicPort: envInt("RMM_TUNNEL_PUBLIC_PORT", 2222, 1, 65535),
DeviceDomain: strings.TrimSpace(os.Getenv("RMM_DEVICE_DOMAIN")),
PublicScheme: env("RMM_PUBLIC_SCHEME", "https"),
PublicURL: publicURL,
PasswordResetSender: passwordResetSender,
AlertEmailSender: alertEmailSender,
TelegramSender: telegramSender,
BackgroundTasks: true,
NotificationMaxAttempts: envInt("RMM_NOTIFICATION_MAX_ATTEMPTS", 5, 1, 20),
StaticDir: env("RMM_WEB_DIR", "web"),
ServerVersion: serverVersion,
ServerRevision: serverRevision,
StableAgentVersion: stableAgentFallback,
StableAgentVersionProvider: stableAgentVersionProvider,
UpdateManifestURL: manifestURL,
CompatibleAgentFeed: compatibleAgentFeed,
CandidateAgentFeed: candidateAgentFeed,
HistoricalAgentFeed: historicalAgentFeed,
})
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 90 * time.Second,
MaxHeaderBytes: 1 << 20,
}
log.Printf("rmm server listening on %s", addr)
log.Fatal(srv.ListenAndServe())
}
func insecurePlaceholder(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
return strings.HasPrefix(value, "dev-") || strings.HasPrefix(value, "replace-with-") || strings.Contains(value, "change-me")
}
type maintenanceStore interface {
PurgeExpiredSecurityData(ctx context.Context) error
PurgeMetricSamplesBefore(ctx context.Context, cutoff time.Time) (int64, error)
PurgeNotificationDeliveriesBefore(ctx context.Context, cutoff time.Time) (int64, error)
}
func maintenanceLoop(st maintenanceStore) {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for range ticker.C {
if err := runMaintenance(context.Background(), st); err != nil {
log.Printf("scheduled maintenance failed: %v", err)
}
}
}
func runMaintenance(ctx context.Context, st maintenanceStore) error {
if err := st.PurgeExpiredSecurityData(ctx); err != nil {
return err
}
retentionDays := envInt("RMM_METRIC_RETENTION_DAYS", 30, 1, 3650)
deleted, err := st.PurgeMetricSamplesBefore(ctx, time.Now().UTC().Add(-time.Duration(retentionDays)*24*time.Hour))
if err != nil {
return err
}
if deleted > 0 {
log.Printf("maintenance removed %d expired metric samples", deleted)
}
notificationRetentionDays := envInt("RMM_NOTIFICATION_RETENTION_DAYS", 90, 1, 3650)
deleted, err = st.PurgeNotificationDeliveriesBefore(ctx, time.Now().UTC().Add(-time.Duration(notificationRetentionDays)*24*time.Hour))
if err != nil {
return err
}
if deleted > 0 {
log.Printf("maintenance removed %d expired notification deliveries", deleted)
}
return nil
}
func env(key, fallback string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
return value
}
func envBool(key string, fallback bool) bool {
switch strings.ToLower(env(key, "")) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return fallback
}
}
func envInt(key string, fallback, minimum, maximum int) int {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback
}
value, err := strconv.Atoi(raw)
if err != nil || value < minimum || value > maximum {
log.Print(fmt.Sprintf("invalid %s=%q; using %d", key, raw, fallback))
return fallback
}
return value
}