From 9558e4b10857b28bb4807fc10aa26d87e89318c3 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Mon, 25 May 2026 16:00:58 -0400 Subject: [PATCH 1/3] chore(git): harden .gitignore for binary exclusions (Mega P1 T-E3-03) --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index ebba9f96..23cac9f1 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,9 @@ node_modules/ bin/certs/ !src/templates/certs/**/*.key !src/templates/certs/**/*.pem + +# Binary exclusions (Mega P1 T-E3-03 hardening) +*.exe +*.dll +*.so +*.dylib From fbf038b9e39bb3d6611889454535823e21a561a1 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Mon, 25 May 2026 17:05:03 -0400 Subject: [PATCH 2/3] refactor(plugin): decompose 1424L manager.go god file into focused modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split internal/plugin/manager.go into six single-responsibility files: - installer.go: Install/Remove/Update + lock serialization + reverse-deps - download.go: downloadPlugin/extractTarGz/rollbackInstall - loader.go: ListInstalled/LoadManifestsFromDir/List + nginx route scan - security.go: checksum/Ed25519 signature/CRL/license/EOL/postInstallEvent - config.go: DisablePlugin/EnablePlugin/IsDisabled + table-conflict guards - paths.go: DefaultCacheDir/LicenseCacheDir/pingAPIURL/findPlugin wrappers manager.go reduced to type declarations only (<40L). All exported symbols preserved — no callers require changes. go build ./... passes clean. Ticket: T-E2-04 (Mega Phase 1, Epic E2) --- internal/plugin/config.go | 163 ++++ internal/plugin/download.go | 194 +++++ internal/plugin/installer.go | 464 +++++++++++ internal/plugin/loader.go | 259 +++++++ internal/plugin/manager.go | 1416 +--------------------------------- internal/plugin/paths.go | 83 ++ internal/plugin/security.go | 321 ++++++++ 7 files changed, 1498 insertions(+), 1402 deletions(-) create mode 100644 internal/plugin/config.go create mode 100644 internal/plugin/download.go create mode 100644 internal/plugin/installer.go create mode 100644 internal/plugin/loader.go create mode 100644 internal/plugin/paths.go create mode 100644 internal/plugin/security.go diff --git a/internal/plugin/config.go b/internal/plugin/config.go new file mode 100644 index 00000000..c1c8f221 --- /dev/null +++ b/internal/plugin/config.go @@ -0,0 +1,163 @@ +package plugin + +// Purpose: Plugin enable/disable state and table-namespace conflict detection. +// DisablePlugin/EnablePlugin toggle a .disabled marker file; table +// helpers prevent two plugins from claiming the same DB namespace. +// Inputs: plugin name string; pluginDir string; PluginManifest for new plugin. +// Outputs: error on conflict, missing plugin, or filesystem failure; nil on success. +// Constraints: .disabled marker is a zero-byte file in the plugin's directory. +// Table prefix is the first two underscore-separated segments +// (e.g. "np_chat_messages" → "np_chat_"). Prefix conflicts block +// install; exact-name conflicts also block install. +// SPORT: plugin-config; callers: cmd/plugin/disable.go, cmd/plugin/enable.go, +// installLocked in installer.go + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// DisablePlugin creates a .disabled marker file in the plugin's directory, +// causing it to be excluded from compose files on the next build. +func DisablePlugin(name, pluginDir string) error { + destDir := filepath.Join(pluginDir, name) + if _, err := os.Stat(destDir); os.IsNotExist(err) { + return fmt.Errorf("plugin %q is not installed", name) + } + + markerPath := filepath.Join(destDir, ".disabled") + if _, err := os.Stat(markerPath); err == nil { + return fmt.Errorf("plugin %q is already disabled", name) + } + + f, err := os.Create(markerPath) + if err != nil { + return fmt.Errorf("creating disable marker: %w", err) + } + f.Close() + return nil +} + +// EnablePlugin removes the .disabled marker file from the plugin's directory, +// allowing it to be included in compose files on the next build. +func EnablePlugin(name, pluginDir string) error { + destDir := filepath.Join(pluginDir, name) + if _, err := os.Stat(destDir); os.IsNotExist(err) { + return fmt.Errorf("plugin %q is not installed", name) + } + + markerPath := filepath.Join(destDir, ".disabled") + if _, err := os.Stat(markerPath); os.IsNotExist(err) { + return fmt.Errorf("plugin %q is not disabled", name) + } + + if err := os.Remove(markerPath); err != nil { + return fmt.Errorf("removing disable marker: %w", err) + } + return nil +} + +// IsDisabled returns true if the named plugin has a .disabled marker file. +func IsDisabled(name, pluginDir string) bool { + markerPath := filepath.Join(pluginDir, name, ".disabled") + _, err := os.Stat(markerPath) + return err == nil +} + +// checkTablePrefixConflict scans all installed plugins in pluginDir and +// returns an error if any of them share a table prefix with the tables listed +// in newTables. Table prefixes are derived from table names by taking the +// first two underscore-separated segments followed by a trailing underscore +// (e.g. "np_chat_messages" → prefix "np_chat_"). The newPluginName parameter +// is used to skip the plugin being installed (allowing reinstalls/updates). +func checkTablePrefixConflict(pluginDir, newPluginName string, newTables []string) error { + if len(newTables) == 0 { + return nil + } + + newPrefixes := make(map[string]bool) + for _, table := range newTables { + if p := tablePrefix(table); p != "" { + newPrefixes[p] = true + } + } + if len(newPrefixes) == 0 { + return nil + } + + entries, err := os.ReadDir(pluginDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("reading plugin directory: %w", err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if strings.EqualFold(entry.Name(), newPluginName) { + continue + } + manifestPath := filepath.Join(pluginDir, entry.Name(), "plugin.json") + m, err := parseManifest(manifestPath) + if err != nil { + continue + } + for _, table := range m.Tables { + p := tablePrefix(table) + if p != "" && newPrefixes[p] { + return fmt.Errorf("plugin %s conflicts with installed plugin %s: table prefix %q already claimed", + newPluginName, m.Name, p) + } + } + } + return nil +} + +// checkTableConflicts scans all installed plugins in pluginDir and returns an +// error if any of them declare a table name that also appears in newPlugin's +// Tables list. This catches exact name collisions (the prefix check above +// catches broader namespace conflicts). +func checkTableConflicts(pluginDir string, newPlugin *PluginManifest) error { + if len(newPlugin.Tables) == 0 { + return nil + } + + entries, err := os.ReadDir(pluginDir) + if err != nil { + return nil + } + + for _, entry := range entries { + if !entry.IsDir() || entry.Name() == newPlugin.Name { + continue + } + existing, err := parseManifest(filepath.Join(pluginDir, entry.Name(), "plugin.json")) + if err != nil { + continue + } + for _, newTable := range newPlugin.Tables { + for _, existingTable := range existing.Tables { + if newTable == existingTable { + return fmt.Errorf("table %q already used by plugin %q", newTable, existing.Name) + } + } + } + } + return nil +} + +// tablePrefix extracts the two-segment prefix from a table name. Given +// "np_chat_messages" it returns "np_chat_". Returns empty string for table +// names that do not have at least two underscore-separated segments. +func tablePrefix(table string) string { + parts := strings.Split(table, "_") + if len(parts) < 2 { + return "" + } + return parts[0] + "_" + parts[1] + "_" +} diff --git a/internal/plugin/download.go b/internal/plugin/download.go new file mode 100644 index 00000000..7cb6e411 --- /dev/null +++ b/internal/plugin/download.go @@ -0,0 +1,194 @@ +package plugin + +// Purpose: Plugin archive download, extraction, and install rollback helpers. +// Inputs: context.Context, plugin name/version/repository strings, archive file path. +// Outputs: string temp file path on download; error on extraction or rollback failure. +// Constraints: Free plugins use plugins.nself.org R2 worker with GitHub Releases fallback +// (S67-T03). Tar extraction enforces path safety (no symlinks, no traversal, +// no setuid/setgid). Rollback is best-effort; errors are logged, not returned. +// SPORT: download/extract pipeline; callers: installLocked in installer.go + +import ( + "archive/tar" + "compress/gzip" + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/nself-org/cli/internal/config" +) + +// downloadPlugin fetches the plugin tarball to a temporary file. +// For free plugins, it tries the R2-backed worker URL first and falls back to +// GitHub Releases on 5xx responses (S67-T03). +func downloadPlugin(ctx context.Context, name, version, repository string) (string, error) { + primaryURL := buildDownloadURL(name, version, repository) + tmp, err := downloadFromURL(ctx, primaryURL) + if err == nil { + return tmp, nil + } + + // If not a paid plugin, attempt GitHub Releases fallback on primary failure. + if !isPaidPlugin(name) { + fallbackURL := buildFallbackDownloadURL(name, version, repository) + if fallbackURL != primaryURL { + tmp2, fallbackErr := downloadFromURL(ctx, fallbackURL) + if fallbackErr == nil { + return tmp2, nil + } + return "", fmt.Errorf("download failed: primary %s: %w; fallback %s: %v", primaryURL, err, fallbackURL, fallbackErr) + } + } + + return "", err +} + +// downloadFromURL fetches a single URL to a temp file and returns the file path. +func downloadFromURL(ctx context.Context, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("creating download request: %w", err) + } + req.Header.Set("User-Agent", "nself-cli") + + client := &http.Client{Timeout: 5 * time.Minute} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("HTTP GET %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP GET %s: status %d", url, resp.StatusCode) + } + + tmp, err := os.CreateTemp("", "nself-plugin-*.tar.gz") + if err != nil { + return "", fmt.Errorf("creating temp file: %w", err) + } + defer tmp.Close() + + if _, err := io.Copy(tmp, resp.Body); err != nil { + os.Remove(tmp.Name()) + return "", fmt.Errorf("writing download to temp file: %w", err) + } + + return tmp.Name(), nil +} + +// buildDownloadURL constructs the tarball URL for a plugin. Pro plugins use +// the ping API download endpoint; free plugins use the plugins.nself.org worker +// which 302-redirects to R2 (primary) with GitHub Releases as fallback. +func buildDownloadURL(name, version, repository string) string { + if isPaidPlugin(name) { + base := pingAPIURL() + return fmt.Sprintf("%s/plugins/%s/download", base, name) + } + // Free plugins: S67-T03 — use plugins.nself.org worker tarball endpoint. + // The worker 302-redirects to R2 (primary CDN, free egress) or falls back + // to GitHub Releases on R2 5xx. Override via NSELF_PLUGIN_REGISTRY env var. + base := "https://plugins.nself.org" + if envURL := os.Getenv("NSELF_PLUGIN_REGISTRY"); envURL != "" { + base = strings.TrimRight(envURL, "/") + } + return fmt.Sprintf("%s/plugins/%s/tarball", base, name) +} + +// buildFallbackDownloadURL constructs the GitHub Releases fallback URL for a +// free plugin. Used when the primary R2/worker download fails. +func buildFallbackDownloadURL(name, version, repository string) string { + if repository != "" { + repo := strings.TrimSuffix(repository, ".git") + return fmt.Sprintf("%s/releases/download/v%s/%s-v%s.tar.gz", repo, version, name, version) + } + return fmt.Sprintf("https://github.com/nself-org/plugins/releases/download/v%s/%s-v%s.tar.gz", version, name, version) +} + +// extractTarGz extracts a gzipped tarball into destDir. +func extractTarGz(archivePath, destDir string) error { + if err := os.MkdirAll(destDir, 0o755); err != nil { + return fmt.Errorf("creating destination directory: %w", err) + } + + f, err := os.Open(archivePath) + if err != nil { + return fmt.Errorf("opening archive: %w", err) + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("gzip reader: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("reading tar entry: %w", err) + } + + // Tar slip prevention: reject absolute paths and directory traversal. + if filepath.IsAbs(hdr.Name) { + return fmt.Errorf("unsafe tar entry: absolute path %q", hdr.Name) + } + cleanName := filepath.Clean(hdr.Name) + if strings.HasPrefix(cleanName, "..") { + return fmt.Errorf("unsafe tar entry: directory traversal %q", hdr.Name) + } + target := filepath.Join(destDir, cleanName) + // Final safety check: verify resolved target stays within destDir. + if !strings.HasPrefix(target, destDir+string(filepath.Separator)) && target != destDir { + return fmt.Errorf("tar slip detected: %q escapes destination directory", hdr.Name) + } + + // S-014: Strip setuid, setgid, and sticky bits from tar entries + // to prevent privilege escalation from malicious plugin archives. + mode := os.FileMode(hdr.Mode) &^ (os.ModeSetuid | os.ModeSetgid | os.ModeSticky) + + switch hdr.Typeflag { + case tar.TypeSymlink, tar.TypeLink: + return fmt.Errorf("unsafe tar entry: symlinks not allowed (%q → %q)", hdr.Name, hdr.Linkname) + case tar.TypeDir: + if err := os.MkdirAll(target, mode); err != nil { + return fmt.Errorf("creating directory %s: %w", target, err) + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("creating parent directory for %s: %w", target, err) + } + outFile, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return fmt.Errorf("creating file %s: %w", target, err) + } + if _, err := io.Copy(outFile, tr); err != nil { + outFile.Close() + return fmt.Errorf("writing file %s: %w", target, err) + } + outFile.Close() + } + } + + return nil +} + +// rollbackInstall cleans up a partially installed plugin by removing the +// extracted directory and dropping the database schema. Errors during +// rollback are logged but not returned. +func rollbackInstall(ctx context.Context, cfg *config.Config, name string, destDir string) { + if err := os.RemoveAll(destDir); err != nil { + fmt.Fprintf(os.Stderr, "warning: rollback cleanup failed for %s: %v\n", destDir, err) + } + if err := dropPluginSchema(ctx, cfg, name); err != nil { + fmt.Fprintf(os.Stderr, "warning: rollback schema drop failed for %s: %v\n", name, err) + } +} diff --git a/internal/plugin/installer.go b/internal/plugin/installer.go new file mode 100644 index 00000000..edeeab20 --- /dev/null +++ b/internal/plugin/installer.go @@ -0,0 +1,464 @@ +package plugin + +// Purpose: Plugin install, remove, and update operations with file-lock serialization, +// dependency resolution, rollback on failure, and license/security pre-checks. +// Inputs: context.Context, *config.Config, plugin name string, pluginDir path string. +// Outputs: error on failure; side effects are plugin directory and database schema changes. +// Constraints: Acquires {pluginDir}/.install.lock (O_CREATE|O_EXCL) to prevent concurrent +// installs. Calls installLocked for dependency recursion to avoid deadlock. +// SPORT: install/remove/update operations; callers: cmd/plugin/install.go, cmd/plugin/remove.go + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + "github.com/nself-org/cli/internal/config" + "github.com/nself-org/cli/internal/errs" + "github.com/nself-org/cli/internal/plugin/verify" + "github.com/nself-org/cli/internal/version" +) + +// dangerousPermissions lists permission strings that warrant a visible warning +// on install. system:exec and network:internet are the two highest-risk grants. +// S71-T02. +var dangerousPermissions = map[string]bool{ + "system:exec": true, + "network:internet": true, +} + +// installLockPath returns the path to the install lock file for pluginDir. +func installLockPath(pluginDir string) string { + return filepath.Join(pluginDir, ".install.lock") +} + +// acquireInstallLock attempts to create an exclusive lock file at +// {pluginDir}/.install.lock using O_CREATE|O_EXCL (atomic on POSIX). It polls +// every 250 ms until the lock is acquired or the 5-second timeout elapses. +// Returns the open lock file on success so the caller can defer its cleanup. +func acquireInstallLock(pluginDir string) (*os.File, error) { + // Ensure the plugin directory exists so the lock file can be created. + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + return nil, fmt.Errorf("creating plugin directory: %w", err) + } + + lockPath := installLockPath(pluginDir) + deadline := time.Now().Add(5 * time.Second) + + for { + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err == nil { + // Lock acquired — write our PID for diagnostic purposes. + fmt.Fprintf(f, "%d\n", os.Getpid()) + return f, nil + } + if !os.IsExist(err) { + // Unexpected error (permissions, etc.). + return nil, fmt.Errorf("acquiring install lock: %w", err) + } + // Lock file already exists — check timeout before polling again. + if time.Now().After(deadline) { + return nil, fmt.Errorf("another plugin install is already running") + } + time.Sleep(250 * time.Millisecond) + } +} + +// releaseInstallLock closes and removes the lock file returned by +// acquireInstallLock. Errors are silently ignored; a stale lock file will be +// cleaned up on the next acquire attempt. +func releaseInstallLock(f *os.File, pluginDir string) { + f.Close() + os.Remove(installLockPath(pluginDir)) +} + +// Install downloads, extracts, and configures a plugin. For paid plugins it +// checks the license first. Dependencies are resolved and recursively +// installed before the target plugin. If any step after extraction fails, the +// extracted directory and database schema are rolled back. +// +// A file lock on {pluginDir}/.install.lock is held for the duration of the +// operation to prevent concurrent installs from corrupting plugin state. +func Install(ctx context.Context, cfg *config.Config, name string, pluginDir string) error { + lock, err := acquireInstallLock(pluginDir) + if err != nil { + return err + } + defer releaseInstallLock(lock, pluginDir) + + return installLocked(ctx, cfg, name, pluginDir) +} + +// installLocked performs the actual install work after the caller has already +// acquired the install lock. Dependency installs call this directly to avoid +// attempting to re-acquire the lock (which would deadlock). +func installLocked(ctx context.Context, cfg *config.Config, name string, pluginDir string) error { + // Step 1: License check for paid plugins. + if isPaidPlugin(name) { + if err := checkLicense(ctx, name); err != nil { + return err + } + } + + // Step 2: Fetch registry and locate the plugin. + cacheDir := defaultCacheDir() + reg, err := FetchRegistry(ctx, "", cacheDir) + if err != nil { + return fmt.Errorf("fetching registry: %w", err) + } + + manifest, found := findPlugin(reg, name) + if !found { + return errs.ErrPluginNotFound + } + + // Status check: lifecycle policy enforcement (S58-T01, S58-T02, S58-T03). + // "stable" and "" (legacy, no status field) proceed silently. + switch manifest.PublishStatus { + case "planned": + return fmt.Errorf( + "plugin %q is not yet available — coming soon.\nSee https://nself.org/plugins/%s for the release timeline.\nRun 'nself plugin list' to see available plugins.", + name, name, + ) + case "experimental": + fmt.Fprintf(os.Stderr, "warning: %s is experimental — API and behavior may change without notice\n", name) + case "beta": + fmt.Fprintf(os.Stderr, "warning: %s is in beta — use in production at your own risk\n", name) + case "deprecated": + d := manifest.Deprecation + if d != nil { + fmt.Fprintf(os.Stderr, "warning: %s is deprecated (EOL: %s). %s\n", name, d.EOLDate, d.MigrationGuide) + if d.ReplacedBy != "" { + fmt.Fprintf(os.Stderr, " A replacement is available: %s\n", d.ReplacedBy) + fmt.Fprintf(os.Stderr, " Install the replacement instead? Run: nself plugin install %s\n", d.ReplacedBy) + } + } else { + fmt.Fprintf(os.Stderr, "warning: %s is deprecated\n", name) + } + case "eol": + // EOL blocking is enforced at the command layer via --allow-eol. + // By the time we reach installLocked the caller has already verified + // the flag; emit a prominent warning so the risk acknowledgment is + // logged even in non-interactive contexts. + fmt.Fprintf(os.Stderr, "warning: %s has reached end-of-life and is no longer maintained\n", name) + } + + // S58-T09: Author CRL check. Blocks install if the plugin's declared author + // key appears in the revocation list at plugins.nself.org. Network errors + // are non-fatal (logged to stderr) so installs are not bricked by a + // transient CRL outage. + if err := checkAuthorRevocation(ctx, manifest.Author); err != nil { + return err + } + + // Compat check: verify CLI version satisfies the plugin's declared range. + if err := CheckCLICompat(manifest.Compat, version.GetVersion()); err != nil { + return fmt.Errorf("compatibility check failed for %q: %w", name, err) + } + + // T21: Check for table prefix conflicts with already-installed plugins. + if err := checkTablePrefixConflict(pluginDir, name, manifest.Tables); err != nil { + return err + } + + // Check for exact table name conflicts with already-installed plugins. + if err := checkTableConflicts(pluginDir, manifest); err != nil { + return err + } + + // T22: Check for nginx route conflicts with already-installed plugins. + existingRoutes := collectInstalledPluginRoutes(pluginDir, name) + if err := checkPluginRouteConflict(manifest, existingRoutes); err != nil { + return err + } + + // T23: Warn if this install would downgrade an already-installed plugin. + existingManifest, err := parseManifest(filepath.Join(pluginDir, name, "plugin.json")) + if err == nil { + // Plugin exists — check for downgrade + if compareSemver(existingManifest.Version, manifest.Version) > 0 { + fmt.Fprintf(os.Stderr, "⚠ Downgrading %s from %s to %s — this may cause data loss\n", + name, existingManifest.Version, manifest.Version) + } + } + + // S43-T17: Validate inter-plugin communication contract (Consumes). + // Every plugin listed in manifest.Consumes must be installed (or will be + // installed as a dependency below). A plugin declaring X in Consumes will + // send X-Source-Plugin: requests to X's HTTP API; if X is not + // installed there is no service to receive those calls. + if err := validateConsumes(pluginDir, name, manifest.Consumes); err != nil { + return err + } + + // Step 3: Resolve dependencies. The resolver reads manifests from + // pluginDir, so already-installed plugins are picked up automatically. + // For plugins not yet installed we install them first, which places + // their manifest on disk for the resolver. We call installLocked here + // (not Install) because the lock is already held by this goroutine. + deps := manifest.Dependencies + if len(deps) > 0 { + fmt.Fprintf(os.Stderr, "Installing %s (requires: %s)\n", name, strings.Join(deps, ", ")) + } + for _, dep := range deps { + depDir := filepath.Join(pluginDir, dep) + if _, err := os.Stat(depDir); err == nil { + fmt.Fprintf(os.Stderr, " ✓ %s (already installed)\n", dep) + continue // dependency already installed + } + fmt.Fprintf(os.Stderr, " → Installing dependency %s...\n", dep) + if err := installLocked(ctx, cfg, dep, pluginDir); err != nil { + return fmt.Errorf("installing dependency %q: %w", dep, err) + } + fmt.Fprintf(os.Stderr, " ✓ %s installed\n", dep) + } + + // Step 4: Download the plugin archive. + archivePath, err := downloadPlugin(ctx, name, manifest.Version, manifest.Repository) + if err != nil { + return fmt.Errorf("downloading plugin %q: %w", name, err) + } + defer os.Remove(archivePath) + + // Step 5: Verify checksum before extraction. + // For stable plugins a missing checksum is a hard error (V06-F2). + // For non-stable plugins an absent checksum emits a warning and continues. + if manifest.Checksum != "" { + if err := verifyChecksum(archivePath, manifest.Checksum, manifest.PublishStatus); err != nil { + os.Remove(archivePath) + return fmt.Errorf("checksum verification for plugin %q: %w", name, err) + } + } else { + if err := verifyChecksum(archivePath, "", manifest.PublishStatus); err != nil { + // stable plugin — hard fail returned by verifyChecksum + os.Remove(archivePath) + return fmt.Errorf("checksum verification for plugin %q: %w", name, err) + } + fmt.Fprintf(os.Stderr, "warning: no checksum in registry for plugin %q, skipping verification\n", name) + } + + // Step 5b: Verify Ed25519 signature (T09 — Security-Always-Free). + // The signature is computed over the raw SHA-256 digest of the tarball. + // Public key is pinned in the registry; never fetched at verify time (TOCTOU). + // For stable plugins a missing signature is a hard error (V06-F2). + // Skip requires BOTH NSELF_LICENSE_SKIP_VERIFY=1 AND NSELF_LICENSE_SKIP_VERIFY_FORCE=1. + // Either var alone is insufficient — standalone skip is not permitted (matches license/validate.go). + if os.Getenv("NSELF_LICENSE_SKIP_VERIFY") == "1" { + if os.Getenv("NSELF_LICENSE_SKIP_VERIFY_FORCE") != "1" { + os.Remove(archivePath) + return fmt.Errorf("NSELF_LICENSE_SKIP_VERIFY requires NSELF_LICENSE_SKIP_VERIFY_FORCE=1; standalone skip is not permitted") + } + fmt.Fprintf(os.Stderr, "WARNING: plugin signature verification skipped (NSELF_LICENSE_SKIP_VERIFY=1 + FORCE)\n") + } else { + if err := verifyPluginSignature(archivePath, manifest.AuthorPublicKey, manifest.Signature, manifest.PublishStatus); err != nil { + os.Remove(archivePath) + return fmt.Errorf("signature verification for plugin %q: %w", name, err) + } + } + + // Step 5c: Verify SBOM (S2.T12). Downloads sbom-{version}.cdx.json from the + // GitHub Release and validates CycloneDX schema. 404 = pre-SBOM release (warn, + // don't fail). Skip via --skip-sbom-check for air-gapped installs only. + sbomSkip := os.Getenv("NSELF_SKIP_SBOM_CHECK") == "1" + if err := verify.VerifySBOM(ctx, name, manifest.Version, verify.SBOMCheckOptions{ + SkipCheck: sbomSkip, + Version: manifest.Version, + }); err != nil { + os.Remove(archivePath) + return fmt.Errorf("sbom verification for plugin %q: %w", name, err) + } + + // Step 6: Extract to pluginDir/{name}/. + destDir := filepath.Join(pluginDir, name) + if err := extractTarGz(archivePath, destDir); err != nil { + return fmt.Errorf("extracting plugin %q: %w", name, err) + } + + // Step 7: Create database schema. On failure, rollback extraction. + if err := createPluginSchema(ctx, cfg, name); err != nil { + rollbackInstall(ctx, cfg, name, destDir) + return fmt.Errorf("creating schema for plugin %q: %w", name, err) + } + + // Step 7b (Q01): Generate per-plugin Ed25519 identity keypair and register + // the public key with ping_api. This is a best-effort step — a failure is + // logged as a warning but does not roll back the install, because the plugin + // will still function without JWT auth until Phase B-3 strict mode. + // The key is only generated when PLUGIN_INTERNAL_SECRET is set (i.e., the + // operator has opted into the inter-plugin JWT system). + if os.Getenv("PLUGIN_INTERNAL_SECRET") != "" { + // pluginDir doubles as the identity data root — each plugin's keypair + // is stored at pluginDir//identity.key alongside its manifest. + if !IdentityKeyExists(pluginDir, name) { + pubKey, idErr := GenerateEd25519Keypair(pluginDir, name) + if idErr != nil { + slog.Warn("plugin identity key generation failed — inter-plugin JWT auth unavailable until resolved", + "plugin", name, "error", idErr) + } else { + if regErr := RegisterIdentity(ctx, name, pubKey); regErr != nil { + slog.Warn("plugin identity registration with ping_api failed — JWT auth unavailable until resolved", + "plugin", name, "error", regErr) + } else { + slog.Info("plugin.identity.registered", "plugin", name) + } + } + } + } + + // S71-T02: Emit structured audit log for the granted permission set. + // One line per install, consumable by Loki. Never logs secret values — + // only the permission strings declared in the manifest. + slog.Info("plugin.install.permissions", + "plugin", name, + "version", manifest.Version, + "permissions", manifest.Permissions, + ) + + // S71-T02: Warn via doctor when dangerous permissions are present. + logDangerousPermissions(name, manifest.Permissions) + + fmt.Fprintf(os.Stderr, "\nℹ Run 'nself build' to include %s in your stack.\n", name) + + // S68-T02: Fire-and-forget install-event to plugins.nself.org registry. + // Silent, 1s timeout, never blocks the install. Sends only an opaque + // SHA-256 hash of the machine fingerprint — no PII in the payload. + go postInstallEvent(name) + + return nil +} + +// logDangerousPermissions emits a stderr warning for any dangerous permissions +// held by the named plugin. Called immediately after schema creation so the +// warning appears before the "Run nself build" footer line. S71-T02. +func logDangerousPermissions(pluginName string, permissions []string) { + for _, perm := range permissions { + if dangerousPermissions[perm] { + fmt.Fprintf(os.Stderr, + "warning: plugin %q holds elevated permission %q — review with 'nself plugin info %s'\n", + pluginName, perm, pluginName, + ) + } + } +} + +// checkReverseDependencies scans all installed plugins in pluginDir and returns +// the names of any that declare name as a dependency. This prevents silently +// breaking dependent plugins during uninstall. +func checkReverseDependencies(pluginDir, name string) ([]string, error) { + entries, err := os.ReadDir(pluginDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading plugin directory: %w", err) + } + + var dependents []string + for _, entry := range entries { + if !entry.IsDir() || entry.Name() == name { + continue + } + manifestPath := filepath.Join(pluginDir, entry.Name(), "plugin.json") + m, err := parseManifest(manifestPath) + if err != nil { + continue // skip directories without valid manifests + } + for _, dep := range m.Dependencies { + if strings.EqualFold(dep, name) { + dependents = append(dependents, m.Name) + break + } + } + } + return dependents, nil +} + +// Remove stops a plugin (if running), optionally drops its database schema, +// and removes its directory from disk. When force is false and other installed +// plugins depend on the target, Remove returns an error listing them. +// +// A file lock on {pluginDir}/.install.lock is held for the duration of the +// operation so that concurrent install and remove calls serialize correctly. +func Remove(ctx context.Context, cfg *config.Config, name string, pluginDir string, keepData bool, force bool) error { + lock, err := acquireInstallLock(pluginDir) + if err != nil { + return err + } + defer releaseInstallLock(lock, pluginDir) + + destDir := filepath.Join(pluginDir, name) + if _, err := os.Stat(destDir); os.IsNotExist(err) { + return fmt.Errorf("plugin %q is not installed", name) + } + + // Check for reverse dependencies before doing anything destructive. + if !force { + dependents, err := checkReverseDependencies(pluginDir, name) + if err != nil { + return fmt.Errorf("checking reverse dependencies: %w", err) + } + if len(dependents) > 0 { + return fmt.Errorf("plugin %s depends on %q. Use --force to remove anyway", + strings.Join(dependents, ", "), name) + } + } + + // Stop if running. + st, err := Status(name) + if err == nil && st.State == "running" { + if stopErr := Stop(ctx, name); stopErr != nil { + return fmt.Errorf("stopping plugin %q: %w", name, stopErr) + } + } + + // Drop schema unless the caller wants to keep data. + if !keepData { + if err := dropPluginSchema(ctx, cfg, name); err != nil { + return fmt.Errorf("dropping schema for plugin %q: %w", name, err) + } + } + + // Remove plugin directory. + if err := os.RemoveAll(destDir); err != nil { + return fmt.Errorf("removing plugin directory %q: %w", destDir, err) + } + + fmt.Fprintf(os.Stderr, "\nℹ Run 'nself build' to update your stack.\n") + return nil +} + +// Update backs up the current installation, then reinstalls from the registry. +// If the new install fails, the previous version is restored automatically. +func Update(ctx context.Context, cfg *config.Config, name string, pluginDir string) error { + currentDir := filepath.Join(pluginDir, name) + backupDir := filepath.Join(pluginDir, name+".prev") + + // Rename current install to .prev so we can restore on failure. + if _, err := os.Stat(currentDir); err == nil { + // Remove any stale backup from a prior interrupted update. + _ = os.RemoveAll(backupDir) + if err := os.Rename(currentDir, backupDir); err != nil { + return fmt.Errorf("backing up plugin %q for update: %w", name, err) + } + } + + // Install the new version. + if err := Install(ctx, cfg, name, pluginDir); err != nil { + // Restore the previous version from backup. + if _, statErr := os.Stat(backupDir); statErr == nil { + _ = os.RemoveAll(currentDir) // clean partial install if any + if renameErr := os.Rename(backupDir, currentDir); renameErr != nil { + fmt.Fprintf(os.Stderr, "warning: failed to restore previous version of %q: %v\n", name, renameErr) + } + } + return fmt.Errorf("installing updated plugin %q: %w", name, err) + } + + // Success: remove the backup. + _ = os.RemoveAll(backupDir) + return nil +} diff --git a/internal/plugin/loader.go b/internal/plugin/loader.go new file mode 100644 index 00000000..c097d23d --- /dev/null +++ b/internal/plugin/loader.go @@ -0,0 +1,259 @@ +package plugin + +// Purpose: Plugin discovery and listing — scans pluginDir for installed plugins +// and merges with registry data to produce PluginInfo/InstalledPluginInfo lists. +// Inputs: pluginDir string (absolute path to plugin installation directory). +// Outputs: []PluginInfo or []InstalledPluginInfo; error on directory read failure. +// Constraints: Registry fetch uses a 30s timeout; pluginDir non-existence returns nil, nil. +// Directories without a valid plugin.json are silently skipped. +// SPORT: list/inventory operations; callers: cmd/plugin/list.go, cmd/plugin/inventory.go + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/nself-org/cli/internal/nginx" +) + +// ListInstalled scans pluginDir and returns detailed information for every +// installed plugin. Each entry's Status is "running", "installed", or +// "unknown" depending on the plugin's current runtime state. +func ListInstalled(pluginDir string) ([]InstalledPluginInfo, error) { + entries, err := os.ReadDir(pluginDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading plugin directory: %w", err) + } + + var plugins []InstalledPluginInfo + for _, entry := range entries { + if !entry.IsDir() { + continue + } + manifestPath := filepath.Join(pluginDir, entry.Name(), "plugin.json") + m, err := parseManifest(manifestPath) + if err != nil { + continue // skip directories without valid manifests + } + + status := "installed" + if st, stErr := Status(m.Name); stErr == nil { + status = st.State + } + // Overlay lifecycle state: dormant/expired beats runtime state. + if store, lcErr := LoadLifecycleStore(); lcErr == nil { + if rec, ok := store.Records[m.Name]; ok { + switch rec.State { + case StateDormant: + status = "dormant" + case StateExpired: + status = "expired" + } + } + } + + tier := m.Tier + if tier == "" { + if m.RequiresLicense || m.LicenseType == "pro" { + tier = "pro" + } else { + tier = "free" + } + } + + plugins = append(plugins, InstalledPluginInfo{ + Name: m.Name, + Version: m.Version, + Tier: tier, + Status: status, + Description: m.Description, + }) + } + + return plugins, nil +} + +// LoadManifestsFromDir scans pluginDir and returns the full PluginManifest for +// every installed plugin. It is the full-manifest counterpart to ListInstalled, +// used by features that need manifest fields beyond name/version/tier/status +// (e.g., federation's GraphQL block). Directories without a valid plugin.json +// are silently skipped. If pluginDir does not exist, nil is returned. +func LoadManifestsFromDir(pluginDir string) ([]*PluginManifest, error) { + entries, err := os.ReadDir(pluginDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading plugin directory: %w", err) + } + var manifests []*PluginManifest + for _, entry := range entries { + if !entry.IsDir() { + continue + } + manifestPath := filepath.Join(pluginDir, entry.Name(), "plugin.json") + m, err := parseManifest(manifestPath) + if err != nil { + continue // skip directories without a valid manifest + } + manifests = append(manifests, m) + } + return manifests, nil +} + +// List returns plugin information. When installed is true it scans pluginDir +// for locally installed plugins. When false it returns all plugins known to +// the registry. +func List(pluginDir string, installed bool) ([]PluginInfo, error) { + if installed { + return listInstalled(pluginDir) + } + return listFromRegistry(pluginDir) +} + +// listInstalled scans pluginDir for subdirectories that contain a valid +// plugin.json manifest and returns PluginInfo for each. +func listInstalled(pluginDir string) ([]PluginInfo, error) { + entries, err := os.ReadDir(pluginDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading plugin directory: %w", err) + } + + var plugins []PluginInfo + for _, entry := range entries { + if !entry.IsDir() { + continue + } + manifestPath := filepath.Join(pluginDir, entry.Name(), "plugin.json") + m, err := parseManifest(manifestPath) + if err != nil { + continue // skip directories without valid manifests + } + + running := false + if st, err := Status(m.Name); err == nil && st.State == "running" { + running = true + } + + plugins = append(plugins, PluginInfo{ + Name: m.Name, + Version: m.Version, + Category: m.Category, + Installed: true, + Running: running, + }) + } + + return plugins, nil +} + +// listFromRegistry fetches the registry and returns PluginInfo for every +// known plugin, marking those already installed locally. +func listFromRegistry(pluginDir string) ([]PluginInfo, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cacheDir := defaultCacheDir() + reg, err := FetchRegistry(ctx, "", cacheDir) + if err != nil { + return nil, fmt.Errorf("fetching registry: %w", err) + } + + // Build a set of installed plugin names for quick lookup. + installedSet := make(map[string]bool) + if entries, dirErr := os.ReadDir(pluginDir); dirErr == nil { + for _, entry := range entries { + if entry.IsDir() { + installedSet[entry.Name()] = true + } + } + } + + plugins := make([]PluginInfo, 0, len(reg.Plugins)) + for _, m := range reg.Plugins { + installed := installedSet[m.Name] + running := false + if installed { + if st, err := Status(m.Name); err == nil && st.State == "running" { + running = true + } + } + plugins = append(plugins, PluginInfo{ + Name: m.Name, + Version: m.Version, + Category: m.Category, + Installed: installed, + Running: running, + PublishStatus: m.PublishStatus, + }) + } + + return plugins, nil +} + +// baseServiceRoutes lists the always-on nSelf service routes that are present +// on any clean init. Plugins must not claim these paths — they are owned by +// core infrastructure and seeded into the conflict-detection set before any +// plugin routes are evaluated. +var baseServiceRoutes = []nginx.NginxRoute{ + {ServerName: "api", Location: "/", PluginName: "hasura"}, + {ServerName: "auth", Location: "/", PluginName: "auth"}, + {ServerName: "storage", Location: "/", PluginName: "storage"}, +} + +// collectInstalledPluginRoutes scans pluginDir and returns the nginx routes +// declared by all installed plugins except the one named skipPlugin (the plugin +// being installed, so reinstalls are permitted). Base service routes (Hasura, +// Auth, Storage) are seeded first so plugins that claim those paths are +// rejected with a clear conflict message naming the base service as owner. +func collectInstalledPluginRoutes(pluginDir, skipPlugin string) []nginx.NginxRoute { + // Seed with always-on base service routes. This prevents false-positive + // "clean install succeeded" for plugins that try to claim /api, /auth, or + // /storage — and produces a clear "claimed by hasura/auth/storage" message. + routes := make([]nginx.NginxRoute, len(baseServiceRoutes)) + copy(routes, baseServiceRoutes) + + entries, err := os.ReadDir(pluginDir) + if err != nil { + return routes + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if strings.EqualFold(entry.Name(), skipPlugin) { + continue + } + manifestPath := filepath.Join(pluginDir, entry.Name(), "plugin.json") + m, err := parseManifest(manifestPath) + if err != nil { + continue + } + for _, endpoint := range m.APIEndpoints { + ep := strings.TrimPrefix(endpoint, "https://") + ep = strings.TrimPrefix(ep, "http://") + parts := strings.SplitN(ep, "/", 2) + serverName := parts[0] + location := "/" + if len(parts) == 2 && parts[1] != "" { + location = "/" + parts[1] + } + routes = append(routes, nginx.NginxRoute{ + ServerName: serverName, + Location: location, + PluginName: m.Name, + }) + } + } + return routes +} diff --git a/internal/plugin/manager.go b/internal/plugin/manager.go index 71942d01..5a8a9dc9 100644 --- a/internal/plugin/manager.go +++ b/internal/plugin/manager.go @@ -1,30 +1,19 @@ package plugin -import ( - "archive/tar" - "compress/gzip" - "context" - "crypto/ed25519" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "log/slog" - "net/http" - "os" - "path/filepath" - "strings" - "time" - - "github.com/nself-org/cli/internal/config" - "github.com/nself-org/cli/internal/errs" - "github.com/nself-org/cli/internal/httptimeout" - "github.com/nself-org/cli/internal/license" - "github.com/nself-org/cli/internal/nginx" - "github.com/nself-org/cli/internal/plugin/verify" - "github.com/nself-org/cli/internal/version" -) +// Purpose: Plugin domain types — PluginInfo and InstalledPluginInfo. +// All implementation (install, remove, load, verify, lifecycle) has +// been decomposed into focused sibling files: +// installer.go — Install/Remove/Update + lock + reverse-deps +// download.go — downloadPlugin/extractTarGz/rollbackInstall +// loader.go — ListInstalled/LoadManifestsFromDir/List + nginx routes +// security.go — verifyChecksum/verifyPluginSignature/checkLicense/ +// CheckEOLBlock/checkAuthorRevocation/postInstallEvent +// config.go — DisablePlugin/EnablePlugin/IsDisabled + table conflicts +// paths.go — DefaultCacheDir/LicenseCacheDir/pingAPIURL/findPlugin +// Inputs: (none — type declarations only) +// Outputs: (none — type declarations only) +// Constraints: Keep this file ≤100 lines. Do not add logic here. +// SPORT: plugin-types; callers: all cmd/plugin/*.go commands // PluginInfo describes a plugin's identity and current state. type PluginInfo struct { @@ -45,1380 +34,3 @@ type InstalledPluginInfo struct { Status string Description string } - -// ListInstalled scans pluginDir and returns detailed information for every -// installed plugin. Each entry's Status is "running", "installed", or -// "unknown" depending on the plugin's current runtime state. -func ListInstalled(pluginDir string) ([]InstalledPluginInfo, error) { - entries, err := os.ReadDir(pluginDir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, fmt.Errorf("reading plugin directory: %w", err) - } - - var plugins []InstalledPluginInfo - for _, entry := range entries { - if !entry.IsDir() { - continue - } - manifestPath := filepath.Join(pluginDir, entry.Name(), "plugin.json") - m, err := parseManifest(manifestPath) - if err != nil { - continue // skip directories without valid manifests - } - - status := "installed" - if st, stErr := Status(m.Name); stErr == nil { - status = st.State - } - // Overlay lifecycle state: dormant/expired beats runtime state. - if store, lcErr := LoadLifecycleStore(); lcErr == nil { - if rec, ok := store.Records[m.Name]; ok { - switch rec.State { - case StateDormant: - status = "dormant" - case StateExpired: - status = "expired" - } - } - } - - tier := m.Tier - if tier == "" { - if m.RequiresLicense || m.LicenseType == "pro" { - tier = "pro" - } else { - tier = "free" - } - } - - plugins = append(plugins, InstalledPluginInfo{ - Name: m.Name, - Version: m.Version, - Tier: tier, - Status: status, - Description: m.Description, - }) - } - - return plugins, nil -} - -// LoadManifestsFromDir scans pluginDir and returns the full PluginManifest for -// every installed plugin. It is the full-manifest counterpart to ListInstalled, -// used by features that need manifest fields beyond name/version/tier/status -// (e.g., federation's GraphQL block). Directories without a valid plugin.json -// are silently skipped. If pluginDir does not exist, nil is returned. -func LoadManifestsFromDir(pluginDir string) ([]*PluginManifest, error) { - entries, err := os.ReadDir(pluginDir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, fmt.Errorf("reading plugin directory: %w", err) - } - var manifests []*PluginManifest - for _, entry := range entries { - if !entry.IsDir() { - continue - } - manifestPath := filepath.Join(pluginDir, entry.Name(), "plugin.json") - m, err := parseManifest(manifestPath) - if err != nil { - continue // skip directories without a valid manifest - } - manifests = append(manifests, m) - } - return manifests, nil -} - -// installLockPath returns the path to the install lock file for pluginDir. -func installLockPath(pluginDir string) string { - return filepath.Join(pluginDir, ".install.lock") -} - -// acquireInstallLock attempts to create an exclusive lock file at -// {pluginDir}/.install.lock using O_CREATE|O_EXCL (atomic on POSIX). It polls -// every 250 ms until the lock is acquired or the 5-second timeout elapses. -// Returns the open lock file on success so the caller can defer its cleanup. -func acquireInstallLock(pluginDir string) (*os.File, error) { - // Ensure the plugin directory exists so the lock file can be created. - if err := os.MkdirAll(pluginDir, 0o755); err != nil { - return nil, fmt.Errorf("creating plugin directory: %w", err) - } - - lockPath := installLockPath(pluginDir) - deadline := time.Now().Add(5 * time.Second) - - for { - f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) - if err == nil { - // Lock acquired — write our PID for diagnostic purposes. - fmt.Fprintf(f, "%d\n", os.Getpid()) - return f, nil - } - if !os.IsExist(err) { - // Unexpected error (permissions, etc.). - return nil, fmt.Errorf("acquiring install lock: %w", err) - } - // Lock file already exists — check timeout before polling again. - if time.Now().After(deadline) { - return nil, fmt.Errorf("another plugin install is already running") - } - time.Sleep(250 * time.Millisecond) - } -} - -// releaseInstallLock closes and removes the lock file returned by -// acquireInstallLock. Errors are silently ignored; a stale lock file will be -// cleaned up on the next acquire attempt. -func releaseInstallLock(f *os.File, pluginDir string) { - f.Close() - os.Remove(installLockPath(pluginDir)) -} - -// Install downloads, extracts, and configures a plugin. For paid plugins it -// checks the license first. Dependencies are resolved and recursively -// installed before the target plugin. If any step after extraction fails, the -// extracted directory and database schema are rolled back. -// -// A file lock on {pluginDir}/.install.lock is held for the duration of the -// operation to prevent concurrent installs from corrupting plugin state. -func Install(ctx context.Context, cfg *config.Config, name string, pluginDir string) error { - lock, err := acquireInstallLock(pluginDir) - if err != nil { - return err - } - defer releaseInstallLock(lock, pluginDir) - - return installLocked(ctx, cfg, name, pluginDir) -} - -// installLocked performs the actual install work after the caller has already -// acquired the install lock. Dependency installs call this directly to avoid -// attempting to re-acquire the lock (which would deadlock). -func installLocked(ctx context.Context, cfg *config.Config, name string, pluginDir string) error { - // Step 1: License check for paid plugins. - if isPaidPlugin(name) { - if err := checkLicense(ctx, name); err != nil { - return err - } - } - - // Step 2: Fetch registry and locate the plugin. - cacheDir := defaultCacheDir() - reg, err := FetchRegistry(ctx, "", cacheDir) - if err != nil { - return fmt.Errorf("fetching registry: %w", err) - } - - manifest, found := findPlugin(reg, name) - if !found { - return errs.ErrPluginNotFound - } - - // Status check: lifecycle policy enforcement (S58-T01, S58-T02, S58-T03). - // "stable" and "" (legacy, no status field) proceed silently. - switch manifest.PublishStatus { - case "planned": - return fmt.Errorf( - "plugin %q is not yet available — coming soon.\nSee https://nself.org/plugins/%s for the release timeline.\nRun 'nself plugin list' to see available plugins.", - name, name, - ) - case "experimental": - fmt.Fprintf(os.Stderr, "warning: %s is experimental — API and behavior may change without notice\n", name) - case "beta": - fmt.Fprintf(os.Stderr, "warning: %s is in beta — use in production at your own risk\n", name) - case "deprecated": - d := manifest.Deprecation - if d != nil { - fmt.Fprintf(os.Stderr, "warning: %s is deprecated (EOL: %s). %s\n", name, d.EOLDate, d.MigrationGuide) - if d.ReplacedBy != "" { - fmt.Fprintf(os.Stderr, " A replacement is available: %s\n", d.ReplacedBy) - fmt.Fprintf(os.Stderr, " Install the replacement instead? Run: nself plugin install %s\n", d.ReplacedBy) - } - } else { - fmt.Fprintf(os.Stderr, "warning: %s is deprecated\n", name) - } - case "eol": - // EOL blocking is enforced at the command layer via --allow-eol. - // By the time we reach installLocked the caller has already verified - // the flag; emit a prominent warning so the risk acknowledgment is - // logged even in non-interactive contexts. - fmt.Fprintf(os.Stderr, "warning: %s has reached end-of-life and is no longer maintained\n", name) - } - - // S58-T09: Author CRL check. Blocks install if the plugin's declared author - // key appears in the revocation list at plugins.nself.org. Network errors - // are non-fatal (logged to stderr) so installs are not bricked by a - // transient CRL outage. - if err := checkAuthorRevocation(ctx, manifest.Author); err != nil { - return err - } - - // Compat check: verify CLI version satisfies the plugin's declared range. - if err := CheckCLICompat(manifest.Compat, version.GetVersion()); err != nil { - return fmt.Errorf("compatibility check failed for %q: %w", name, err) - } - - // T21: Check for table prefix conflicts with already-installed plugins. - if err := checkTablePrefixConflict(pluginDir, name, manifest.Tables); err != nil { - return err - } - - // Check for exact table name conflicts with already-installed plugins. - if err := checkTableConflicts(pluginDir, manifest); err != nil { - return err - } - - // T22: Check for nginx route conflicts with already-installed plugins. - existingRoutes := collectInstalledPluginRoutes(pluginDir, name) - if err := checkPluginRouteConflict(manifest, existingRoutes); err != nil { - return err - } - - // T23: Warn if this install would downgrade an already-installed plugin. - existingManifest, err := parseManifest(filepath.Join(pluginDir, name, "plugin.json")) - if err == nil { - // Plugin exists — check for downgrade - if compareSemver(existingManifest.Version, manifest.Version) > 0 { - fmt.Fprintf(os.Stderr, "⚠ Downgrading %s from %s to %s — this may cause data loss\n", - name, existingManifest.Version, manifest.Version) - } - } - - // S43-T17: Validate inter-plugin communication contract (Consumes). - // Every plugin listed in manifest.Consumes must be installed (or will be - // installed as a dependency below). A plugin declaring X in Consumes will - // send X-Source-Plugin: requests to X's HTTP API; if X is not - // installed there is no service to receive those calls. - if err := validateConsumes(pluginDir, name, manifest.Consumes); err != nil { - return err - } - - // Step 3: Resolve dependencies. The resolver reads manifests from - // pluginDir, so already-installed plugins are picked up automatically. - // For plugins not yet installed we install them first, which places - // their manifest on disk for the resolver. We call installLocked here - // (not Install) because the lock is already held by this goroutine. - deps := manifest.Dependencies - if len(deps) > 0 { - fmt.Fprintf(os.Stderr, "Installing %s (requires: %s)\n", name, strings.Join(deps, ", ")) - } - for _, dep := range deps { - depDir := filepath.Join(pluginDir, dep) - if _, err := os.Stat(depDir); err == nil { - fmt.Fprintf(os.Stderr, " ✓ %s (already installed)\n", dep) - continue // dependency already installed - } - fmt.Fprintf(os.Stderr, " → Installing dependency %s...\n", dep) - if err := installLocked(ctx, cfg, dep, pluginDir); err != nil { - return fmt.Errorf("installing dependency %q: %w", dep, err) - } - fmt.Fprintf(os.Stderr, " ✓ %s installed\n", dep) - } - - // Step 4: Download the plugin archive. - archivePath, err := downloadPlugin(ctx, name, manifest.Version, manifest.Repository) - if err != nil { - return fmt.Errorf("downloading plugin %q: %w", name, err) - } - defer os.Remove(archivePath) - - // Step 5: Verify checksum before extraction. - // For stable plugins a missing checksum is a hard error (V06-F2). - // For non-stable plugins an absent checksum emits a warning and continues. - if manifest.Checksum != "" { - if err := verifyChecksum(archivePath, manifest.Checksum, manifest.PublishStatus); err != nil { - os.Remove(archivePath) - return fmt.Errorf("checksum verification for plugin %q: %w", name, err) - } - } else { - if err := verifyChecksum(archivePath, "", manifest.PublishStatus); err != nil { - // stable plugin — hard fail returned by verifyChecksum - os.Remove(archivePath) - return fmt.Errorf("checksum verification for plugin %q: %w", name, err) - } - fmt.Fprintf(os.Stderr, "warning: no checksum in registry for plugin %q, skipping verification\n", name) - } - - // Step 5b: Verify Ed25519 signature (T09 — Security-Always-Free). - // The signature is computed over the raw SHA-256 digest of the tarball. - // Public key is pinned in the registry; never fetched at verify time (TOCTOU). - // For stable plugins a missing signature is a hard error (V06-F2). - // Skip requires BOTH NSELF_LICENSE_SKIP_VERIFY=1 AND NSELF_LICENSE_SKIP_VERIFY_FORCE=1. - // Either var alone is insufficient — standalone skip is not permitted (matches license/validate.go). - if os.Getenv("NSELF_LICENSE_SKIP_VERIFY") == "1" { - if os.Getenv("NSELF_LICENSE_SKIP_VERIFY_FORCE") != "1" { - os.Remove(archivePath) - return fmt.Errorf("NSELF_LICENSE_SKIP_VERIFY requires NSELF_LICENSE_SKIP_VERIFY_FORCE=1; standalone skip is not permitted") - } - fmt.Fprintf(os.Stderr, "WARNING: plugin signature verification skipped (NSELF_LICENSE_SKIP_VERIFY=1 + FORCE)\n") - } else { - if err := verifyPluginSignature(archivePath, manifest.AuthorPublicKey, manifest.Signature, manifest.PublishStatus); err != nil { - os.Remove(archivePath) - return fmt.Errorf("signature verification for plugin %q: %w", name, err) - } - } - - // Step 5c: Verify SBOM (S2.T12). Downloads sbom-{version}.cdx.json from the - // GitHub Release and validates CycloneDX schema. 404 = pre-SBOM release (warn, - // don't fail). Skip via --skip-sbom-check for air-gapped installs only. - sbomSkip := os.Getenv("NSELF_SKIP_SBOM_CHECK") == "1" - if err := verify.VerifySBOM(ctx, name, manifest.Version, verify.SBOMCheckOptions{ - SkipCheck: sbomSkip, - Version: manifest.Version, - }); err != nil { - os.Remove(archivePath) - return fmt.Errorf("sbom verification for plugin %q: %w", name, err) - } - - // Step 6: Extract to pluginDir/{name}/. - destDir := filepath.Join(pluginDir, name) - if err := extractTarGz(archivePath, destDir); err != nil { - return fmt.Errorf("extracting plugin %q: %w", name, err) - } - - // Step 7: Create database schema. On failure, rollback extraction. - if err := createPluginSchema(ctx, cfg, name); err != nil { - rollbackInstall(ctx, cfg, name, destDir) - return fmt.Errorf("creating schema for plugin %q: %w", name, err) - } - - // Step 7b (Q01): Generate per-plugin Ed25519 identity keypair and register - // the public key with ping_api. This is a best-effort step — a failure is - // logged as a warning but does not roll back the install, because the plugin - // will still function without JWT auth until Phase B-3 strict mode. - // The key is only generated when PLUGIN_INTERNAL_SECRET is set (i.e., the - // operator has opted into the inter-plugin JWT system). - if os.Getenv("PLUGIN_INTERNAL_SECRET") != "" { - // pluginDir doubles as the identity data root — each plugin's keypair - // is stored at pluginDir//identity.key alongside its manifest. - if !IdentityKeyExists(pluginDir, name) { - pubKey, idErr := GenerateEd25519Keypair(pluginDir, name) - if idErr != nil { - slog.Warn("plugin identity key generation failed — inter-plugin JWT auth unavailable until resolved", - "plugin", name, "error", idErr) - } else { - if regErr := RegisterIdentity(ctx, name, pubKey); regErr != nil { - slog.Warn("plugin identity registration with ping_api failed — JWT auth unavailable until resolved", - "plugin", name, "error", regErr) - } else { - slog.Info("plugin.identity.registered", "plugin", name) - } - } - } - } - - // S71-T02: Emit structured audit log for the granted permission set. - // One line per install, consumable by Loki. Never logs secret values — - // only the permission strings declared in the manifest. - slog.Info("plugin.install.permissions", - "plugin", name, - "version", manifest.Version, - "permissions", manifest.Permissions, - ) - - // S71-T02: Warn via doctor when dangerous permissions are present. - logDangerousPermissions(name, manifest.Permissions) - - fmt.Fprintf(os.Stderr, "\nℹ Run 'nself build' to include %s in your stack.\n", name) - - // S68-T02: Fire-and-forget install-event to plugins.nself.org registry. - // Silent, 1s timeout, never blocks the install. Sends only an opaque - // SHA-256 hash of the machine fingerprint — no PII in the payload. - go postInstallEvent(name) - - return nil -} - -// dangerousPermissions lists permission strings that warrant a visible warning -// on install. system:exec and network:internet are the two highest-risk grants. -// S71-T02. -var dangerousPermissions = map[string]bool{ - "system:exec": true, - "network:internet": true, -} - -// logDangerousPermissions emits a stderr warning for any dangerous permissions -// held by the named plugin. Called immediately after schema creation so the -// warning appears before the "Run nself build" footer line. S71-T02. -func logDangerousPermissions(pluginName string, permissions []string) { - for _, perm := range permissions { - if dangerousPermissions[perm] { - fmt.Fprintf(os.Stderr, - "warning: plugin %q holds elevated permission %q — review with 'nself plugin info %s'\n", - pluginName, perm, pluginName, - ) - } - } -} - -// postInstallEvent POSTs an anonymous install count event to the registry worker. -// It is always called in a goroutine and swallows all errors silently. -// The instanceId is a SHA-256 hex hash of the machineID — opaque, no PII. -func postInstallEvent(pluginName string) { - mid := machineID() // 16-char hex - // SHA-256 of the machineID to produce the required 64-char hex instanceId - h := sha256.Sum256([]byte(mid)) - instanceID := hex.EncodeToString(h[:]) - - body := `{"instanceId":"` + instanceID + `"}` - url := "https://plugins.nself.org/plugins/" + pluginName + "/install-event" - - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body)) - if err != nil { - return // silent - } - req.Header.Set("Content-Type", "application/json") - - resp, err := httptimeout.Plugin.Do(req) - if err != nil { - return // silent - } - _ = resp.Body.Close() -} - -// checkReverseDependencies scans all installed plugins in pluginDir and returns -// the names of any that declare name as a dependency. This prevents silently -// breaking dependent plugins during uninstall. -func checkReverseDependencies(pluginDir, name string) ([]string, error) { - entries, err := os.ReadDir(pluginDir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, fmt.Errorf("reading plugin directory: %w", err) - } - - var dependents []string - for _, entry := range entries { - if !entry.IsDir() || entry.Name() == name { - continue - } - manifestPath := filepath.Join(pluginDir, entry.Name(), "plugin.json") - m, err := parseManifest(manifestPath) - if err != nil { - continue // skip directories without valid manifests - } - for _, dep := range m.Dependencies { - if strings.EqualFold(dep, name) { - dependents = append(dependents, m.Name) - break - } - } - } - return dependents, nil -} - -// Remove stops a plugin (if running), optionally drops its database schema, -// and removes its directory from disk. When force is false and other installed -// plugins depend on the target, Remove returns an error listing them. -// -// A file lock on {pluginDir}/.install.lock is held for the duration of the -// operation so that concurrent install and remove calls serialize correctly. -func Remove(ctx context.Context, cfg *config.Config, name string, pluginDir string, keepData bool, force bool) error { - lock, err := acquireInstallLock(pluginDir) - if err != nil { - return err - } - defer releaseInstallLock(lock, pluginDir) - - destDir := filepath.Join(pluginDir, name) - if _, err := os.Stat(destDir); os.IsNotExist(err) { - return fmt.Errorf("plugin %q is not installed", name) - } - - // Check for reverse dependencies before doing anything destructive. - if !force { - dependents, err := checkReverseDependencies(pluginDir, name) - if err != nil { - return fmt.Errorf("checking reverse dependencies: %w", err) - } - if len(dependents) > 0 { - return fmt.Errorf("plugin %s depends on %q. Use --force to remove anyway", - strings.Join(dependents, ", "), name) - } - } - - // Stop if running. - st, err := Status(name) - if err == nil && st.State == "running" { - if stopErr := Stop(ctx, name); stopErr != nil { - return fmt.Errorf("stopping plugin %q: %w", name, stopErr) - } - } - - // Drop schema unless the caller wants to keep data. - if !keepData { - if err := dropPluginSchema(ctx, cfg, name); err != nil { - return fmt.Errorf("dropping schema for plugin %q: %w", name, err) - } - } - - // Remove plugin directory. - if err := os.RemoveAll(destDir); err != nil { - return fmt.Errorf("removing plugin directory %q: %w", destDir, err) - } - - fmt.Fprintf(os.Stderr, "\nℹ Run 'nself build' to update your stack.\n") - return nil -} - -// Update backs up the current installation, then reinstalls from the registry. -// If the new install fails, the previous version is restored automatically. -func Update(ctx context.Context, cfg *config.Config, name string, pluginDir string) error { - currentDir := filepath.Join(pluginDir, name) - backupDir := filepath.Join(pluginDir, name+".prev") - - // Rename current install to .prev so we can restore on failure. - if _, err := os.Stat(currentDir); err == nil { - // Remove any stale backup from a prior interrupted update. - _ = os.RemoveAll(backupDir) - if err := os.Rename(currentDir, backupDir); err != nil { - return fmt.Errorf("backing up plugin %q for update: %w", name, err) - } - } - - // Install the new version. - if err := Install(ctx, cfg, name, pluginDir); err != nil { - // Restore the previous version from backup. - if _, statErr := os.Stat(backupDir); statErr == nil { - _ = os.RemoveAll(currentDir) // clean partial install if any - if renameErr := os.Rename(backupDir, currentDir); renameErr != nil { - fmt.Fprintf(os.Stderr, "warning: failed to restore previous version of %q: %v\n", name, renameErr) - } - } - return fmt.Errorf("installing updated plugin %q: %w", name, err) - } - - // Success: remove the backup. - _ = os.RemoveAll(backupDir) - return nil -} - -// List returns plugin information. When installed is true it scans pluginDir -// for locally installed plugins. When false it returns all plugins known to -// the registry. -func List(pluginDir string, installed bool) ([]PluginInfo, error) { - if installed { - return listInstalled(pluginDir) - } - return listFromRegistry(pluginDir) -} - -// CheckTablePrefixConflict scans all installed plugins in pluginDir and -// returns an error if any of them share a table prefix with the tables listed -// in newTables. Table prefixes are derived from table names by taking the -// first two underscore-separated segments followed by a trailing underscore -// (e.g. "np_chat_messages" → prefix "np_chat_"). The newPluginName parameter -// is used to skip the plugin being installed (allowing reinstalls/updates). -func checkTablePrefixConflict(pluginDir, newPluginName string, newTables []string) error { - if len(newTables) == 0 { - return nil - } - - // Derive prefix set for the new plugin's tables. - newPrefixes := make(map[string]bool) - for _, table := range newTables { - if p := tablePrefix(table); p != "" { - newPrefixes[p] = true - } - } - if len(newPrefixes) == 0 { - return nil - } - - entries, err := os.ReadDir(pluginDir) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return fmt.Errorf("reading plugin directory: %w", err) - } - - for _, entry := range entries { - if !entry.IsDir() { - continue - } - // Skip the plugin being installed so reinstalls and updates are allowed. - if strings.EqualFold(entry.Name(), newPluginName) { - continue - } - manifestPath := filepath.Join(pluginDir, entry.Name(), "plugin.json") - m, err := parseManifest(manifestPath) - if err != nil { - continue // skip directories without valid manifests - } - for _, table := range m.Tables { - p := tablePrefix(table) - if p != "" && newPrefixes[p] { - return fmt.Errorf("plugin %s conflicts with installed plugin %s: table prefix %q already claimed", newPluginName, m.Name, p) - } - } - } - return nil -} - -// checkTableConflicts scans all installed plugins in pluginDir and returns an -// error if any of them declare a table name that also appears in newPlugin's -// Tables list. This catches exact name collisions (the prefix check above -// catches broader namespace conflicts). -func checkTableConflicts(pluginDir string, newPlugin *PluginManifest) error { - if len(newPlugin.Tables) == 0 { - return nil - } - - entries, err := os.ReadDir(pluginDir) - if err != nil { - return nil // No plugins dir = no conflicts - } - - for _, entry := range entries { - if !entry.IsDir() || entry.Name() == newPlugin.Name { - continue - } - existing, err := parseManifest(filepath.Join(pluginDir, entry.Name(), "plugin.json")) - if err != nil { - continue - } - // Check for overlapping table names - for _, newTable := range newPlugin.Tables { - for _, existingTable := range existing.Tables { - if newTable == existingTable { - return fmt.Errorf("table %q already used by plugin %q", newTable, existing.Name) - } - } - } - } - return nil -} - -// tablePrefix extracts the two-segment prefix from a table name. Given -// "np_chat_messages" it returns "np_chat_". Returns empty string for table -// names that do not have at least two underscore-separated segments. -func tablePrefix(table string) string { - parts := strings.Split(table, "_") - if len(parts) < 2 { - return "" - } - return parts[0] + "_" + parts[1] + "_" -} - -// DisablePlugin creates a .disabled marker file in the plugin's directory, -// causing it to be excluded from compose files on the next build. -func DisablePlugin(name, pluginDir string) error { - destDir := filepath.Join(pluginDir, name) - if _, err := os.Stat(destDir); os.IsNotExist(err) { - return fmt.Errorf("plugin %q is not installed", name) - } - - markerPath := filepath.Join(destDir, ".disabled") - if _, err := os.Stat(markerPath); err == nil { - return fmt.Errorf("plugin %q is already disabled", name) - } - - f, err := os.Create(markerPath) - if err != nil { - return fmt.Errorf("creating disable marker: %w", err) - } - f.Close() - return nil -} - -// EnablePlugin removes the .disabled marker file from the plugin's directory, -// allowing it to be included in compose files on the next build. -func EnablePlugin(name, pluginDir string) error { - destDir := filepath.Join(pluginDir, name) - if _, err := os.Stat(destDir); os.IsNotExist(err) { - return fmt.Errorf("plugin %q is not installed", name) - } - - markerPath := filepath.Join(destDir, ".disabled") - if _, err := os.Stat(markerPath); os.IsNotExist(err) { - return fmt.Errorf("plugin %q is not disabled", name) - } - - if err := os.Remove(markerPath); err != nil { - return fmt.Errorf("removing disable marker: %w", err) - } - return nil -} - -// IsDisabled returns true if the named plugin has a .disabled marker file. -func IsDisabled(name, pluginDir string) bool { - markerPath := filepath.Join(pluginDir, name, ".disabled") - _, err := os.Stat(markerPath) - return err == nil -} - -// --- internal helpers --- - -// verifyChecksum computes the SHA256 hash of the file at filePath and compares -// it to expectedHash (hex-encoded). Returns an error if the hashes differ. -// -// publishStatus is the plugin's lifecycle status from the registry -// ("stable", "beta", "alpha", "experimental", etc.). When publishStatus is -// "stable" and expectedHash is empty, the function returns -// errs.ErrPluginMissingChecksum — install is refused. For non-stable plugins -// an empty expectedHash is permitted (a warning is emitted to stderr). -func verifyChecksum(filePath string, expectedHash string, publishStatus string) error { - if expectedHash == "" { - if publishStatus == "stable" { - return fmt.Errorf("plugin %q is missing required checksum for stable publishStatus — install refused: %w", - filepath.Base(filepath.Dir(filePath)), errs.ErrPluginMissingChecksum) - } - // Non-stable: permissive, warning only (handled by caller). - return nil - } - - f, err := os.Open(filePath) - if err != nil { - return fmt.Errorf("opening file for checksum: %w", err) - } - defer f.Close() - - h := sha256.New() - if _, err := io.Copy(h, f); err != nil { - return fmt.Errorf("computing checksum: %w", err) - } - - actual := hex.EncodeToString(h.Sum(nil)) - if !strings.EqualFold(actual, expectedHash) { - return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actual) - } - return nil -} - -// verifyPluginSignature verifies that the Ed25519 signature stored in the -// plugin's registry manifest matches the SHA-256 hash of the downloaded -// tarball. The public key is pinned in the registry (never fetched at verify -// time, preventing TOCTOU attacks). -// -// The signed message is the raw 32-byte SHA-256 digest of the tarball — -// consistent with how release tarballs are signed on the publisher side. -// -// publishStatus is the plugin's lifecycle status from the registry -// ("stable", "beta", "alpha", "experimental", etc.). When publishStatus is -// "stable" and either authorPublicKeyHex or signatureHex is empty, the -// function returns errs.ErrPluginUnsigned — install is refused. For -// non-stable plugins an empty key or signature skips verification with a -// warning (development workflow). -func verifyPluginSignature(archivePath, authorPublicKeyHex, signatureHex, publishStatus string) error { - if authorPublicKeyHex == "" || signatureHex == "" { - if publishStatus == "stable" { - return fmt.Errorf("plugin is missing required signature for stable publishStatus — install refused: %w", - errs.ErrPluginUnsigned) - } - // Non-stable: permissive, skip verification (development workflow). - return nil - } - - // Decode the hex-encoded Ed25519 public key. - pkBytes, err := hex.DecodeString(authorPublicKeyHex) - if err != nil { - return fmt.Errorf("decoding author public key: %w", err) - } - if len(pkBytes) != ed25519.PublicKeySize { - return fmt.Errorf("author public key has wrong length: expected %d bytes, got %d", ed25519.PublicKeySize, len(pkBytes)) - } - pubKey := ed25519.PublicKey(pkBytes) - - // Decode the hex-encoded signature. - sigBytes, err := hex.DecodeString(signatureHex) - if err != nil { - return fmt.Errorf("decoding plugin signature: %w", err) - } - if len(sigBytes) != ed25519.SignatureSize { - return fmt.Errorf("plugin signature has wrong length: expected %d bytes, got %d", ed25519.SignatureSize, len(sigBytes)) - } - - // Compute the SHA-256 digest of the tarball. - f, err := os.Open(archivePath) - if err != nil { - return fmt.Errorf("opening archive for signature verification: %w", err) - } - defer f.Close() - - h := sha256.New() - if _, err := io.Copy(h, f); err != nil { - return fmt.Errorf("hashing archive for signature verification: %w", err) - } - digest := h.Sum(nil) // 32-byte raw digest - - if !ed25519.Verify(pubKey, digest, sigBytes) { - return fmt.Errorf("plugin signature verification failed: tarball does not match registry signature (possible tampering)") - } - return nil -} - -// checkLicense validates that a license key exists and is acceptable. -// It checks all configured keys (multi-key support) against the local -// entitlement cache first, then falls through to remote validation. -func checkLicense(ctx context.Context, name string) error { - keys := license.CollectLicenseKeys() - - // Fallback: try legacy single-key path if CollectLicenseKeys found nothing. - if len(keys) == 0 { - key := os.Getenv("NSELF_PLUGIN_LICENSE_KEY") - if key == "" { - keyPath := licenseKeyPath() - data, err := os.ReadFile(keyPath) - if err != nil { - return fmt.Errorf("plugin %q requires a license key. Run 'nself license add ' or visit %s", - name, "nself.org/pricing") - } - key = strings.TrimSpace(string(data)) - } - if key != "" { - keys = []string{key} - } - } - - if len(keys) == 0 { - return fmt.Errorf("plugin %q requires a license key. Run 'nself license add ' or visit %s", - name, "nself.org/pricing") - } - - cacheDir := licenseCacheDir() - - // Fast path: check the entitlement cache before any network call. - if allowed, found := checkEntitlements(cacheDir, name); found { - if allowed { - return nil - } - // Cache says not allowed, but we might have a new key not yet cached. - // Fall through to check all keys. - } - - // Try each key. If any key covers this plugin, allow it. - var lastErr error - for _, key := range keys { - if err := validateLicenseFormat(key); err != nil { - continue - } - - // Check the per-key license cache before hitting the network. - if valid, found := checkLicenseCache(key, cacheDir); found { - if valid { - return nil - } - continue - } - - valid, lvr, err := validateLicenseRemoteWithEntitlements(ctx, key, pingAPIURL()) - if err != nil { - if strings.Contains(err.Error(), "expired") { - lastErr = fmt.Errorf("plugin %q: %w", name, errs.ErrLicenseExpired) - continue - } - // Network unavailable: try offline cache. - if offlineValid, offlineFound := checkLicenseCacheOffline(key, cacheDir); offlineFound && offlineValid { - return nil - } - lastErr = fmt.Errorf("plugin %q: %w", name, errs.ErrLicenseNetworkUnavailable) - continue - } - _ = CacheLicense(key, valid, cacheDir) - - if lvr != nil && len(lvr.Plugins) > 0 { - _ = cacheEntitlements(cacheDir, lvr.Tier, lvr.Plugins) - for _, p := range lvr.Plugins { - if p == name { - return nil - } - } - } - - if valid { - // Key is valid but doesn't cover this specific plugin. - lastErr = fmt.Errorf("plugin %q: %w", name, errs.ErrLicenseTierTooLow) - continue - } - lastErr = fmt.Errorf("plugin %q: license key is not valid", name) - } - - // No key covered this plugin. Provide a helpful suggestion. - if lastErr != nil { - return lastErr - } - return fmt.Errorf("plugin %q requires a license key. Get one at %s", name, "nself.org/pricing") -} - -// CheckEOLBlock fetches the registry entry for name and returns an error if -// the plugin's status is "eol" and allowEOL is false. (S58-T03) -// If the registry cannot be reached or the plugin is not found, this function -// returns nil (install will fail downstream with a more specific error). -// This is intentionally a pre-flight check: it does not install anything. -func CheckEOLBlock(ctx context.Context, name string, allowEOL bool) error { - cacheDir := defaultCacheDir() - reg, err := FetchRegistry(ctx, "", cacheDir) - if err != nil { - // Registry unreachable — defer failure to Install proper. - return nil - } - manifest, found := findPlugin(reg, name) - if !found { - return nil - } - if manifest.PublishStatus == "eol" && !allowEOL { - return fmt.Errorf( - "plugin %q has reached end-of-life and cannot be installed.\n"+ - "Use --allow-eol to override (not recommended).\n"+ - "Run 'nself plugin info %s' for details.", - name, name, - ) - } - return nil -} - -// checkAuthorRevocation fetches the author CRL from plugins.nself.org and -// returns an error if the plugin author appears in the revocation list. (S58-T09) -// Short-circuits on any network / parse error — install proceeds and the -// full install step will catch other issues. Revocation is security-critical -// so errors are logged but not fatal (to avoid bricking installs during a -// transient outage). -func checkAuthorRevocation(ctx context.Context, author string) error { - if author == "" { - return nil // no author field — nothing to check - } - - url := "https://plugins.nself.org/.well-known/revoked-authors.json" - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil // construction failure — do not block install - } - req.Header.Set("User-Agent", "nself-cli") - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - // Network failure — do not block install. CLI warns instead. - fmt.Fprintf(os.Stderr, "warning: could not fetch author revocation list (offline?): %v\n", err) - return nil - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - // Non-200 — registry side issue, do not block. - return nil - } - - type revokedEntry struct { - AuthorKey string `json:"authorKey"` - RevokedAt string `json:"revokedAt"` - Reason string `json:"reason,omitempty"` - } - type crlResponse struct { - RevokedAuthors []revokedEntry `json:"revokedAuthors"` - } - - var crl crlResponse - body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) // 512 KB max - if err != nil { - return nil - } - if err := json.Unmarshal(body, &crl); err != nil { - return nil - } - - for _, entry := range crl.RevokedAuthors { - if strings.EqualFold(entry.AuthorKey, author) { - msg := fmt.Sprintf( - "plugin author %q has been revoked and cannot be installed (revoked: %s).\n"+ - "Remove any plugins from this author and contact support@nself.org.\n"+ - "See https://nself.org/security/revocations for details.", - author, entry.RevokedAt, - ) - if entry.Reason != "" { - msg += "\nReason: " + entry.Reason - } - return fmt.Errorf("%s", msg) - } - } - return nil -} - -// findPlugin locates a plugin in the registry by name (case-insensitive). -func findPlugin(reg *Registry, name string) (*PluginManifest, bool) { - for i := range reg.Plugins { - if strings.EqualFold(reg.Plugins[i].Name, name) { - return ®.Plugins[i], true - } - } - return nil, false -} - -// downloadPlugin fetches the plugin tarball to a temporary file. -// For free plugins, it tries the R2-backed worker URL first and falls back to -// GitHub Releases on 5xx responses (S67-T03). -func downloadPlugin(ctx context.Context, name, version, repository string) (string, error) { - primaryURL := buildDownloadURL(name, version, repository) - tmp, err := downloadFromURL(ctx, primaryURL) - if err == nil { - return tmp, nil - } - - // If not a paid plugin, attempt GitHub Releases fallback on primary failure. - if !isPaidPlugin(name) { - fallbackURL := buildFallbackDownloadURL(name, version, repository) - if fallbackURL != primaryURL { - tmp2, fallbackErr := downloadFromURL(ctx, fallbackURL) - if fallbackErr == nil { - return tmp2, nil - } - return "", fmt.Errorf("download failed: primary %s: %w; fallback %s: %v", primaryURL, err, fallbackURL, fallbackErr) - } - } - - return "", err -} - -// downloadFromURL fetches a single URL to a temp file and returns the file path. -func downloadFromURL(ctx context.Context, url string) (string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return "", fmt.Errorf("creating download request: %w", err) - } - req.Header.Set("User-Agent", "nself-cli") - - client := &http.Client{Timeout: 5 * time.Minute} - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("HTTP GET %s: %w", url, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("HTTP GET %s: status %d", url, resp.StatusCode) - } - - tmp, err := os.CreateTemp("", "nself-plugin-*.tar.gz") - if err != nil { - return "", fmt.Errorf("creating temp file: %w", err) - } - defer tmp.Close() - - if _, err := io.Copy(tmp, resp.Body); err != nil { - os.Remove(tmp.Name()) - return "", fmt.Errorf("writing download to temp file: %w", err) - } - - return tmp.Name(), nil -} - -// buildDownloadURL constructs the tarball URL for a plugin. Pro plugins use -// the ping API download endpoint; free plugins use the plugins.nself.org worker -// which 302-redirects to R2 (primary) with GitHub Releases as fallback. -func buildDownloadURL(name, version, repository string) string { - if isPaidPlugin(name) { - base := pingAPIURL() - return fmt.Sprintf("%s/plugins/%s/download", base, name) - } - // Free plugins: S67-T03 — use plugins.nself.org worker tarball endpoint. - // The worker 302-redirects to R2 (primary CDN, free egress) or falls back - // to GitHub Releases on R2 5xx. Override via NSELF_PLUGIN_REGISTRY env var. - base := "https://plugins.nself.org" - if envURL := os.Getenv("NSELF_PLUGIN_REGISTRY"); envURL != "" { - base = strings.TrimRight(envURL, "/") - } - return fmt.Sprintf("%s/plugins/%s/tarball", base, name) -} - -// buildFallbackDownloadURL constructs the GitHub Releases fallback URL for a -// free plugin. Used when the primary R2/worker download fails. -func buildFallbackDownloadURL(name, version, repository string) string { - if repository != "" { - repo := strings.TrimSuffix(repository, ".git") - return fmt.Sprintf("%s/releases/download/v%s/%s-v%s.tar.gz", repo, version, name, version) - } - return fmt.Sprintf("https://github.com/nself-org/plugins/releases/download/v%s/%s-v%s.tar.gz", version, name, version) -} - -// extractTarGz extracts a gzipped tarball into destDir. -func extractTarGz(archivePath, destDir string) error { - if err := os.MkdirAll(destDir, 0o755); err != nil { - return fmt.Errorf("creating destination directory: %w", err) - } - - f, err := os.Open(archivePath) - if err != nil { - return fmt.Errorf("opening archive: %w", err) - } - defer f.Close() - - gz, err := gzip.NewReader(f) - if err != nil { - return fmt.Errorf("gzip reader: %w", err) - } - defer gz.Close() - - tr := tar.NewReader(gz) - for { - hdr, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return fmt.Errorf("reading tar entry: %w", err) - } - - // Tar slip prevention: reject absolute paths and directory traversal. - if filepath.IsAbs(hdr.Name) { - return fmt.Errorf("unsafe tar entry: absolute path %q", hdr.Name) - } - cleanName := filepath.Clean(hdr.Name) - if strings.HasPrefix(cleanName, "..") { - return fmt.Errorf("unsafe tar entry: directory traversal %q", hdr.Name) - } - target := filepath.Join(destDir, cleanName) - // Final safety check: verify resolved target stays within destDir. - if !strings.HasPrefix(target, destDir+string(filepath.Separator)) && target != destDir { - return fmt.Errorf("tar slip detected: %q escapes destination directory", hdr.Name) - } - - // S-014: Strip setuid, setgid, and sticky bits from tar entries - // to prevent privilege escalation from malicious plugin archives. - mode := os.FileMode(hdr.Mode) &^ (os.ModeSetuid | os.ModeSetgid | os.ModeSticky) - - switch hdr.Typeflag { - case tar.TypeSymlink, tar.TypeLink: - return fmt.Errorf("unsafe tar entry: symlinks not allowed (%q → %q)", hdr.Name, hdr.Linkname) - case tar.TypeDir: - if err := os.MkdirAll(target, mode); err != nil { - return fmt.Errorf("creating directory %s: %w", target, err) - } - case tar.TypeReg: - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return fmt.Errorf("creating parent directory for %s: %w", target, err) - } - outFile, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) - if err != nil { - return fmt.Errorf("creating file %s: %w", target, err) - } - if _, err := io.Copy(outFile, tr); err != nil { - outFile.Close() - return fmt.Errorf("writing file %s: %w", target, err) - } - outFile.Close() - } - } - - return nil -} - -// rollbackInstall cleans up a partially installed plugin by removing the -// extracted directory and dropping the database schema. Errors during -// rollback are logged but not returned. -func rollbackInstall(ctx context.Context, cfg *config.Config, name string, destDir string) { - if err := os.RemoveAll(destDir); err != nil { - fmt.Fprintf(os.Stderr, "warning: rollback cleanup failed for %s: %v\n", destDir, err) - } - if err := dropPluginSchema(ctx, cfg, name); err != nil { - fmt.Fprintf(os.Stderr, "warning: rollback schema drop failed for %s: %v\n", name, err) - } -} - -// listInstalled scans pluginDir for subdirectories that contain a valid -// plugin.json manifest and returns PluginInfo for each. -func listInstalled(pluginDir string) ([]PluginInfo, error) { - entries, err := os.ReadDir(pluginDir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, fmt.Errorf("reading plugin directory: %w", err) - } - - var plugins []PluginInfo - for _, entry := range entries { - if !entry.IsDir() { - continue - } - manifestPath := filepath.Join(pluginDir, entry.Name(), "plugin.json") - m, err := parseManifest(manifestPath) - if err != nil { - continue // skip directories without valid manifests - } - - running := false - if st, err := Status(m.Name); err == nil && st.State == "running" { - running = true - } - - plugins = append(plugins, PluginInfo{ - Name: m.Name, - Version: m.Version, - Category: m.Category, - Installed: true, - Running: running, - }) - } - - return plugins, nil -} - -// listFromRegistry fetches the registry and returns PluginInfo for every -// known plugin, marking those already installed locally. -func listFromRegistry(pluginDir string) ([]PluginInfo, error) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - cacheDir := defaultCacheDir() - reg, err := FetchRegistry(ctx, "", cacheDir) - if err != nil { - return nil, fmt.Errorf("fetching registry: %w", err) - } - - // Build a set of installed plugin names for quick lookup. - installedSet := make(map[string]bool) - if entries, dirErr := os.ReadDir(pluginDir); dirErr == nil { - for _, entry := range entries { - if entry.IsDir() { - installedSet[entry.Name()] = true - } - } - } - - plugins := make([]PluginInfo, 0, len(reg.Plugins)) - for _, m := range reg.Plugins { - installed := installedSet[m.Name] - running := false - if installed { - if st, err := Status(m.Name); err == nil && st.State == "running" { - running = true - } - } - plugins = append(plugins, PluginInfo{ - Name: m.Name, - Version: m.Version, - Category: m.Category, - Installed: installed, - Running: running, - PublishStatus: m.PublishStatus, - }) - } - - return plugins, nil -} - -// baseServiceRoutes lists the always-on nSelf service routes that are present -// on any clean init. Plugins must not claim these paths — they are owned by -// core infrastructure and seeded into the conflict-detection set before any -// plugin routes are evaluated. -var baseServiceRoutes = []nginx.NginxRoute{ - {ServerName: "api", Location: "/", PluginName: "hasura"}, - {ServerName: "auth", Location: "/", PluginName: "auth"}, - {ServerName: "storage", Location: "/", PluginName: "storage"}, -} - -// collectInstalledPluginRoutes scans pluginDir and returns the nginx routes -// declared by all installed plugins except the one named skipPlugin (the plugin -// being installed, so reinstalls are permitted). Base service routes (Hasura, -// Auth, Storage) are seeded first so plugins that claim those paths are -// rejected with a clear conflict message naming the base service as owner. -func collectInstalledPluginRoutes(pluginDir, skipPlugin string) []nginx.NginxRoute { - // Seed with always-on base service routes. This prevents false-positive - // "clean install succeeded" for plugins that try to claim /api, /auth, or - // /storage — and produces a clear "claimed by hasura/auth/storage" message. - routes := make([]nginx.NginxRoute, len(baseServiceRoutes)) - copy(routes, baseServiceRoutes) - - entries, err := os.ReadDir(pluginDir) - if err != nil { - return routes - } - - for _, entry := range entries { - if !entry.IsDir() { - continue - } - if strings.EqualFold(entry.Name(), skipPlugin) { - continue - } - manifestPath := filepath.Join(pluginDir, entry.Name(), "plugin.json") - m, err := parseManifest(manifestPath) - if err != nil { - continue - } - for _, endpoint := range m.APIEndpoints { - ep := strings.TrimPrefix(endpoint, "https://") - ep = strings.TrimPrefix(ep, "http://") - parts := strings.SplitN(ep, "/", 2) - serverName := parts[0] - location := "/" - if len(parts) == 2 && parts[1] != "" { - location = "/" + parts[1] - } - routes = append(routes, nginx.NginxRoute{ - ServerName: serverName, - Location: location, - PluginName: m.Name, - }) - } - } - return routes -} - -// --- path helpers --- - -// DefaultCacheDir is the exported alias of defaultCacheDir for use by -// commands that need to call FetchRegistry directly. (S58-T06) -func DefaultCacheDir() string { return defaultCacheDir() } - -// FindPluginByName is the exported wrapper around findPlugin for use by -// commands outside the plugin package. (S58-T06) -func FindPluginByName(reg *Registry, name string) (*PluginManifest, bool) { - return findPlugin(reg, name) -} - -// CompareVersions compares two semver strings a and b. -// Returns -1 if a < b, 0 if a == b, +1 if a > b. (S58-T06) -func CompareVersions(a, b string) int { return compareSemver(a, b) } - -// defaultCacheDir returns the default plugin cache directory. -func defaultCacheDir() string { - home, err := os.UserHomeDir() - if err != nil { - return filepath.Join("/tmp", ".nself", "cache", "plugins") - } - return filepath.Join(home, ".nself", "cache", "plugins") -} - -// LicenseCacheDir returns the directory used for license validation caching. -// This is ~/.nself/license/ (or /tmp/.nself/license/ if the home directory -// cannot be determined). -func LicenseCacheDir() string { - return licenseCacheDir() -} - -// licenseCacheDir returns the directory used for license validation caching. -func licenseCacheDir() string { - home, err := os.UserHomeDir() - if err != nil { - return filepath.Join("/tmp", ".nself", "license") - } - return filepath.Join(home, ".nself", "license") -} - -// licenseKeyPath returns the file path where the license key is stored. -func licenseKeyPath() string { - home, err := os.UserHomeDir() - if err != nil { - return filepath.Join("/tmp", ".nself", "license", "key") - } - return filepath.Join(home, ".nself", "license", "key") -} - -// pingAPIURL returns the license validation API base URL. -func pingAPIURL() string { - if u := os.Getenv("NSELF_PING_API_URL"); u != "" { - return strings.TrimRight(u, "/") - } - return "https://ping.nself.org" -} diff --git a/internal/plugin/paths.go b/internal/plugin/paths.go new file mode 100644 index 00000000..0f9b770b --- /dev/null +++ b/internal/plugin/paths.go @@ -0,0 +1,83 @@ +package plugin + +// Purpose: Path helpers and exported utility wrappers for the plugin package. +// Provides deterministic directory paths for plugin cache, license +// store, and license key file; plus exported wrappers around internal +// helpers for use by cmd/ callers. +// Inputs: OS home directory (via os.UserHomeDir); env override vars. +// Outputs: string paths; bool presence flags; int comparison result. +// Constraints: Falls back to /tmp/.nself/... when home directory is unavailable. +// pingAPIURL honours NSELF_PING_API_URL env override (test seam). +// findPlugin is case-insensitive on plugin name. +// SPORT: path-helpers; callers: cmd/plugin/*.go, security.go, installer.go, +// loader.go + +import ( + "os" + "path/filepath" + "strings" +) + +// DefaultCacheDir is the exported alias of defaultCacheDir for use by +// commands that need to call FetchRegistry directly. (S58-T06) +func DefaultCacheDir() string { return defaultCacheDir() } + +// FindPluginByName is the exported wrapper around findPlugin for use by +// commands outside the plugin package. (S58-T06) +func FindPluginByName(reg *Registry, name string) (*PluginManifest, bool) { + return findPlugin(reg, name) +} + +// CompareVersions compares two semver strings a and b. +// Returns -1 if a < b, 0 if a == b, +1 if a > b. (S58-T06) +func CompareVersions(a, b string) int { return compareSemver(a, b) } + +// defaultCacheDir returns the default plugin cache directory. +func defaultCacheDir() string { + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join("/tmp", ".nself", "cache", "plugins") + } + return filepath.Join(home, ".nself", "cache", "plugins") +} + +// LicenseCacheDir returns the directory used for license validation caching. +// This is ~/.nself/license/ (or /tmp/.nself/license/ if the home directory +// cannot be determined). +func LicenseCacheDir() string { return licenseCacheDir() } + +// licenseCacheDir returns the directory used for license validation caching. +func licenseCacheDir() string { + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join("/tmp", ".nself", "license") + } + return filepath.Join(home, ".nself", "license") +} + +// licenseKeyPath returns the file path where the license key is stored. +func licenseKeyPath() string { + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join("/tmp", ".nself", "license", "key") + } + return filepath.Join(home, ".nself", "license", "key") +} + +// pingAPIURL returns the license validation API base URL. +func pingAPIURL() string { + if u := os.Getenv("NSELF_PING_API_URL"); u != "" { + return strings.TrimRight(u, "/") + } + return "https://ping.nself.org" +} + +// findPlugin locates a plugin in the registry by name (case-insensitive). +func findPlugin(reg *Registry, name string) (*PluginManifest, bool) { + for i := range reg.Plugins { + if strings.EqualFold(reg.Plugins[i].Name, name) { + return ®.Plugins[i], true + } + } + return nil, false +} diff --git a/internal/plugin/security.go b/internal/plugin/security.go new file mode 100644 index 00000000..1bd82c41 --- /dev/null +++ b/internal/plugin/security.go @@ -0,0 +1,321 @@ +package plugin + +// Purpose: Plugin security checks — checksum/signature verification, Ed25519 +// CRL author revocation, license validation, EOL blocking, and the +// anonymous install-event telemetry POST. +// Inputs: archive file paths, hex-encoded keys/sigs, context with timeout. +// Outputs: error on any policy violation; nil on pass or when non-fatal +// (network offline, non-stable status, or author not found in CRL). +// Constraints: Stable plugins MUST have checksum + signature (errs.ErrPlugin*). +// License check tries all keys; first valid entitlement match wins. +// CRL fetch errors are non-fatal — warns to stderr, never blocks. +// postInstallEvent always runs in a goroutine; errors are silent. +// SPORT: security/verification pipeline; callers: installLocked in installer.go + +import ( + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/nself-org/cli/internal/errs" + "github.com/nself-org/cli/internal/httptimeout" + "github.com/nself-org/cli/internal/license" +) + +// postInstallEvent POSTs an anonymous install count event to the registry worker. +// It is always called in a goroutine and swallows all errors silently. +// The instanceId is a SHA-256 hex hash of the machineID — opaque, no PII. +func postInstallEvent(pluginName string) { + mid := machineID() // 16-char hex + // SHA-256 of the machineID to produce the required 64-char hex instanceId + h := sha256.Sum256([]byte(mid)) + instanceID := hex.EncodeToString(h[:]) + + body := `{"instanceId":"` + instanceID + `"}` + url := "https://plugins.nself.org/plugins/" + pluginName + "/install-event" + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body)) + if err != nil { + return // silent + } + req.Header.Set("Content-Type", "application/json") + + resp, err := httptimeout.Plugin.Do(req) + if err != nil { + return // silent + } + _ = resp.Body.Close() +} + +// verifyChecksum computes the SHA256 hash of the file at filePath and compares +// it to expectedHash (hex-encoded). Returns an error if the hashes differ. +// +// publishStatus is the plugin's lifecycle status from the registry +// ("stable", "beta", "alpha", "experimental", etc.). When publishStatus is +// "stable" and expectedHash is empty, the function returns +// errs.ErrPluginMissingChecksum — install is refused. For non-stable plugins +// an empty expectedHash is permitted (a warning is emitted to stderr). +func verifyChecksum(filePath string, expectedHash string, publishStatus string) error { + if expectedHash == "" { + if publishStatus == "stable" { + return fmt.Errorf("plugin %q is missing required checksum for stable publishStatus — install refused: %w", + filePath, errs.ErrPluginMissingChecksum) + } + return nil + } + + f, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("opening file for checksum: %w", err) + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return fmt.Errorf("computing checksum: %w", err) + } + + actual := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(actual, expectedHash) { + return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actual) + } + return nil +} + +// verifyPluginSignature verifies that the Ed25519 signature stored in the +// plugin's registry manifest matches the SHA-256 hash of the downloaded +// tarball. The public key is pinned in the registry (never fetched at verify +// time, preventing TOCTOU attacks). +// +// publishStatus is the plugin's lifecycle status from the registry +// ("stable", "beta", "alpha", "experimental", etc.). When publishStatus is +// "stable" and either authorPublicKeyHex or signatureHex is empty, the +// function returns errs.ErrPluginUnsigned — install is refused. For +// non-stable plugins an empty key or signature skips verification with a +// warning (development workflow). +func verifyPluginSignature(archivePath, authorPublicKeyHex, signatureHex, publishStatus string) error { + if authorPublicKeyHex == "" || signatureHex == "" { + if publishStatus == "stable" { + return fmt.Errorf("plugin is missing required signature for stable publishStatus — install refused: %w", + errs.ErrPluginUnsigned) + } + return nil + } + + pkBytes, err := hex.DecodeString(authorPublicKeyHex) + if err != nil { + return fmt.Errorf("decoding author public key: %w", err) + } + if len(pkBytes) != ed25519.PublicKeySize { + return fmt.Errorf("author public key has wrong length: expected %d bytes, got %d", ed25519.PublicKeySize, len(pkBytes)) + } + pubKey := ed25519.PublicKey(pkBytes) + + sigBytes, err := hex.DecodeString(signatureHex) + if err != nil { + return fmt.Errorf("decoding plugin signature: %w", err) + } + if len(sigBytes) != ed25519.SignatureSize { + return fmt.Errorf("plugin signature has wrong length: expected %d bytes, got %d", ed25519.SignatureSize, len(sigBytes)) + } + + f, err := os.Open(archivePath) + if err != nil { + return fmt.Errorf("opening archive for signature verification: %w", err) + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return fmt.Errorf("hashing archive for signature verification: %w", err) + } + digest := h.Sum(nil) + + if !ed25519.Verify(pubKey, digest, sigBytes) { + return fmt.Errorf("plugin signature verification failed: tarball does not match registry signature (possible tampering)") + } + return nil +} + +// checkLicense validates that a license key exists and is acceptable. +// It checks all configured keys (multi-key support) against the local +// entitlement cache first, then falls through to remote validation. +func checkLicense(ctx context.Context, name string) error { + keys := license.CollectLicenseKeys() + + if len(keys) == 0 { + key := os.Getenv("NSELF_PLUGIN_LICENSE_KEY") + if key == "" { + keyPath := licenseKeyPath() + data, err := os.ReadFile(keyPath) + if err != nil { + return fmt.Errorf("plugin %q requires a license key. Run 'nself license add ' or visit %s", + name, "nself.org/pricing") + } + key = strings.TrimSpace(string(data)) + } + if key != "" { + keys = []string{key} + } + } + + if len(keys) == 0 { + return fmt.Errorf("plugin %q requires a license key. Run 'nself license add ' or visit %s", + name, "nself.org/pricing") + } + + cacheDir := licenseCacheDir() + + if allowed, found := checkEntitlements(cacheDir, name); found { + if allowed { + return nil + } + } + + var lastErr error + for _, key := range keys { + if err := validateLicenseFormat(key); err != nil { + continue + } + + if valid, found := checkLicenseCache(key, cacheDir); found { + if valid { + return nil + } + continue + } + + valid, lvr, err := validateLicenseRemoteWithEntitlements(ctx, key, pingAPIURL()) + if err != nil { + if strings.Contains(err.Error(), "expired") { + lastErr = fmt.Errorf("plugin %q: %w", name, errs.ErrLicenseExpired) + continue + } + if offlineValid, offlineFound := checkLicenseCacheOffline(key, cacheDir); offlineFound && offlineValid { + return nil + } + lastErr = fmt.Errorf("plugin %q: %w", name, errs.ErrLicenseNetworkUnavailable) + continue + } + _ = CacheLicense(key, valid, cacheDir) + + if lvr != nil && len(lvr.Plugins) > 0 { + _ = cacheEntitlements(cacheDir, lvr.Tier, lvr.Plugins) + for _, p := range lvr.Plugins { + if p == name { + return nil + } + } + } + + if valid { + lastErr = fmt.Errorf("plugin %q: %w", name, errs.ErrLicenseTierTooLow) + continue + } + lastErr = fmt.Errorf("plugin %q: license key is not valid", name) + } + + if lastErr != nil { + return lastErr + } + return fmt.Errorf("plugin %q requires a license key. Get one at %s", name, "nself.org/pricing") +} + +// CheckEOLBlock fetches the registry entry for name and returns an error if +// the plugin's status is "eol" and allowEOL is false. (S58-T03) +// If the registry cannot be reached or the plugin is not found, this function +// returns nil (install will fail downstream with a more specific error). +func CheckEOLBlock(ctx context.Context, name string, allowEOL bool) error { + cacheDir := defaultCacheDir() + reg, err := FetchRegistry(ctx, "", cacheDir) + if err != nil { + return nil + } + manifest, found := findPlugin(reg, name) + if !found { + return nil + } + if manifest.PublishStatus == "eol" && !allowEOL { + return fmt.Errorf( + "plugin %q has reached end-of-life and cannot be installed.\n"+ + "Use --allow-eol to override (not recommended).\n"+ + "Run 'nself plugin info %s' for details.", + name, name, + ) + } + return nil +} + +// checkAuthorRevocation fetches the author CRL from plugins.nself.org and +// returns an error if the plugin author appears in the revocation list. (S58-T09) +// Short-circuits on any network / parse error — install proceeds and the +// full install step will catch other issues. +func checkAuthorRevocation(ctx context.Context, author string) error { + if author == "" { + return nil + } + + url := "https://plugins.nself.org/.well-known/revoked-authors.json" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil + } + req.Header.Set("User-Agent", "nself-cli") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: could not fetch author revocation list (offline?): %v\n", err) + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil + } + + type revokedEntry struct { + AuthorKey string `json:"authorKey"` + RevokedAt string `json:"revokedAt"` + Reason string `json:"reason,omitempty"` + } + type crlResponse struct { + RevokedAuthors []revokedEntry `json:"revokedAuthors"` + } + + var crl crlResponse + body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) + if err != nil { + return nil + } + if err := json.Unmarshal(body, &crl); err != nil { + return nil + } + + for _, entry := range crl.RevokedAuthors { + if strings.EqualFold(entry.AuthorKey, author) { + msg := fmt.Sprintf( + "plugin author %q has been revoked and cannot be installed (revoked: %s).\n"+ + "Remove any plugins from this author and contact support@nself.org.\n"+ + "See https://nself.org/security/revocations for details.", + author, entry.RevokedAt, + ) + if entry.Reason != "" { + msg += "\nReason: " + entry.Reason + } + return fmt.Errorf("%s", msg) + } + } + return nil +} From a2c1fc904a8bb97bf64fe28c2e9b7c8d8fab4ed4 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Mon, 25 May 2026 17:38:35 -0400 Subject: [PATCH 3/3] refactor(multi): sweep 500-1000L god files in clawde/cli/plugins-pro (T-E2-06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split hardening_check.go (808L) → hardening_check_db.go / hardening_check_auth_net.go / hardening_check_infra.go / hardening_check_helpers.go in cli/internal/doctor. Split loader.go (942L) → loader_known_vars.go / loader_parse_env.go / loader_helpers.go in cli/internal/config. Split scaffold.go (938L) → scaffold_templates_infra.go / scaffold_templates_code.go in cli/internal/plugin/scaffold. Each file has a Purpose/Inputs/Outputs/Constraints/SPORT comment block. Build verified clean (go build ./...). --- internal/config/loader.go | 816 +-------------- internal/config/loader_helpers.go | 93 ++ internal/config/loader_known_vars.go | 398 +++++++ internal/config/loader_parse_env.go | 356 +++++++ internal/doctor/hardening_check.go | 976 +----------------- internal/doctor/hardening_check_auth_net.go | 323 ++++++ internal/doctor/hardening_check_db.go | 182 ++++ internal/doctor/hardening_check_helpers.go | 84 ++ internal/doctor/hardening_check_infra.go | 426 ++++++++ internal/plugin/scaffold/scaffold.go | 466 +-------- .../scaffold/scaffold_templates_code.go | 209 ++++ .../scaffold/scaffold_templates_infra.go | 272 +++++ 12 files changed, 2387 insertions(+), 2214 deletions(-) create mode 100644 internal/config/loader_helpers.go create mode 100644 internal/config/loader_known_vars.go create mode 100644 internal/config/loader_parse_env.go create mode 100644 internal/doctor/hardening_check_auth_net.go create mode 100644 internal/doctor/hardening_check_db.go create mode 100644 internal/doctor/hardening_check_helpers.go create mode 100644 internal/doctor/hardening_check_infra.go create mode 100644 internal/plugin/scaffold/scaffold_templates_code.go create mode 100644 internal/plugin/scaffold/scaffold_templates_infra.go diff --git a/internal/config/loader.go b/internal/config/loader.go index 20523131..676aa589 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -1,406 +1,30 @@ package config +// loader.go — env-cascade loader; entry point for config.Load(). +// +// Purpose: Orchestrate the nSelf .env file cascade: read files in precedence +// order, pass keys through warnUnknownEnvVars, then delegate to +// parseEnvToConfig (loader_parse_env.go) for struct population and +// ApplyDefaults (defaults.go) for smart defaults. +// Inputs: projectDir string — the nSelf working directory that contains .env.* +// files. ENV os env var controls which .env.{ENV} file is loaded. +// Outputs: *Config — fully populated and defaulted configuration struct. +// Constraints: Only Load() lives here. The env var list is in +// loader_known_vars.go; field mapping in loader_parse_env.go; +// collectPassthrough/parseInternalRoutes/parseExtensionList in +// loader_helpers.go. +// SPORT: cli/internal/config — decomposed from loader.go (T-E2-06). + import ( "bytes" "fmt" "log/slog" "os" "path/filepath" - "strings" "github.com/joho/godotenv" ) -// knownEnvVars is the authoritative list of every environment variable name -// that parseEnvToConfig (and ApplyDefaults) reads. Any key present in a .env -// file that is NOT in this list — and does not match a user-defined prefix -// (CS_*, FRONTEND_APP_*, etc.) — triggers a WarnUnknownEnvVars warning so -// users learn about typos early. -// -// Maintainers: keep this list in sync with parseEnvToConfig and defaults.go. -var knownEnvVars = []string{ - // Core - "PROJECT_NAME", - "BASE_DOMAIN", - "PROJECT_DOMAIN", - "ENV", - "PROJECT_DESCRIPTION", - "ADMIN_EMAIL", - "DB_ENV_SEEDS", - - // PostgreSQL - "POSTGRES_VERSION", - "POSTGRES_HOST", - "POSTGRES_PORT", - "POSTGRES_DB", - "POSTGRES_USER", - "POSTGRES_PASSWORD", - "POSTGRES_EXTENSIONS", - "POSTGRES_EXPOSE_PORT", - "POSTGRES_MEM_LIMIT", - "POSTGRES_CPU_LIMIT", - - // Hasura - "HASURA_VERSION", - "HASURA_GRAPHQL_ADMIN_SECRET", - "HASURA_JWT_KEY", - "HASURA_JWT_TYPE", - "HASURA_GRAPHQL_ENABLE_CONSOLE", - "HASURA_GRAPHQL_DEV_MODE", - "HASURA_DEV_MODE", - "HASURA_GRAPHQL_CORS_DOMAIN", - "HASURA_GRAPHQL_JWT_SECRET", - "HASURA_ROUTE", - "HASURA_PORT", - "HASURA_MEM_LIMIT", - "HASURA_CPU_LIMIT", - "HASURA_GRAPHQL_LOG_LEVEL", - - // Auth - "AUTH_VERSION", - "AUTH_PORT", - "AUTH_CLIENT_URL", - "AUTH_ACCESS_TOKEN_EXPIRES_IN", - "AUTH_REFRESH_TOKEN_EXPIRES_IN", - "AUTH_ROUTE", - "AUTH_SMTP_HOST", - "AUTH_SMTP_PORT", - "AUTH_SMTP_USER", - "AUTH_SMTP_PASS", - "AUTH_SMTP_SECURE", - "AUTH_SMTP_SENDER", - "AUTH_MEM_LIMIT", - "AUTH_CPU_LIMIT", - "AUTH_EXTRA_REDIRECT_URLS", - "AUTH_RATE_LIMIT", - "AUTH_WEBAUTHN_ENABLED", - "AUTH_LOG_LEVEL", - - // Nginx - "NGINX_VERSION", - "NGINX_HTTP_PORT", - "NGINX_PORT", - "NGINX_HTTPS_PORT", - "NGINX_SSL_PORT", - "NGINX_CLIENT_MAX_BODY_SIZE", - "NGINX_BIND_IP", - - // SSL - "SSL_MODE", - "EXTRA_SSL_DOMAINS", - - // Redis - "REDIS_ENABLED", - "REDIS_VERSION", - "REDIS_PORT", - "REDIS_PASSWORD", - "REDIS_MEMORY", - "REDIS_CPU", - - // MinIO / Storage - "MINIO_ENABLED", - "STORAGE_ENABLED", - "MINIO_VERSION", - "MINIO_PORT", - "MINIO_CONSOLE_PORT", - "MINIO_ROOT_USER", - "MINIO_ROOT_PASSWORD", - "MINIO_DEFAULT_BUCKETS", - "MINIO_REGION", - "S3_ACCESS_KEY", - "S3_SECRET_KEY", - "S3_BUCKET", - "STORAGE_VERSION", - "STORAGE_ROUTE", - "STORAGE_CONSOLE_ROUTE", - "MINIO_MEMORY", - "MINIO_CPU", - - // Mailpit - "MAILPIT_ENABLED", - "MAILPIT_VERSION", - "MAILPIT_SMTP_PORT", - "MAILPIT_UI_PORT", - "MAILPIT_MAX_MESSAGES", - "MAILPIT_ROUTE", - "MAIL_ROUTE", - "MAILPIT_UI_USER", - "MAILPIT_UI_PASSWORD", - - // Functions - "FUNCTIONS_ENABLED", - "FUNCTIONS_VERSION", - "FUNCTIONS_PORT", - "FUNCTIONS_ROUTE", - - // MLflow - "MLFLOW_ENABLED", - "MLFLOW_VERSION", - "MLFLOW_PORT", - "MLFLOW_ROUTE", - "MLFLOW_DB_NAME", - "MLFLOW_ARTIFACTS_BUCKET", - "MLFLOW_AUTH_ENABLED", - "MLFLOW_AUTH_USERNAME", - "MLFLOW_AUTH_PASSWORD", - - // Admin - "NSELF_ADMIN_ENABLED", - "NSELF_ADMIN_VERSION", - "NSELF_ADMIN_PORT", - "NSELF_ADMIN_ROUTE", - "NSELF_ADMIN_DEV", - "NSELF_ADMIN_DEV_PORT", - "ADMIN_SECRET_KEY", - "ADMIN_PASSWORD_HASH", - - // Search - "SEARCH_ENABLED", - "SEARCH_ENGINE", - "SEARCH_PROVIDER", - "SEARCH_PORT", - "SEARCH_API_KEY", - "SEARCH_ROUTE", - "SEARCH_INDEX_PREFIX", - "SEARCH_AUTO_INDEX", - "SEARCH_LANGUAGE", - "MEILISEARCH_VERSION", - "MEILISEARCH_MASTER_KEY", - "MEILISEARCH_ENV", - "MEILISEARCH_WARMUP_QUERIES", - "MEILI_ENV", - "TYPESENSE_VERSION", - "TYPESENSE_API_KEY", - "TYPESENSE_ENABLE_CORS", - "TYPESENSE_LOG_LEVEL", - "TYPESENSE_NUM_MEMORY_SHARDS", - "TYPESENSE_SNAPSHOT_INTERVAL_SECONDS", - "ELASTICSEARCH_VERSION", - "ELASTICSEARCH_PORT", - "ELASTICSEARCH_PASSWORD", - "ELASTICSEARCH_MEMORY", - - // Monitoring - "MONITORING_ENABLED", - "PROMETHEUS_ENABLED", - "PROMETHEUS_PORT", - "GRAFANA_ENABLED", - "GRAFANA_PORT", - "GRAFANA_ADMIN_USER", - "GRAFANA_ADMIN_PASSWORD", - "GRAFANA_ROUTE", - "LOKI_ENABLED", - "LOKI_PORT", - "PROMTAIL_ENABLED", - "TEMPO_ENABLED", - "TEMPO_PORT", - "ALERTMANAGER_ENABLED", - "ALERTMANAGER_PORT", - "CADVISOR_ENABLED", - "CADVISOR_PORT", - "NODE_EXPORTER_ENABLED", - "NODE_EXPORTER_PORT", - "POSTGRES_EXPORTER_ENABLED", - "POSTGRES_EXPORTER_PORT", - "REDIS_EXPORTER_ENABLED", - "REDIS_EXPORTER_PORT", - - // Email - "EMAIL_PROVIDER", - "EMAIL_FROM", - "ELASTIC_EMAIL_API_KEY", - "ELASTIC_EMAIL_ACCOUNT_EMAIL", - "SENDGRID_API_KEY", - "POSTMARK_API_KEY", - "MAILGUN_API_KEY", - "MAILGUN_DOMAIN", - "AWS_ACCESS_KEY_ID", - "AWS_SECRET_ACCESS_KEY", - "AWS_REGION", - "SMTP_HOST", - "SMTP_PORT", - "SMTP_USER", - "SMTP_PASS", - "SMTP_SECURE", - - // Backup - "BACKUP_ENABLED", - "BACKUP_DIR", - "BACKUP_SCHEDULE", - "BACKUP_RETENTION_DAYS", - "BACKUP_CLOUD_PROVIDER", - "BACKUP_REMOTE", - "BACKUP_ENCRYPTION", - "BACKUP_AGE_RECIPIENTS", - "BACKUP_SCHEDULE_FULL", - "BACKUP_WAL_INTERVAL_SECONDS", - "BACKUP_RETENTION_DAILY", - "BACKUP_RETENTION_WEEKLY", - "BACKUP_RETENTION_MONTHLY", - "BACKUP_RESTORE_TEST_SCHEDULE", - "BACKUP_ALERT_ON_FAILURE", - "BACKUP_S3_ACCESS_KEY_ID", - "BACKUP_S3_SECRET_ACCESS_KEY", - "BACKUP_S3_REGION", - "BACKUP_S3_ENDPOINT", - - // Disaster Recovery - "DR_SECONDARY_REGION", - "DR_STANDBY_HOST", - "DR_DRILL_SCHEDULE", - - // Plugin Pro - "NOTIFY_INTERNAL_SECRET", - "NOTIFY_PORT", - "NOTIFY_VAPID_PUBLIC_KEY", - "NOTIFY_VAPID_PRIVATE_KEY", - "NOTIFY_ROUTE", - "CRON_INTERNAL_SECRET", - "CRON_PORT", - "CRON_RETENTION_DAYS", - "PLUGIN_AI_MEMORY_LIMIT", - "PLUGIN_AI_CPU_LIMIT", - "PLUGIN_MUX_MEMORY_LIMIT", - "PLUGIN_MUX_CPU_LIMIT", - "PLUGIN_CLAW_MEMORY_LIMIT", - "PLUGIN_CLAW_CPU_LIMIT", - "PLUGIN_DEFAULT_MEMORY_LIMIT", - "PLUGIN_DEFAULT_CPU_LIMIT", - "PLUGIN_INTERNAL_SECRET", - - // Plugin System - "NSELF_PLUGIN_DIR", - "NSELF_PLUGIN_CACHE", - "NSELF_PLUGIN_REGISTRY", - "NSELF_REGISTRY_CACHE_TTL", - "NSELF_PLUGIN_LICENSE_KEY", - "NSELF_LICENSE_SKIP_VERIFY", - "NSELF_PING_API_URL", - "NSELF_PRICING_URL", - - // Docker - "DOCKER_NETWORK", - "DOCKER_LOG_MAX_SIZE", - "DOCKER_LOG_MAX_FILE", - "DOCKER_STOP_GRACE_PERIOD", - "NSELF_DOCKER_BUILD_TIMEOUT", - - // Start/Stop - "NSELF_START_MODE", - "NSELF_HEALTH_CHECK_TIMEOUT", - "NSELF_HEALTH_CHECK_INTERVAL", - "NSELF_HEALTH_CHECK_REQUIRED", - "NSELF_CLEANUP_ON_START", - "NSELF_ALLOW_EXPOSED_PORTS", - "NSELF_PARALLEL_LIMIT", - "NSELF_LOG_LEVEL", - "NSELF_SKIP_HEALTH_CHECKS", - "NSELF_STOP_TIMEOUT", - - // Plugin-managed: compose-injected vars that users may set in .env. - // The CLI loader does not read these; they are listed here only to suppress - // false "unknown env var" warnings from WarnUnknownEnvVars. - // Auth service (nHost auth container) — passed through compose template. - "AUTH_HOST", - "AUTH_SERVER_URL", - "AUTH_JWT_SECRET", - "AUTH_REFRESH_TOKEN_SECRET", - "AUTH_ACCESS_TOKEN_EXPIRY", - "AUTH_REFRESH_TOKEN_EXPIRY", - "AUTH_EMAIL_SIGNIN_EMAIL_VERIFIED_REQUIRED", - // Hasura container — passed through compose template. - "HASURA_GRAPHQL_ENABLE_TELEMETRY", - "HASURA_GRAPHQL_UNAUTHORIZED_ROLE", - "HASURA_CONSOLE_PORT", - "HASURA_GRAPHQL_JWT_SECRET", - "HASURA_GRAPHQL_DATABASE_URL", - "HASURA_METADATA_DATABASE_URL", - // Nginx compose template vars. - "NGINX_GZIP_ENABLED", - "NGINX_MODE", - "NGINX_MEM_LIMIT", - // MinIO/Storage — compose-computed. - "S3_ENDPOINT", - "STORAGE_PORT", - "FILES_ROUTE", - // nSelf Admin container. - "NSELF_ADMIN_USER", - "NSELF_ADMIN_PASSWORD", - // Typesense search provider (partial: TYPESENSE_PORT/ROUTE not in knownEnvVars struct). - "TYPESENSE_PORT", - "TYPESENSE_ROUTE", - // Notify plugin. - "NOTIFY_VAPID_SUBJECT", - // Docker Compose runtime vars (exported by shell wrapper, not read by loader). - "COMPOSE_PROJECT_NAME", - "DOCKER_BUILDKIT", - // Phase 14 CLI-command vars (read by cmd handlers, not by loader). - "NSELF_AUTO_TRUST_CA", - "NSELF_AUTO_HOSTS_ENTRIES", - "NSELF_MKCERT_CAROOT", - "NSELF_NO_MONOREPO", - // CLI tool behavior vars (read by main binary, not by loader). - "DEBUG", - "NO_COLOR", - // Postgres internal port (documentation-only; always 5432). - "POSTGRES_INTERNAL_PORT", - // MeiliSearch search engine (plugin-managed: injected into search compose template). - "MEILISEARCH_ENABLED", - "MEILISEARCH_PORT", - "MEILISEARCH_ROUTE", - "MEILI_NO_ANALYTICS", - // OpenSearch search provider (plugin-managed: opensearch plugin compose template). - "OPENSEARCH_VERSION", - "OPENSEARCH_PORT", - "OPENSEARCH_PASSWORD", - "OPENSEARCH_MEMORY", - // Zinc search provider (plugin-managed: zinc plugin compose template). - "ZINC_VERSION", - "ZINC_PORT", - "ZINC_ADMIN_USER", - "ZINC_ADMIN_PASSWORD", - // Sonic search provider (plugin-managed: sonic plugin compose template). - "SONIC_VERSION", - "SONIC_PORT", - "SONIC_PASSWORD", - // Dashboard plugin (plugin-managed: dashboard plugin compose template). - "DASHBOARD_ENABLED", - "DASHBOARD_VERSION", - "DASHBOARD_ROUTE", - "DASHBOARD_PORT", - // Legacy microservice system (plugin-managed: pre-CS_N system; may appear in old .env files). - "SERVICES_ENABLED", - "NESTJS_ENABLED", - "NESTJS_SERVICES", - "NESTJS_USE_TYPESCRIPT", - "NESTJS_PORT_START", - "BULLMQ_ENABLED", - "BULLMQ_WORKERS", - "BULLMQ_DASHBOARD_ENABLED", - "BULLMQ_DASHBOARD_PORT", - "BULLMQ_DASHBOARD_ROUTE", - "GOLANG_ENABLED", - "GOLANG_SERVICES", - "GOLANG_PORT_START", - "PYTHON_ENABLED", - "PYTHON_SERVICES", - "PYTHON_FRAMEWORK", - "PYTHON_PORT_START", - // Plugin integration vars (plugin-managed: stripe, github, shopify plugin compose templates). - "STRIPE_API_KEY", - "STRIPE_WEBHOOK_SECRET", - "STRIPE_SYNC_INTERVAL", - "GITHUB_TOKEN", - "GITHUB_WEBHOOK_SECRET", - "GITHUB_ORG", - "GITHUB_REPOS", - "SHOPIFY_STORE", - "SHOPIFY_ACCESS_TOKEN", - "SHOPIFY_API_VERSION", - "SHOPIFY_WEBHOOK_SECRET", - "SHOPIFY_SYNC_INTERVAL", -} - // Load reads the .env cascade from projectDir, populates a Config struct from // os.Getenv, applies smart defaults, and returns the complete configuration. // @@ -530,413 +154,3 @@ func Load(projectDir string) (*Config, error) { return cfg, nil } - -// parseEnvToConfig reads every Config field from os.Getenv using the helper -// functions (getEnvOr, getEnvInt, getEnvBool). This is the single place that -// maps environment variable names to struct fields. -func parseEnvToConfig() *Config { - cfg := &Config{} - - // ── Core ───────────────────────────────────────────────────────── - cfg.ProjectName = os.Getenv("PROJECT_NAME") - cfg.BaseDomain = os.Getenv("BASE_DOMAIN") - if cfg.BaseDomain == "" { - cfg.BaseDomain = os.Getenv("PROJECT_DOMAIN") - } - cfg.Env = normalizeEnv(getEnvOr("ENV", "dev")) - cfg.ProjectDescription = os.Getenv("PROJECT_DESCRIPTION") - cfg.AdminEmail = os.Getenv("ADMIN_EMAIL") - cfg.DBEnvSeeds = getEnvBool("DB_ENV_SEEDS", true) - - // ── PostgreSQL ─────────────────────────────────────────────────── - cfg.Postgres = PostgresConfig{ - Version: os.Getenv("POSTGRES_VERSION"), - Host: os.Getenv("POSTGRES_HOST"), - Port: getEnvInt("POSTGRES_PORT", 0), - DB: os.Getenv("POSTGRES_DB"), - User: os.Getenv("POSTGRES_USER"), - Password: os.Getenv("POSTGRES_PASSWORD"), - Extensions: parseExtensionList(getEnvOr("POSTGRES_EXTENSIONS", "uuid-ossp,pgcrypto,pg_trgm")), - ExposePort: os.Getenv("POSTGRES_EXPOSE_PORT"), - MemLimit: os.Getenv("POSTGRES_MEM_LIMIT"), - CPULimit: os.Getenv("POSTGRES_CPU_LIMIT"), - } - - // ── Hasura ─────────────────────────────────────────────────────── - cfg.Hasura = HasuraConfig{ - Version: os.Getenv("HASURA_VERSION"), - AdminSecret: os.Getenv("HASURA_GRAPHQL_ADMIN_SECRET"), - JWTKey: os.Getenv("HASURA_JWT_KEY"), - JWTType: os.Getenv("HASURA_JWT_TYPE"), - Console: getEnvBool("HASURA_GRAPHQL_ENABLE_CONSOLE", false), - DevMode: getEnvBool("HASURA_GRAPHQL_DEV_MODE", false), - CORSDomain: os.Getenv("HASURA_GRAPHQL_CORS_DOMAIN"), - Route: os.Getenv("HASURA_ROUTE"), - Port: getEnvInt("HASURA_PORT", 0), - MemLimit: os.Getenv("HASURA_MEM_LIMIT"), - CPULimit: os.Getenv("HASURA_CPU_LIMIT"), - LogLevel: os.Getenv("HASURA_GRAPHQL_LOG_LEVEL"), - } - // HASURA_DEV_MODE backward-compat alias: v1 used HASURA_DEV_MODE, v2 uses HASURA_GRAPHQL_DEV_MODE. - // Only apply alias if HASURA_GRAPHQL_DEV_MODE was not explicitly set. - if alias := os.Getenv("HASURA_DEV_MODE"); alias != "" { - if _, explicitly := os.LookupEnv("HASURA_GRAPHQL_DEV_MODE"); !explicitly { - cfg.Hasura.DevMode = alias == "true" || alias == "1" || alias == "yes" - } - } - - // ── Auth ───────────────────────────────────────────────────────── - cfg.Auth = AuthConfig{ - Version: os.Getenv("AUTH_VERSION"), - Port: getEnvInt("AUTH_PORT", 0), - ClientURL: os.Getenv("AUTH_CLIENT_URL"), - AccessTokenExpiry: getEnvInt("AUTH_ACCESS_TOKEN_EXPIRES_IN", 0), - RefreshTokenExpiry: getEnvInt("AUTH_REFRESH_TOKEN_EXPIRES_IN", 0), - Route: os.Getenv("AUTH_ROUTE"), - SMTPHost: os.Getenv("AUTH_SMTP_HOST"), - SMTPPort: getEnvInt("AUTH_SMTP_PORT", 0), - SMTPUser: os.Getenv("AUTH_SMTP_USER"), - SMTPPass: os.Getenv("AUTH_SMTP_PASS"), - SMTPSecure: getEnvBool("AUTH_SMTP_SECURE", false), - SMTPSender: os.Getenv("AUTH_SMTP_SENDER"), - MemLimit: os.Getenv("AUTH_MEM_LIMIT"), - CPULimit: os.Getenv("AUTH_CPU_LIMIT"), - ExtraRedirectURLs: os.Getenv("AUTH_EXTRA_REDIRECT_URLS"), - WebAuthnEnabled: getEnvBool("AUTH_WEBAUTHN_ENABLED", false), - LogLevel: os.Getenv("AUTH_LOG_LEVEL"), - } - - // ── Nginx ──────────────────────────────────────────────────────── - cfg.Nginx = NginxConfig{ - Version: os.Getenv("NGINX_VERSION"), - HTTPPort: getEnvInt("NGINX_HTTP_PORT", getEnvInt("NGINX_PORT", 0)), - SSLPort: getEnvInt("NGINX_HTTPS_PORT", getEnvInt("NGINX_SSL_PORT", 0)), - MaxBody: os.Getenv("NGINX_CLIENT_MAX_BODY_SIZE"), - BindIP: os.Getenv("NGINX_BIND_IP"), - AuthRateLimit: os.Getenv("AUTH_RATE_LIMIT"), - RateLimitAPI: os.Getenv("RATE_LIMIT_API_RPS"), - RateLimitAuth: os.Getenv("RATE_LIMIT_AUTH_RPS"), - RateLimitAI: os.Getenv("RATE_LIMIT_AI_RPS"), - } - - // ── SSL ────────────────────────────────────────────────────────── - cfg.SSLMode = os.Getenv("SSL_MODE") - cfg.SSLProvider = os.Getenv("SSL_PROVIDER") - cfg.SSLWildcardDomain = os.Getenv("SSL_WILDCARD_DOMAIN") - cfg.ExtraSSLDomains = os.Getenv("EXTRA_SSL_DOMAINS") - cfg.CloudflareAPIKey = os.Getenv("CLOUDFLARE_API_KEY") - - // ── WAF ────────────────────────────────────────────────────────── - cfg.WAFMode = os.Getenv("WAF_MODE") - - // ── Redis ──────────────────────────────────────────────────────── - cfg.Redis = RedisConfig{ - Enabled: getEnvBool("REDIS_ENABLED", false), - Version: os.Getenv("REDIS_VERSION"), - Port: getEnvInt("REDIS_PORT", 0), - Password: os.Getenv("REDIS_PASSWORD"), - Memory: os.Getenv("REDIS_MEMORY"), - CPU: os.Getenv("REDIS_CPU"), - } - - // ── MinIO / Storage ────────────────────────────────────────────── - // Backward compat: STORAGE_ENABLED=true implies MINIO_ENABLED=true. - minioEnabled := getEnvBool("MINIO_ENABLED", false) || getEnvBool("STORAGE_ENABLED", false) - cfg.Minio = MinioConfig{ - Enabled: minioEnabled, - Version: os.Getenv("MINIO_VERSION"), - Port: getEnvInt("MINIO_PORT", 0), - ConsolePort: getEnvInt("MINIO_CONSOLE_PORT", 0), - RootUser: os.Getenv("MINIO_ROOT_USER"), - RootPassword: os.Getenv("MINIO_ROOT_PASSWORD"), - DefaultBuckets: os.Getenv("MINIO_DEFAULT_BUCKETS"), - Region: os.Getenv("MINIO_REGION"), - S3AccessKey: os.Getenv("S3_ACCESS_KEY"), - S3SecretKey: os.Getenv("S3_SECRET_KEY"), - S3Bucket: os.Getenv("S3_BUCKET"), - StorageVersion: os.Getenv("STORAGE_VERSION"), - StorageRoute: os.Getenv("STORAGE_ROUTE"), - ConsoleRoute: os.Getenv("STORAGE_CONSOLE_ROUTE"), - MemLimit: os.Getenv("MINIO_MEMORY"), - CPULimit: os.Getenv("MINIO_CPU"), - } - - // ── Mailpit ────────────────────────────────────────────────────── - cfg.Mailpit = MailpitConfig{ - Enabled: getEnvBool("MAILPIT_ENABLED", false), - Version: os.Getenv("MAILPIT_VERSION"), - SMTPPort: getEnvInt("MAILPIT_SMTP_PORT", 0), - UIPort: getEnvInt("MAILPIT_UI_PORT", 0), - MaxMessages: getEnvInt("MAILPIT_MAX_MESSAGES", 0), - Route: getEnvOr("MAILPIT_ROUTE", os.Getenv("MAIL_ROUTE")), - UIUser: getEnvOr("MAILPIT_UI_USER", "admin"), - UIPassword: os.Getenv("MAILPIT_UI_PASSWORD"), - } - - // ── Functions ──────────────────────────────────────────────────── - cfg.Functions = FunctionsConfig{ - Enabled: getEnvBool("FUNCTIONS_ENABLED", false), - Version: os.Getenv("FUNCTIONS_VERSION"), - Port: getEnvInt("FUNCTIONS_PORT", 0), - Route: os.Getenv("FUNCTIONS_ROUTE"), - } - - // ── MLflow ─────────────────────────────────────────────────────── - cfg.MLflow = MLflowConfig{ - Enabled: getEnvBool("MLFLOW_ENABLED", false), - Version: os.Getenv("MLFLOW_VERSION"), - Port: getEnvInt("MLFLOW_PORT", 0), - Route: os.Getenv("MLFLOW_ROUTE"), - DBName: os.Getenv("MLFLOW_DB_NAME"), - ArtifactsBucket: os.Getenv("MLFLOW_ARTIFACTS_BUCKET"), - AuthEnabled: getEnvBool("MLFLOW_AUTH_ENABLED", false), - AuthUsername: os.Getenv("MLFLOW_AUTH_USERNAME"), - AuthPassword: os.Getenv("MLFLOW_AUTH_PASSWORD"), - } - - // ── Admin ──────────────────────────────────────────────────────── - cfg.Admin = AdminConfig{ - Enabled: getEnvBool("NSELF_ADMIN_ENABLED", false), - Version: os.Getenv("NSELF_ADMIN_VERSION"), - Port: getEnvInt("NSELF_ADMIN_PORT", 0), - Route: os.Getenv("NSELF_ADMIN_ROUTE"), - DevMode: getEnvBool("NSELF_ADMIN_DEV", false), - DevPort: getEnvInt("NSELF_ADMIN_DEV_PORT", 0), - SecretKey: os.Getenv("ADMIN_SECRET_KEY"), - PasswordHash: os.Getenv("ADMIN_PASSWORD_HASH"), - } - - // ── Search (provider-agnostic) ─────────────────────────────────── - cfg.Search = SearchConfig{ - Enabled: getEnvBool("SEARCH_ENABLED", false), - Engine: getEnvOr("SEARCH_ENGINE", os.Getenv("SEARCH_PROVIDER")), - Port: getEnvInt("SEARCH_PORT", 0), - APIKey: os.Getenv("SEARCH_API_KEY"), - Route: os.Getenv("SEARCH_ROUTE"), - IndexPrefix: os.Getenv("SEARCH_INDEX_PREFIX"), - AutoIndex: getEnvBool("SEARCH_AUTO_INDEX", true), - Language: os.Getenv("SEARCH_LANGUAGE"), - MeiliSearch: MeiliSearchConfig{ - Version: os.Getenv("MEILISEARCH_VERSION"), - MasterKey: os.Getenv("MEILISEARCH_MASTER_KEY"), - Env: getEnvOr("MEILISEARCH_ENV", os.Getenv("MEILI_ENV")), - }, - Typesense: TypesenseConfig{ - Version: os.Getenv("TYPESENSE_VERSION"), - APIKey: os.Getenv("TYPESENSE_API_KEY"), - EnableCORS: getEnvBool("TYPESENSE_ENABLE_CORS", false), - LogLevel: os.Getenv("TYPESENSE_LOG_LEVEL"), - NumMemoryShards: getEnvInt("TYPESENSE_NUM_MEMORY_SHARDS", 0), - SnapshotIntervalS: getEnvInt("TYPESENSE_SNAPSHOT_INTERVAL_SECONDS", 0), - }, - Elasticsearch: ElasticsearchConfig{ - Version: os.Getenv("ELASTICSEARCH_VERSION"), - Port: getEnvInt("ELASTICSEARCH_PORT", 0), - Password: os.Getenv("ELASTICSEARCH_PASSWORD"), - Memory: os.Getenv("ELASTICSEARCH_MEMORY"), - }, - } - - // ── Monitoring ─────────────────────────────────────────────────── - cfg.Monitoring = MonitoringConfig{ - Enabled: getEnvBool("MONITORING_ENABLED", false), - PrometheusEnabled: getEnvBool("PROMETHEUS_ENABLED", false), - PrometheusPort: getEnvInt("PROMETHEUS_PORT", 0), - GrafanaEnabled: getEnvBool("GRAFANA_ENABLED", false), - GrafanaPort: getEnvInt("GRAFANA_PORT", 0), - GrafanaAdminUser: os.Getenv("GRAFANA_ADMIN_USER"), - GrafanaAdminPassword: os.Getenv("GRAFANA_ADMIN_PASSWORD"), - GrafanaRoute: os.Getenv("GRAFANA_ROUTE"), - LokiEnabled: getEnvBool("LOKI_ENABLED", false), - LokiPort: getEnvInt("LOKI_PORT", 0), - PromtailEnabled: getEnvBool("PROMTAIL_ENABLED", false), - TempoEnabled: getEnvBool("TEMPO_ENABLED", false), - TempoPort: getEnvInt("TEMPO_PORT", 0), - AlertmanagerEnabled: getEnvBool("ALERTMANAGER_ENABLED", false), - AlertmanagerPort: getEnvInt("ALERTMANAGER_PORT", 0), - CadvisorEnabled: getEnvBool("CADVISOR_ENABLED", false), - CadvisorPort: getEnvInt("CADVISOR_PORT", 0), - NodeExporterEnabled: getEnvBool("NODE_EXPORTER_ENABLED", false), - NodeExporterPort: getEnvInt("NODE_EXPORTER_PORT", 0), - PGExporterEnabled: getEnvBool("POSTGRES_EXPORTER_ENABLED", false), - PGExporterPort: getEnvInt("POSTGRES_EXPORTER_PORT", 0), - RedisExporterEnabled: getEnvBool("REDIS_EXPORTER_ENABLED", false), - RedisExporterPort: getEnvInt("REDIS_EXPORTER_PORT", 0), - } - - // ── Email ──────────────────────────────────────────────────────── - cfg.Email = EmailConfig{ - Provider: os.Getenv("EMAIL_PROVIDER"), - From: os.Getenv("EMAIL_FROM"), - ElasticEmailAPIKey: os.Getenv("ELASTIC_EMAIL_API_KEY"), - ElasticEmailAccount: os.Getenv("ELASTIC_EMAIL_ACCOUNT_EMAIL"), - SendGridAPIKey: os.Getenv("SENDGRID_API_KEY"), - PostmarkAPIKey: os.Getenv("POSTMARK_API_KEY"), - MailgunAPIKey: os.Getenv("MAILGUN_API_KEY"), - MailgunDomain: os.Getenv("MAILGUN_DOMAIN"), - AWSAccessKeyID: os.Getenv("AWS_ACCESS_KEY_ID"), - AWSSecretAccessKey: os.Getenv("AWS_SECRET_ACCESS_KEY"), - AWSRegion: os.Getenv("AWS_REGION"), - SMTPHost: os.Getenv("SMTP_HOST"), - SMTPPort: getEnvInt("SMTP_PORT", 0), - SMTPUser: os.Getenv("SMTP_USER"), - SMTPPass: os.Getenv("SMTP_PASS"), - SMTPSecure: getEnvBool("SMTP_SECURE", false), - } - - // ── Backup ─────────────────────────────────────────────────────── - cfg.Backup = BackupConfig{ - Enabled: getEnvBool("BACKUP_ENABLED", false), - Dir: os.Getenv("BACKUP_DIR"), - Schedule: os.Getenv("BACKUP_SCHEDULE"), - RetentionDays: getEnvInt("BACKUP_RETENTION_DAYS", 0), - CloudProvider: os.Getenv("BACKUP_CLOUD_PROVIDER"), - Remote: os.Getenv("BACKUP_REMOTE"), - Encryption: getEnvBool("BACKUP_ENCRYPTION", false), - AgeRecipients: os.Getenv("BACKUP_AGE_RECIPIENTS"), - ScheduleFull: os.Getenv("BACKUP_SCHEDULE_FULL"), - WALInterval: getEnvInt("BACKUP_WAL_INTERVAL_SECONDS", 0), - RetentionDaily: getEnvInt("BACKUP_RETENTION_DAILY", 0), - RetentionWeekly: getEnvInt("BACKUP_RETENTION_WEEKLY", 0), - RetentionMonthly: getEnvInt("BACKUP_RETENTION_MONTHLY", 0), - RestoreTestSchedule: os.Getenv("BACKUP_RESTORE_TEST_SCHEDULE"), - AlertOnFailure: getEnvBool("BACKUP_ALERT_ON_FAILURE", true), - S3AccessKeyID: os.Getenv("BACKUP_S3_ACCESS_KEY_ID"), - S3SecretAccessKey: os.Getenv("BACKUP_S3_SECRET_ACCESS_KEY"), - S3Region: os.Getenv("BACKUP_S3_REGION"), - S3Endpoint: os.Getenv("BACKUP_S3_ENDPOINT"), - } - - cfg.DR = DRConfig{ - SecondaryRegion: os.Getenv("DR_SECONDARY_REGION"), - StandbyHost: os.Getenv("DR_STANDBY_HOST"), - DrillSchedule: os.Getenv("DR_DRILL_SCHEDULE"), - } - - // Plugin port defaults (3712=notify, 3713=cron) are set in ApplyDefaults() when port==0. - // ── Plugin Pro Configuration ───────────────────────────────────── - cfg.PluginConfig = PluginProConfig{ - NotifySecret: os.Getenv("NOTIFY_INTERNAL_SECRET"), - NotifyPort: getEnvInt("NOTIFY_PORT", 0), - NotifyVAPIDPub: os.Getenv("NOTIFY_VAPID_PUBLIC_KEY"), - NotifyVAPIDPriv: os.Getenv("NOTIFY_VAPID_PRIVATE_KEY"), - NotifyRoute: os.Getenv("NOTIFY_ROUTE"), - CronSecret: os.Getenv("CRON_INTERNAL_SECRET"), - CronPort: getEnvInt("CRON_PORT", 0), - CronRetention: getEnvInt("CRON_RETENTION_DAYS", 0), - AIMemLimit: os.Getenv("PLUGIN_AI_MEMORY_LIMIT"), - AICPULimit: os.Getenv("PLUGIN_AI_CPU_LIMIT"), - MuxMemLimit: os.Getenv("PLUGIN_MUX_MEMORY_LIMIT"), - MuxCPULimit: os.Getenv("PLUGIN_MUX_CPU_LIMIT"), - ClawMemLimit: os.Getenv("PLUGIN_CLAW_MEMORY_LIMIT"), - ClawCPULimit: os.Getenv("PLUGIN_CLAW_CPU_LIMIT"), - DefaultMemLimit: os.Getenv("PLUGIN_DEFAULT_MEMORY_LIMIT"), - DefaultCPULimit: os.Getenv("PLUGIN_DEFAULT_CPU_LIMIT"), - } - - // ── Plugin System ──────────────────────────────────────────────── - cfg.PluginSystem = PluginSystemConfig{ - Dir: os.Getenv("NSELF_PLUGIN_DIR"), - Cache: os.Getenv("NSELF_PLUGIN_CACHE"), - Registry: os.Getenv("NSELF_PLUGIN_REGISTRY"), - CacheTTL: getEnvInt("NSELF_REGISTRY_CACHE_TTL", 0), - LicenseKey: os.Getenv("NSELF_PLUGIN_LICENSE_KEY"), - SkipVerify: getEnvBool("NSELF_LICENSE_SKIP_VERIFY", false), - PingURL: os.Getenv("NSELF_PING_API_URL"), - PricingURL: os.Getenv("NSELF_PRICING_URL"), - InternalSecret: os.Getenv("PLUGIN_INTERNAL_SECRET"), - } - - // ── Docker ─────────────────────────────────────────────────────── - cfg.DockerNetwork = os.Getenv("DOCKER_NETWORK") - cfg.DockerLogMaxSize = os.Getenv("DOCKER_LOG_MAX_SIZE") - cfg.DockerLogMaxFile = os.Getenv("DOCKER_LOG_MAX_FILE") - cfg.DockerStopGrace = os.Getenv("DOCKER_STOP_GRACE_PERIOD") - cfg.DockerBuildTimeout = getEnvInt("NSELF_DOCKER_BUILD_TIMEOUT", 0) - - // ── Start/Stop ─────────────────────────────────────────────────── - cfg.StartMode = os.Getenv("NSELF_START_MODE") - cfg.HealthCheckTimeout = getEnvInt("NSELF_HEALTH_CHECK_TIMEOUT", 0) - cfg.HealthCheckInterval = getEnvInt("NSELF_HEALTH_CHECK_INTERVAL", 0) - cfg.HealthCheckRequired = getEnvInt("NSELF_HEALTH_CHECK_REQUIRED", 0) - cfg.CleanupOnStart = os.Getenv("NSELF_CLEANUP_ON_START") - cfg.AllowExposedPorts = getEnvBool("NSELF_ALLOW_EXPOSED_PORTS", false) - cfg.ParallelLimit = getEnvInt("NSELF_PARALLEL_LIMIT", 0) - cfg.LogLevel = os.Getenv("NSELF_LOG_LEVEL") - cfg.SkipHealthChecks = getEnvBool("NSELF_SKIP_HEALTH_CHECKS", false) - cfg.StopTimeout = getEnvInt("NSELF_STOP_TIMEOUT", 0) - - return cfg -} - -// collectPassthrough scans the full environment for dynamic env vars matching -// known prefixes (AUTH_PROVIDER_*, REMOTE_SCHEMA_*, HASURA_EXTRA_*) and returns -// them as a key-value map. These variables cannot be predefined in structs -// because users add them dynamically for OAuth providers, remote schemas, etc. -func collectPassthrough(environ []string) map[string]string { - prefixes := []string{ - "AUTH_PROVIDER_", - "REMOTE_SCHEMA_", - "HASURA_EXTRA_", - } - result := make(map[string]string) - for _, env := range environ { - parts := strings.SplitN(env, "=", 2) - if len(parts) != 2 { - continue - } - for _, prefix := range prefixes { - if strings.HasPrefix(parts[0], prefix) { - result[parts[0]] = parts[1] - } - } - } - return result -} - -// parseInternalRoutes parses INTERNAL_ROUTE_1 through INTERNAL_ROUTE_20 -// environment variables into InternalRoute structs. Each route is defined by: -// -// INTERNAL_ROUTE_N_NAME — required (skip if empty) -// INTERNAL_ROUTE_N_SUBDOMAIN -// INTERNAL_ROUTE_N_TARGET — e.g., hasura:8080 -// INTERNAL_ROUTE_N_RATE_ZONE — default: general -// INTERNAL_ROUTE_N_WEBSOCKET — bool -func parseInternalRoutes() []InternalRoute { - var routes []InternalRoute - for i := 1; i <= 20; i++ { - prefix := fmt.Sprintf("INTERNAL_ROUTE_%d_", i) - name := os.Getenv(prefix + "NAME") - if name == "" { - continue - } - - route := InternalRoute{ - Index: i, - Name: name, - Subdomain: os.Getenv(prefix + "SUBDOMAIN"), - Target: os.Getenv(prefix + "TARGET"), - RateZone: getEnvOr(prefix+"RATE_ZONE", "general"), - WebSocket: getEnvBool(prefix+"WEBSOCKET", false), - } - routes = append(routes, route) - } - return routes -} - -// parseExtensionList parses a comma-separated extension list string into a slice. -// Trims whitespace from each element and removes empty entries. -func parseExtensionList(s string) []string { - if s == "" { - return nil - } - parts := strings.Split(s, ",") - result := make([]string, 0, len(parts)) - for _, p := range parts { - p = strings.TrimSpace(p) - if p != "" { - result = append(result, p) - } - } - return result -} diff --git a/internal/config/loader_helpers.go b/internal/config/loader_helpers.go new file mode 100644 index 00000000..4b12237d --- /dev/null +++ b/internal/config/loader_helpers.go @@ -0,0 +1,93 @@ +package config + +// loader_helpers.go — small parsing helpers used by the config loader. +// +// Purpose: Three focused helpers that parse dynamic or structured env var sets +// that cannot be reduced to a single getEnvOr call. Each lives here +// to keep loader.go and loader_parse_env.go focused on orchestration +// and field mapping respectively. +// Inputs: os.Environ (collectPassthrough) and os.Getenv (parseInternalRoutes, +// parseExtensionList) — no filesystem access. +// Outputs: collectPassthrough → map[string]string; parseInternalRoutes → +// []InternalRoute; parseExtensionList → []string. +// Constraints: No side effects beyond returning values. Must not call +// ApplyDefaults or touch the filesystem. +// SPORT: cli/internal/config — decomposed from loader.go (T-E2-06). + +import ( + "fmt" + "os" + "strings" +) + +// collectPassthrough scans the full environment for dynamic env vars matching +// known prefixes (AUTH_PROVIDER_*, REMOTE_SCHEMA_*, HASURA_EXTRA_*) and returns +// them as a key-value map. These variables cannot be predefined in structs +// because users add them dynamically for OAuth providers, remote schemas, etc. +func collectPassthrough(environ []string) map[string]string { + prefixes := []string{ + "AUTH_PROVIDER_", + "REMOTE_SCHEMA_", + "HASURA_EXTRA_", + } + result := make(map[string]string) + for _, env := range environ { + parts := strings.SplitN(env, "=", 2) + if len(parts) != 2 { + continue + } + for _, prefix := range prefixes { + if strings.HasPrefix(parts[0], prefix) { + result[parts[0]] = parts[1] + } + } + } + return result +} + +// parseInternalRoutes parses INTERNAL_ROUTE_1 through INTERNAL_ROUTE_20 +// environment variables into InternalRoute structs. Each route is defined by: +// +// INTERNAL_ROUTE_N_NAME — required (skip if empty) +// INTERNAL_ROUTE_N_SUBDOMAIN +// INTERNAL_ROUTE_N_TARGET — e.g., hasura:8080 +// INTERNAL_ROUTE_N_RATE_ZONE — default: general +// INTERNAL_ROUTE_N_WEBSOCKET — bool +func parseInternalRoutes() []InternalRoute { + var routes []InternalRoute + for i := 1; i <= 20; i++ { + prefix := fmt.Sprintf("INTERNAL_ROUTE_%d_", i) + name := os.Getenv(prefix + "NAME") + if name == "" { + continue + } + + route := InternalRoute{ + Index: i, + Name: name, + Subdomain: os.Getenv(prefix + "SUBDOMAIN"), + Target: os.Getenv(prefix + "TARGET"), + RateZone: getEnvOr(prefix+"RATE_ZONE", "general"), + WebSocket: getEnvBool(prefix+"WEBSOCKET", false), + } + routes = append(routes, route) + } + return routes +} + +// parseExtensionList parses a comma-separated extension list string into a slice. +// Trims whitespace from each element and removes empty entries. +func parseExtensionList(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} diff --git a/internal/config/loader_known_vars.go b/internal/config/loader_known_vars.go new file mode 100644 index 00000000..1366070b --- /dev/null +++ b/internal/config/loader_known_vars.go @@ -0,0 +1,398 @@ +package config + +// loader_known_vars.go — authoritative list of known environment variable names. +// +// Purpose: Enumerate every env var name that the nSelf config loader and +// ApplyDefaults recognise. Used by warnUnknownEnvVars to surface +// typos in user .env files (any key not in this list and not matching +// a dynamic prefix emits a slog.Warn — it never fails the load). +// Inputs: none (package-level var, referenced by loader.go and defaults.go). +// Outputs: knownEnvVars []string — consumed by warnUnknownEnvVars in warn.go. +// Constraints: Keep in sync with parseEnvToConfig (loader_parse_env.go) and +// ApplyDefaults (defaults.go). Plugin-managed vars that the CLI +// loader does NOT read are included at the bottom to suppress false +// "unknown env var" warnings from compose-injected config. +// SPORT: cli/internal/config — decomposed from loader.go (T-E2-06). + +var knownEnvVars = []string{ + // Core + "PROJECT_NAME", + "BASE_DOMAIN", + "PROJECT_DOMAIN", + "ENV", + "PROJECT_DESCRIPTION", + "ADMIN_EMAIL", + "DB_ENV_SEEDS", + + // PostgreSQL + "POSTGRES_VERSION", + "POSTGRES_HOST", + "POSTGRES_PORT", + "POSTGRES_DB", + "POSTGRES_USER", + "POSTGRES_PASSWORD", + "POSTGRES_EXTENSIONS", + "POSTGRES_EXPOSE_PORT", + "POSTGRES_MEM_LIMIT", + "POSTGRES_CPU_LIMIT", + + // Hasura + "HASURA_VERSION", + "HASURA_GRAPHQL_ADMIN_SECRET", + "HASURA_JWT_KEY", + "HASURA_JWT_TYPE", + "HASURA_GRAPHQL_ENABLE_CONSOLE", + "HASURA_GRAPHQL_DEV_MODE", + "HASURA_DEV_MODE", + "HASURA_GRAPHQL_CORS_DOMAIN", + "HASURA_GRAPHQL_JWT_SECRET", + "HASURA_ROUTE", + "HASURA_PORT", + "HASURA_MEM_LIMIT", + "HASURA_CPU_LIMIT", + "HASURA_GRAPHQL_LOG_LEVEL", + + // Auth + "AUTH_VERSION", + "AUTH_PORT", + "AUTH_CLIENT_URL", + "AUTH_ACCESS_TOKEN_EXPIRES_IN", + "AUTH_REFRESH_TOKEN_EXPIRES_IN", + "AUTH_ROUTE", + "AUTH_SMTP_HOST", + "AUTH_SMTP_PORT", + "AUTH_SMTP_USER", + "AUTH_SMTP_PASS", + "AUTH_SMTP_SECURE", + "AUTH_SMTP_SENDER", + "AUTH_MEM_LIMIT", + "AUTH_CPU_LIMIT", + "AUTH_EXTRA_REDIRECT_URLS", + "AUTH_RATE_LIMIT", + "AUTH_WEBAUTHN_ENABLED", + "AUTH_LOG_LEVEL", + + // Nginx + "NGINX_VERSION", + "NGINX_HTTP_PORT", + "NGINX_PORT", + "NGINX_HTTPS_PORT", + "NGINX_SSL_PORT", + "NGINX_CLIENT_MAX_BODY_SIZE", + "NGINX_BIND_IP", + + // SSL + "SSL_MODE", + "EXTRA_SSL_DOMAINS", + + // Redis + "REDIS_ENABLED", + "REDIS_VERSION", + "REDIS_PORT", + "REDIS_PASSWORD", + "REDIS_MEMORY", + "REDIS_CPU", + + // MinIO / Storage + "MINIO_ENABLED", + "STORAGE_ENABLED", + "MINIO_VERSION", + "MINIO_PORT", + "MINIO_CONSOLE_PORT", + "MINIO_ROOT_USER", + "MINIO_ROOT_PASSWORD", + "MINIO_DEFAULT_BUCKETS", + "MINIO_REGION", + "S3_ACCESS_KEY", + "S3_SECRET_KEY", + "S3_BUCKET", + "STORAGE_VERSION", + "STORAGE_ROUTE", + "STORAGE_CONSOLE_ROUTE", + "MINIO_MEMORY", + "MINIO_CPU", + + // Mailpit + "MAILPIT_ENABLED", + "MAILPIT_VERSION", + "MAILPIT_SMTP_PORT", + "MAILPIT_UI_PORT", + "MAILPIT_MAX_MESSAGES", + "MAILPIT_ROUTE", + "MAIL_ROUTE", + "MAILPIT_UI_USER", + "MAILPIT_UI_PASSWORD", + + // Functions + "FUNCTIONS_ENABLED", + "FUNCTIONS_VERSION", + "FUNCTIONS_PORT", + "FUNCTIONS_ROUTE", + + // MLflow + "MLFLOW_ENABLED", + "MLFLOW_VERSION", + "MLFLOW_PORT", + "MLFLOW_ROUTE", + "MLFLOW_DB_NAME", + "MLFLOW_ARTIFACTS_BUCKET", + "MLFLOW_AUTH_ENABLED", + "MLFLOW_AUTH_USERNAME", + "MLFLOW_AUTH_PASSWORD", + + // Admin + "NSELF_ADMIN_ENABLED", + "NSELF_ADMIN_VERSION", + "NSELF_ADMIN_PORT", + "NSELF_ADMIN_ROUTE", + "NSELF_ADMIN_DEV", + "NSELF_ADMIN_DEV_PORT", + "ADMIN_SECRET_KEY", + "ADMIN_PASSWORD_HASH", + + // Search + "SEARCH_ENABLED", + "SEARCH_ENGINE", + "SEARCH_PROVIDER", + "SEARCH_PORT", + "SEARCH_API_KEY", + "SEARCH_ROUTE", + "SEARCH_INDEX_PREFIX", + "SEARCH_AUTO_INDEX", + "SEARCH_LANGUAGE", + "MEILISEARCH_VERSION", + "MEILISEARCH_MASTER_KEY", + "MEILISEARCH_ENV", + "MEILISEARCH_WARMUP_QUERIES", + "MEILI_ENV", + "TYPESENSE_VERSION", + "TYPESENSE_API_KEY", + "TYPESENSE_ENABLE_CORS", + "TYPESENSE_LOG_LEVEL", + "TYPESENSE_NUM_MEMORY_SHARDS", + "TYPESENSE_SNAPSHOT_INTERVAL_SECONDS", + "ELASTICSEARCH_VERSION", + "ELASTICSEARCH_PORT", + "ELASTICSEARCH_PASSWORD", + "ELASTICSEARCH_MEMORY", + + // Monitoring + "MONITORING_ENABLED", + "PROMETHEUS_ENABLED", + "PROMETHEUS_PORT", + "GRAFANA_ENABLED", + "GRAFANA_PORT", + "GRAFANA_ADMIN_USER", + "GRAFANA_ADMIN_PASSWORD", + "GRAFANA_ROUTE", + "LOKI_ENABLED", + "LOKI_PORT", + "PROMTAIL_ENABLED", + "TEMPO_ENABLED", + "TEMPO_PORT", + "ALERTMANAGER_ENABLED", + "ALERTMANAGER_PORT", + "CADVISOR_ENABLED", + "CADVISOR_PORT", + "NODE_EXPORTER_ENABLED", + "NODE_EXPORTER_PORT", + "POSTGRES_EXPORTER_ENABLED", + "POSTGRES_EXPORTER_PORT", + "REDIS_EXPORTER_ENABLED", + "REDIS_EXPORTER_PORT", + + // Email + "EMAIL_PROVIDER", + "EMAIL_FROM", + "ELASTIC_EMAIL_API_KEY", + "ELASTIC_EMAIL_ACCOUNT_EMAIL", + "SENDGRID_API_KEY", + "POSTMARK_API_KEY", + "MAILGUN_API_KEY", + "MAILGUN_DOMAIN", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_REGION", + "SMTP_HOST", + "SMTP_PORT", + "SMTP_USER", + "SMTP_PASS", + "SMTP_SECURE", + + // Backup + "BACKUP_ENABLED", + "BACKUP_DIR", + "BACKUP_SCHEDULE", + "BACKUP_RETENTION_DAYS", + "BACKUP_CLOUD_PROVIDER", + "BACKUP_REMOTE", + "BACKUP_ENCRYPTION", + "BACKUP_AGE_RECIPIENTS", + "BACKUP_SCHEDULE_FULL", + "BACKUP_WAL_INTERVAL_SECONDS", + "BACKUP_RETENTION_DAILY", + "BACKUP_RETENTION_WEEKLY", + "BACKUP_RETENTION_MONTHLY", + "BACKUP_RESTORE_TEST_SCHEDULE", + "BACKUP_ALERT_ON_FAILURE", + "BACKUP_S3_ACCESS_KEY_ID", + "BACKUP_S3_SECRET_ACCESS_KEY", + "BACKUP_S3_REGION", + "BACKUP_S3_ENDPOINT", + + // Disaster Recovery + "DR_SECONDARY_REGION", + "DR_STANDBY_HOST", + "DR_DRILL_SCHEDULE", + + // Plugin Pro + "NOTIFY_INTERNAL_SECRET", + "NOTIFY_PORT", + "NOTIFY_VAPID_PUBLIC_KEY", + "NOTIFY_VAPID_PRIVATE_KEY", + "NOTIFY_ROUTE", + "CRON_INTERNAL_SECRET", + "CRON_PORT", + "CRON_RETENTION_DAYS", + "PLUGIN_AI_MEMORY_LIMIT", + "PLUGIN_AI_CPU_LIMIT", + "PLUGIN_MUX_MEMORY_LIMIT", + "PLUGIN_MUX_CPU_LIMIT", + "PLUGIN_CLAW_MEMORY_LIMIT", + "PLUGIN_CLAW_CPU_LIMIT", + "PLUGIN_DEFAULT_MEMORY_LIMIT", + "PLUGIN_DEFAULT_CPU_LIMIT", + "PLUGIN_INTERNAL_SECRET", + + // Plugin System + "NSELF_PLUGIN_DIR", + "NSELF_PLUGIN_CACHE", + "NSELF_PLUGIN_REGISTRY", + "NSELF_REGISTRY_CACHE_TTL", + "NSELF_PLUGIN_LICENSE_KEY", + "NSELF_LICENSE_SKIP_VERIFY", + "NSELF_PING_API_URL", + "NSELF_PRICING_URL", + + // Docker + "DOCKER_NETWORK", + "DOCKER_LOG_MAX_SIZE", + "DOCKER_LOG_MAX_FILE", + "DOCKER_STOP_GRACE_PERIOD", + "NSELF_DOCKER_BUILD_TIMEOUT", + + // Start/Stop + "NSELF_START_MODE", + "NSELF_HEALTH_CHECK_TIMEOUT", + "NSELF_HEALTH_CHECK_INTERVAL", + "NSELF_HEALTH_CHECK_REQUIRED", + "NSELF_CLEANUP_ON_START", + "NSELF_ALLOW_EXPOSED_PORTS", + "NSELF_PARALLEL_LIMIT", + "NSELF_LOG_LEVEL", + "NSELF_SKIP_HEALTH_CHECKS", + "NSELF_STOP_TIMEOUT", + + // Plugin-managed: compose-injected vars that users may set in .env. + // The CLI loader does not read these; they are listed here only to suppress + // false "unknown env var" warnings from WarnUnknownEnvVars. + // Auth service (nHost auth container) — passed through compose template. + "AUTH_HOST", + "AUTH_SERVER_URL", + "AUTH_JWT_SECRET", + "AUTH_REFRESH_TOKEN_SECRET", + "AUTH_ACCESS_TOKEN_EXPIRY", + "AUTH_REFRESH_TOKEN_EXPIRY", + "AUTH_EMAIL_SIGNIN_EMAIL_VERIFIED_REQUIRED", + // Hasura container — passed through compose template. + "HASURA_GRAPHQL_ENABLE_TELEMETRY", + "HASURA_GRAPHQL_UNAUTHORIZED_ROLE", + "HASURA_CONSOLE_PORT", + "HASURA_GRAPHQL_JWT_SECRET", + "HASURA_GRAPHQL_DATABASE_URL", + "HASURA_METADATA_DATABASE_URL", + // Nginx compose template vars. + "NGINX_GZIP_ENABLED", + "NGINX_MODE", + "NGINX_MEM_LIMIT", + // MinIO/Storage — compose-computed. + "S3_ENDPOINT", + "STORAGE_PORT", + "FILES_ROUTE", + // nSelf Admin container. + "NSELF_ADMIN_USER", + "NSELF_ADMIN_PASSWORD", + // Typesense search provider (partial: TYPESENSE_PORT/ROUTE not in knownEnvVars struct). + "TYPESENSE_PORT", + "TYPESENSE_ROUTE", + // Notify plugin. + "NOTIFY_VAPID_SUBJECT", + // Docker Compose runtime vars (exported by shell wrapper, not read by loader). + "COMPOSE_PROJECT_NAME", + "DOCKER_BUILDKIT", + // Phase 14 CLI-command vars (read by cmd handlers, not by loader). + "NSELF_AUTO_TRUST_CA", + "NSELF_AUTO_HOSTS_ENTRIES", + "NSELF_MKCERT_CAROOT", + "NSELF_NO_MONOREPO", + // CLI tool behavior vars (read by main binary, not by loader). + "DEBUG", + "NO_COLOR", + // Postgres internal port (documentation-only; always 5432). + "POSTGRES_INTERNAL_PORT", + // MeiliSearch search engine (plugin-managed: injected into search compose template). + "MEILISEARCH_ENABLED", + "MEILISEARCH_PORT", + "MEILISEARCH_ROUTE", + "MEILI_NO_ANALYTICS", + // OpenSearch search provider (plugin-managed: opensearch plugin compose template). + "OPENSEARCH_VERSION", + "OPENSEARCH_PORT", + "OPENSEARCH_PASSWORD", + "OPENSEARCH_MEMORY", + // Zinc search provider (plugin-managed: zinc plugin compose template). + "ZINC_VERSION", + "ZINC_PORT", + "ZINC_ADMIN_USER", + "ZINC_ADMIN_PASSWORD", + // Sonic search provider (plugin-managed: sonic plugin compose template). + "SONIC_VERSION", + "SONIC_PORT", + "SONIC_PASSWORD", + // Dashboard plugin (plugin-managed: dashboard plugin compose template). + "DASHBOARD_ENABLED", + "DASHBOARD_VERSION", + "DASHBOARD_ROUTE", + "DASHBOARD_PORT", + // Legacy microservice system (plugin-managed: pre-CS_N system; may appear in old .env files). + "SERVICES_ENABLED", + "NESTJS_ENABLED", + "NESTJS_SERVICES", + "NESTJS_USE_TYPESCRIPT", + "NESTJS_PORT_START", + "BULLMQ_ENABLED", + "BULLMQ_WORKERS", + "BULLMQ_DASHBOARD_ENABLED", + "BULLMQ_DASHBOARD_PORT", + "BULLMQ_DASHBOARD_ROUTE", + "GOLANG_ENABLED", + "GOLANG_SERVICES", + "GOLANG_PORT_START", + "PYTHON_ENABLED", + "PYTHON_SERVICES", + "PYTHON_FRAMEWORK", + "PYTHON_PORT_START", + // Plugin integration vars (plugin-managed: stripe, github, shopify plugin compose templates). + "STRIPE_API_KEY", + "STRIPE_WEBHOOK_SECRET", + "STRIPE_SYNC_INTERVAL", + "GITHUB_TOKEN", + "GITHUB_WEBHOOK_SECRET", + "GITHUB_ORG", + "GITHUB_REPOS", + "SHOPIFY_STORE", + "SHOPIFY_ACCESS_TOKEN", + "SHOPIFY_API_VERSION", + "SHOPIFY_WEBHOOK_SECRET", + "SHOPIFY_SYNC_INTERVAL", +} diff --git a/internal/config/loader_parse_env.go b/internal/config/loader_parse_env.go new file mode 100644 index 00000000..3cc9b3ab --- /dev/null +++ b/internal/config/loader_parse_env.go @@ -0,0 +1,356 @@ +package config + +// loader_parse_env.go — maps every environment variable to its Config struct field. +// +// Purpose: Single canonical mapping from env var name strings to typed Config +// struct fields. Every section (Core, Postgres, Hasura, Auth, Nginx, +// SSL, WAF, Redis, Minio, Mailpit, Functions, MLflow, Admin, Search, +// Monitoring, Email, Backup, DR, PluginPro, PluginSystem, Docker, +// Start/Stop) is mapped in one place so the connection between env var +// name and field is unambiguous and grep-friendly. +// Inputs: os.Environ (read via os.Getenv, getEnvOr, getEnvInt, getEnvBool). +// Outputs: *Config — fully populated struct (no defaults yet; ApplyDefaults +// fills zero values after this function returns). +// Constraints: Must not call ApplyDefaults or touch the filesystem. Pure +// os.Getenv reads only. Keep in sync with loader_known_vars.go. +// SPORT: cli/internal/config — decomposed from loader.go (T-E2-06). + +import "os" + +// parseEnvToConfig reads every Config field from os.Getenv using the helper +// functions (getEnvOr, getEnvInt, getEnvBool). This is the single place that +// maps environment variable names to struct fields. +func parseEnvToConfig() *Config { + cfg := &Config{} + + // ── Core ───────────────────────────────────────────────────────── + cfg.ProjectName = os.Getenv("PROJECT_NAME") + cfg.BaseDomain = os.Getenv("BASE_DOMAIN") + if cfg.BaseDomain == "" { + cfg.BaseDomain = os.Getenv("PROJECT_DOMAIN") + } + cfg.Env = normalizeEnv(getEnvOr("ENV", "dev")) + cfg.ProjectDescription = os.Getenv("PROJECT_DESCRIPTION") + cfg.AdminEmail = os.Getenv("ADMIN_EMAIL") + cfg.DBEnvSeeds = getEnvBool("DB_ENV_SEEDS", true) + + // ── PostgreSQL ─────────────────────────────────────────────────── + cfg.Postgres = PostgresConfig{ + Version: os.Getenv("POSTGRES_VERSION"), + Host: os.Getenv("POSTGRES_HOST"), + Port: getEnvInt("POSTGRES_PORT", 0), + DB: os.Getenv("POSTGRES_DB"), + User: os.Getenv("POSTGRES_USER"), + Password: os.Getenv("POSTGRES_PASSWORD"), + Extensions: parseExtensionList(getEnvOr("POSTGRES_EXTENSIONS", "uuid-ossp,pgcrypto,pg_trgm")), + ExposePort: os.Getenv("POSTGRES_EXPOSE_PORT"), + MemLimit: os.Getenv("POSTGRES_MEM_LIMIT"), + CPULimit: os.Getenv("POSTGRES_CPU_LIMIT"), + } + + // ── Hasura ─────────────────────────────────────────────────────── + cfg.Hasura = HasuraConfig{ + Version: os.Getenv("HASURA_VERSION"), + AdminSecret: os.Getenv("HASURA_GRAPHQL_ADMIN_SECRET"), + JWTKey: os.Getenv("HASURA_JWT_KEY"), + JWTType: os.Getenv("HASURA_JWT_TYPE"), + Console: getEnvBool("HASURA_GRAPHQL_ENABLE_CONSOLE", false), + DevMode: getEnvBool("HASURA_GRAPHQL_DEV_MODE", false), + CORSDomain: os.Getenv("HASURA_GRAPHQL_CORS_DOMAIN"), + Route: os.Getenv("HASURA_ROUTE"), + Port: getEnvInt("HASURA_PORT", 0), + MemLimit: os.Getenv("HASURA_MEM_LIMIT"), + CPULimit: os.Getenv("HASURA_CPU_LIMIT"), + LogLevel: os.Getenv("HASURA_GRAPHQL_LOG_LEVEL"), + } + // HASURA_DEV_MODE backward-compat alias: v1 used HASURA_DEV_MODE, v2 uses HASURA_GRAPHQL_DEV_MODE. + // Only apply alias if HASURA_GRAPHQL_DEV_MODE was not explicitly set. + if alias := os.Getenv("HASURA_DEV_MODE"); alias != "" { + if _, explicitly := os.LookupEnv("HASURA_GRAPHQL_DEV_MODE"); !explicitly { + cfg.Hasura.DevMode = alias == "true" || alias == "1" || alias == "yes" + } + } + + // ── Auth ───────────────────────────────────────────────────────── + cfg.Auth = AuthConfig{ + Version: os.Getenv("AUTH_VERSION"), + Port: getEnvInt("AUTH_PORT", 0), + ClientURL: os.Getenv("AUTH_CLIENT_URL"), + AccessTokenExpiry: getEnvInt("AUTH_ACCESS_TOKEN_EXPIRES_IN", 0), + RefreshTokenExpiry: getEnvInt("AUTH_REFRESH_TOKEN_EXPIRES_IN", 0), + Route: os.Getenv("AUTH_ROUTE"), + SMTPHost: os.Getenv("AUTH_SMTP_HOST"), + SMTPPort: getEnvInt("AUTH_SMTP_PORT", 0), + SMTPUser: os.Getenv("AUTH_SMTP_USER"), + SMTPPass: os.Getenv("AUTH_SMTP_PASS"), + SMTPSecure: getEnvBool("AUTH_SMTP_SECURE", false), + SMTPSender: os.Getenv("AUTH_SMTP_SENDER"), + MemLimit: os.Getenv("AUTH_MEM_LIMIT"), + CPULimit: os.Getenv("AUTH_CPU_LIMIT"), + ExtraRedirectURLs: os.Getenv("AUTH_EXTRA_REDIRECT_URLS"), + WebAuthnEnabled: getEnvBool("AUTH_WEBAUTHN_ENABLED", false), + LogLevel: os.Getenv("AUTH_LOG_LEVEL"), + } + + // ── Nginx ──────────────────────────────────────────────────────── + cfg.Nginx = NginxConfig{ + Version: os.Getenv("NGINX_VERSION"), + HTTPPort: getEnvInt("NGINX_HTTP_PORT", getEnvInt("NGINX_PORT", 0)), + SSLPort: getEnvInt("NGINX_HTTPS_PORT", getEnvInt("NGINX_SSL_PORT", 0)), + MaxBody: os.Getenv("NGINX_CLIENT_MAX_BODY_SIZE"), + BindIP: os.Getenv("NGINX_BIND_IP"), + AuthRateLimit: os.Getenv("AUTH_RATE_LIMIT"), + RateLimitAPI: os.Getenv("RATE_LIMIT_API_RPS"), + RateLimitAuth: os.Getenv("RATE_LIMIT_AUTH_RPS"), + RateLimitAI: os.Getenv("RATE_LIMIT_AI_RPS"), + } + + // ── SSL ────────────────────────────────────────────────────────── + cfg.SSLMode = os.Getenv("SSL_MODE") + cfg.SSLProvider = os.Getenv("SSL_PROVIDER") + cfg.SSLWildcardDomain = os.Getenv("SSL_WILDCARD_DOMAIN") + cfg.ExtraSSLDomains = os.Getenv("EXTRA_SSL_DOMAINS") + cfg.CloudflareAPIKey = os.Getenv("CLOUDFLARE_API_KEY") + + // ── WAF ────────────────────────────────────────────────────────── + cfg.WAFMode = os.Getenv("WAF_MODE") + + // ── Redis ──────────────────────────────────────────────────────── + cfg.Redis = RedisConfig{ + Enabled: getEnvBool("REDIS_ENABLED", false), + Version: os.Getenv("REDIS_VERSION"), + Port: getEnvInt("REDIS_PORT", 0), + Password: os.Getenv("REDIS_PASSWORD"), + Memory: os.Getenv("REDIS_MEMORY"), + CPU: os.Getenv("REDIS_CPU"), + } + + // ── MinIO / Storage ────────────────────────────────────────────── + // Backward compat: STORAGE_ENABLED=true implies MINIO_ENABLED=true. + minioEnabled := getEnvBool("MINIO_ENABLED", false) || getEnvBool("STORAGE_ENABLED", false) + cfg.Minio = MinioConfig{ + Enabled: minioEnabled, + Version: os.Getenv("MINIO_VERSION"), + Port: getEnvInt("MINIO_PORT", 0), + ConsolePort: getEnvInt("MINIO_CONSOLE_PORT", 0), + RootUser: os.Getenv("MINIO_ROOT_USER"), + RootPassword: os.Getenv("MINIO_ROOT_PASSWORD"), + DefaultBuckets: os.Getenv("MINIO_DEFAULT_BUCKETS"), + Region: os.Getenv("MINIO_REGION"), + S3AccessKey: os.Getenv("S3_ACCESS_KEY"), + S3SecretKey: os.Getenv("S3_SECRET_KEY"), + S3Bucket: os.Getenv("S3_BUCKET"), + StorageVersion: os.Getenv("STORAGE_VERSION"), + StorageRoute: os.Getenv("STORAGE_ROUTE"), + ConsoleRoute: os.Getenv("STORAGE_CONSOLE_ROUTE"), + MemLimit: os.Getenv("MINIO_MEMORY"), + CPULimit: os.Getenv("MINIO_CPU"), + } + + // ── Mailpit ────────────────────────────────────────────────────── + cfg.Mailpit = MailpitConfig{ + Enabled: getEnvBool("MAILPIT_ENABLED", false), + Version: os.Getenv("MAILPIT_VERSION"), + SMTPPort: getEnvInt("MAILPIT_SMTP_PORT", 0), + UIPort: getEnvInt("MAILPIT_UI_PORT", 0), + MaxMessages: getEnvInt("MAILPIT_MAX_MESSAGES", 0), + Route: getEnvOr("MAILPIT_ROUTE", os.Getenv("MAIL_ROUTE")), + UIUser: getEnvOr("MAILPIT_UI_USER", "admin"), + UIPassword: os.Getenv("MAILPIT_UI_PASSWORD"), + } + + // ── Functions ──────────────────────────────────────────────────── + cfg.Functions = FunctionsConfig{ + Enabled: getEnvBool("FUNCTIONS_ENABLED", false), + Version: os.Getenv("FUNCTIONS_VERSION"), + Port: getEnvInt("FUNCTIONS_PORT", 0), + Route: os.Getenv("FUNCTIONS_ROUTE"), + } + + // ── MLflow ─────────────────────────────────────────────────────── + cfg.MLflow = MLflowConfig{ + Enabled: getEnvBool("MLFLOW_ENABLED", false), + Version: os.Getenv("MLFLOW_VERSION"), + Port: getEnvInt("MLFLOW_PORT", 0), + Route: os.Getenv("MLFLOW_ROUTE"), + DBName: os.Getenv("MLFLOW_DB_NAME"), + ArtifactsBucket: os.Getenv("MLFLOW_ARTIFACTS_BUCKET"), + AuthEnabled: getEnvBool("MLFLOW_AUTH_ENABLED", false), + AuthUsername: os.Getenv("MLFLOW_AUTH_USERNAME"), + AuthPassword: os.Getenv("MLFLOW_AUTH_PASSWORD"), + } + + // ── Admin ──────────────────────────────────────────────────────── + cfg.Admin = AdminConfig{ + Enabled: getEnvBool("NSELF_ADMIN_ENABLED", false), + Version: os.Getenv("NSELF_ADMIN_VERSION"), + Port: getEnvInt("NSELF_ADMIN_PORT", 0), + Route: os.Getenv("NSELF_ADMIN_ROUTE"), + DevMode: getEnvBool("NSELF_ADMIN_DEV", false), + DevPort: getEnvInt("NSELF_ADMIN_DEV_PORT", 0), + SecretKey: os.Getenv("ADMIN_SECRET_KEY"), + PasswordHash: os.Getenv("ADMIN_PASSWORD_HASH"), + } + + // ── Search (provider-agnostic) ─────────────────────────────────── + cfg.Search = SearchConfig{ + Enabled: getEnvBool("SEARCH_ENABLED", false), + Engine: getEnvOr("SEARCH_ENGINE", os.Getenv("SEARCH_PROVIDER")), + Port: getEnvInt("SEARCH_PORT", 0), + APIKey: os.Getenv("SEARCH_API_KEY"), + Route: os.Getenv("SEARCH_ROUTE"), + IndexPrefix: os.Getenv("SEARCH_INDEX_PREFIX"), + AutoIndex: getEnvBool("SEARCH_AUTO_INDEX", true), + Language: os.Getenv("SEARCH_LANGUAGE"), + MeiliSearch: MeiliSearchConfig{ + Version: os.Getenv("MEILISEARCH_VERSION"), + MasterKey: os.Getenv("MEILISEARCH_MASTER_KEY"), + Env: getEnvOr("MEILISEARCH_ENV", os.Getenv("MEILI_ENV")), + }, + Typesense: TypesenseConfig{ + Version: os.Getenv("TYPESENSE_VERSION"), + APIKey: os.Getenv("TYPESENSE_API_KEY"), + EnableCORS: getEnvBool("TYPESENSE_ENABLE_CORS", false), + LogLevel: os.Getenv("TYPESENSE_LOG_LEVEL"), + NumMemoryShards: getEnvInt("TYPESENSE_NUM_MEMORY_SHARDS", 0), + SnapshotIntervalS: getEnvInt("TYPESENSE_SNAPSHOT_INTERVAL_SECONDS", 0), + }, + Elasticsearch: ElasticsearchConfig{ + Version: os.Getenv("ELASTICSEARCH_VERSION"), + Port: getEnvInt("ELASTICSEARCH_PORT", 0), + Password: os.Getenv("ELASTICSEARCH_PASSWORD"), + Memory: os.Getenv("ELASTICSEARCH_MEMORY"), + }, + } + + // ── Monitoring ─────────────────────────────────────────────────── + cfg.Monitoring = MonitoringConfig{ + Enabled: getEnvBool("MONITORING_ENABLED", false), + PrometheusEnabled: getEnvBool("PROMETHEUS_ENABLED", false), + PrometheusPort: getEnvInt("PROMETHEUS_PORT", 0), + GrafanaEnabled: getEnvBool("GRAFANA_ENABLED", false), + GrafanaPort: getEnvInt("GRAFANA_PORT", 0), + GrafanaAdminUser: os.Getenv("GRAFANA_ADMIN_USER"), + GrafanaAdminPassword: os.Getenv("GRAFANA_ADMIN_PASSWORD"), + GrafanaRoute: os.Getenv("GRAFANA_ROUTE"), + LokiEnabled: getEnvBool("LOKI_ENABLED", false), + LokiPort: getEnvInt("LOKI_PORT", 0), + PromtailEnabled: getEnvBool("PROMTAIL_ENABLED", false), + TempoEnabled: getEnvBool("TEMPO_ENABLED", false), + TempoPort: getEnvInt("TEMPO_PORT", 0), + AlertmanagerEnabled: getEnvBool("ALERTMANAGER_ENABLED", false), + AlertmanagerPort: getEnvInt("ALERTMANAGER_PORT", 0), + CadvisorEnabled: getEnvBool("CADVISOR_ENABLED", false), + CadvisorPort: getEnvInt("CADVISOR_PORT", 0), + NodeExporterEnabled: getEnvBool("NODE_EXPORTER_ENABLED", false), + NodeExporterPort: getEnvInt("NODE_EXPORTER_PORT", 0), + PGExporterEnabled: getEnvBool("POSTGRES_EXPORTER_ENABLED", false), + PGExporterPort: getEnvInt("POSTGRES_EXPORTER_PORT", 0), + RedisExporterEnabled: getEnvBool("REDIS_EXPORTER_ENABLED", false), + RedisExporterPort: getEnvInt("REDIS_EXPORTER_PORT", 0), + } + + // ── Email ──────────────────────────────────────────────────────── + cfg.Email = EmailConfig{ + Provider: os.Getenv("EMAIL_PROVIDER"), + From: os.Getenv("EMAIL_FROM"), + ElasticEmailAPIKey: os.Getenv("ELASTIC_EMAIL_API_KEY"), + ElasticEmailAccount: os.Getenv("ELASTIC_EMAIL_ACCOUNT_EMAIL"), + SendGridAPIKey: os.Getenv("SENDGRID_API_KEY"), + PostmarkAPIKey: os.Getenv("POSTMARK_API_KEY"), + MailgunAPIKey: os.Getenv("MAILGUN_API_KEY"), + MailgunDomain: os.Getenv("MAILGUN_DOMAIN"), + AWSAccessKeyID: os.Getenv("AWS_ACCESS_KEY_ID"), + AWSSecretAccessKey: os.Getenv("AWS_SECRET_ACCESS_KEY"), + AWSRegion: os.Getenv("AWS_REGION"), + SMTPHost: os.Getenv("SMTP_HOST"), + SMTPPort: getEnvInt("SMTP_PORT", 0), + SMTPUser: os.Getenv("SMTP_USER"), + SMTPPass: os.Getenv("SMTP_PASS"), + SMTPSecure: getEnvBool("SMTP_SECURE", false), + } + + // ── Backup ─────────────────────────────────────────────────────── + cfg.Backup = BackupConfig{ + Enabled: getEnvBool("BACKUP_ENABLED", false), + Dir: os.Getenv("BACKUP_DIR"), + Schedule: os.Getenv("BACKUP_SCHEDULE"), + RetentionDays: getEnvInt("BACKUP_RETENTION_DAYS", 0), + CloudProvider: os.Getenv("BACKUP_CLOUD_PROVIDER"), + Remote: os.Getenv("BACKUP_REMOTE"), + Encryption: getEnvBool("BACKUP_ENCRYPTION", false), + AgeRecipients: os.Getenv("BACKUP_AGE_RECIPIENTS"), + ScheduleFull: os.Getenv("BACKUP_SCHEDULE_FULL"), + WALInterval: getEnvInt("BACKUP_WAL_INTERVAL_SECONDS", 0), + RetentionDaily: getEnvInt("BACKUP_RETENTION_DAILY", 0), + RetentionWeekly: getEnvInt("BACKUP_RETENTION_WEEKLY", 0), + RetentionMonthly: getEnvInt("BACKUP_RETENTION_MONTHLY", 0), + RestoreTestSchedule: os.Getenv("BACKUP_RESTORE_TEST_SCHEDULE"), + AlertOnFailure: getEnvBool("BACKUP_ALERT_ON_FAILURE", true), + S3AccessKeyID: os.Getenv("BACKUP_S3_ACCESS_KEY_ID"), + S3SecretAccessKey: os.Getenv("BACKUP_S3_SECRET_ACCESS_KEY"), + S3Region: os.Getenv("BACKUP_S3_REGION"), + S3Endpoint: os.Getenv("BACKUP_S3_ENDPOINT"), + } + + cfg.DR = DRConfig{ + SecondaryRegion: os.Getenv("DR_SECONDARY_REGION"), + StandbyHost: os.Getenv("DR_STANDBY_HOST"), + DrillSchedule: os.Getenv("DR_DRILL_SCHEDULE"), + } + + // Plugin port defaults (3712=notify, 3713=cron) are set in ApplyDefaults() when port==0. + // ── Plugin Pro Configuration ───────────────────────────────────── + cfg.PluginConfig = PluginProConfig{ + NotifySecret: os.Getenv("NOTIFY_INTERNAL_SECRET"), + NotifyPort: getEnvInt("NOTIFY_PORT", 0), + NotifyVAPIDPub: os.Getenv("NOTIFY_VAPID_PUBLIC_KEY"), + NotifyVAPIDPriv: os.Getenv("NOTIFY_VAPID_PRIVATE_KEY"), + NotifyRoute: os.Getenv("NOTIFY_ROUTE"), + CronSecret: os.Getenv("CRON_INTERNAL_SECRET"), + CronPort: getEnvInt("CRON_PORT", 0), + CronRetention: getEnvInt("CRON_RETENTION_DAYS", 0), + AIMemLimit: os.Getenv("PLUGIN_AI_MEMORY_LIMIT"), + AICPULimit: os.Getenv("PLUGIN_AI_CPU_LIMIT"), + MuxMemLimit: os.Getenv("PLUGIN_MUX_MEMORY_LIMIT"), + MuxCPULimit: os.Getenv("PLUGIN_MUX_CPU_LIMIT"), + ClawMemLimit: os.Getenv("PLUGIN_CLAW_MEMORY_LIMIT"), + ClawCPULimit: os.Getenv("PLUGIN_CLAW_CPU_LIMIT"), + DefaultMemLimit: os.Getenv("PLUGIN_DEFAULT_MEMORY_LIMIT"), + DefaultCPULimit: os.Getenv("PLUGIN_DEFAULT_CPU_LIMIT"), + } + + // ── Plugin System ──────────────────────────────────────────────── + cfg.PluginSystem = PluginSystemConfig{ + Dir: os.Getenv("NSELF_PLUGIN_DIR"), + Cache: os.Getenv("NSELF_PLUGIN_CACHE"), + Registry: os.Getenv("NSELF_PLUGIN_REGISTRY"), + CacheTTL: getEnvInt("NSELF_REGISTRY_CACHE_TTL", 0), + LicenseKey: os.Getenv("NSELF_PLUGIN_LICENSE_KEY"), + SkipVerify: getEnvBool("NSELF_LICENSE_SKIP_VERIFY", false), + PingURL: os.Getenv("NSELF_PING_API_URL"), + PricingURL: os.Getenv("NSELF_PRICING_URL"), + InternalSecret: os.Getenv("PLUGIN_INTERNAL_SECRET"), + } + + // ── Docker ─────────────────────────────────────────────────────── + cfg.DockerNetwork = os.Getenv("DOCKER_NETWORK") + cfg.DockerLogMaxSize = os.Getenv("DOCKER_LOG_MAX_SIZE") + cfg.DockerLogMaxFile = os.Getenv("DOCKER_LOG_MAX_FILE") + cfg.DockerStopGrace = os.Getenv("DOCKER_STOP_GRACE_PERIOD") + cfg.DockerBuildTimeout = getEnvInt("NSELF_DOCKER_BUILD_TIMEOUT", 0) + + // ── Start/Stop ─────────────────────────────────────────────────── + cfg.StartMode = os.Getenv("NSELF_START_MODE") + cfg.HealthCheckTimeout = getEnvInt("NSELF_HEALTH_CHECK_TIMEOUT", 0) + cfg.HealthCheckInterval = getEnvInt("NSELF_HEALTH_CHECK_INTERVAL", 0) + cfg.HealthCheckRequired = getEnvInt("NSELF_HEALTH_CHECK_REQUIRED", 0) + cfg.CleanupOnStart = os.Getenv("NSELF_CLEANUP_ON_START") + cfg.AllowExposedPorts = getEnvBool("NSELF_ALLOW_EXPOSED_PORTS", false) + cfg.ParallelLimit = getEnvInt("NSELF_PARALLEL_LIMIT", 0) + cfg.LogLevel = os.Getenv("NSELF_LOG_LEVEL") + cfg.SkipHealthChecks = getEnvBool("NSELF_SKIP_HEALTH_CHECKS", false) + cfg.StopTimeout = getEnvInt("NSELF_STOP_TIMEOUT", 0) + + return cfg +} diff --git a/internal/doctor/hardening_check.go b/internal/doctor/hardening_check.go index d2e13cbf..4e1ea7e5 100644 --- a/internal/doctor/hardening_check.go +++ b/internal/doctor/hardening_check.go @@ -1,39 +1,28 @@ package doctor -// hardening_check.go — SEC-HARDENING-01..08 + SEC-CORS-01 + SEC-OFFLINE-01 + SEC-DEVMODE-01 + SEC-METRICS-01: Security-Always-Free hardening checks. +// hardening_check.go — entry point for all SEC-HARDENING-*, SEC-CORS-01, +// SEC-OFFLINE-01, SEC-DEVMODE-01, and SEC-METRICS-01 security checks. // -// These checks run as part of `nself doctor --deep` (no license required). -// Each returns Pass | Warn | Fail with a remediation hint. -// -// Checks: -// SEC-HARDENING-01 RLS enabled + FORCE RLS on every np_* table -// SEC-HARDENING-02 Auth plugin has AUTH_RATE_LIMIT_* env vars -// SEC-HARDENING-03 MFA throttle (AUTH_MFA_MAX_ATTEMPTS_PER_HOUR) set -// SEC-HARDENING-04 SSRF guard imported by any plugin making outbound HTTP -// SEC-HARDENING-05 JWT_PUBLIC_KEYS has at least 2 keys (rotation-ready) -// SEC-HARDENING-06 Nginx config has rate-limit zones for /auth/login + /api/ -// SEC-HARDENING-07 nself-audit plugin installed or audit_log table writable -// SEC-HARDENING-08 POSTGRES_DATA_ENCRYPTED env set or encrypted-disk marker present -// SEC-CORS-01 HASURA_GRAPHQL_CORS_DOMAIN is not a wildcard "*" in staging/prod -// SEC-OFFLINE-01 License cache fetched_at within 24h (warn when server unreachable too long) -// SEC-DEVMODE-01 HASURA_GRAPHQL_DEV_MODE must not be true in staging/prod -// SEC-METRICS-01 Prometheus /metrics port (9090) bound to 127.0.0.1 only (not 0.0.0.0) - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "time" -) +// Purpose: Aggregate entry point — HardeningChecks() returns the full slice of +// 12 security check results by delegating to focused sub-files. +// Inputs: ctx context.Context for Docker/psql exec; projectDir string (nSelf +// working directory, same value passed to DeepChecks). +// Outputs: []CheckResult — one result per check ID (SEC-HARDENING-01..08, +// SEC-CORS-01, SEC-OFFLINE-01, SEC-DEVMODE-01, SEC-METRICS-01). +// Constraints: All checks run as part of `nself doctor --deep` (no license +// required). Each returns Pass | Warn | Fail + remediation hint. +// Sub-checks live in hardening_check_db.go (RLS, audit), +// hardening_check_auth_net.go (rate-limit, MFA, SSRF, JWT, nginx), +// hardening_check_infra.go (encryption, CORS, devmode, license, +// metrics), and hardening_check_helpers.go (env file readers). +// SPORT: cli/internal/doctor — decomposed from hardening_check.go (T-E2-06). + +import "context" const hardeningSection = "security" -// HardeningChecks runs all SEC-HARDENING-*, SEC-CORS-01, SEC-OFFLINE-01, SEC-DEVMODE-01, and SEC-METRICS-01 checks. +// HardeningChecks runs all SEC-HARDENING-*, SEC-CORS-01, SEC-OFFLINE-01, +// SEC-DEVMODE-01, and SEC-METRICS-01 checks. // projectDir is the nSelf working directory (same value passed to DeepChecks). func HardeningChecks(ctx context.Context, projectDir string) []CheckResult { return []CheckResult{ @@ -51,930 +40,3 @@ func HardeningChecks(ctx context.Context, projectDir string) []CheckResult { checkMetricsPortBinding(projectDir), } } - -// ─── SEC-HARDENING-01: RLS enabled + FORCE RLS on every np_* table ────────── - -func checkHardeningRLS(ctx context.Context) CheckResult { - const checkID = "SEC-HARDENING-01" - - // Query pg_class for np_* tables that are missing RLS enable or force. - query := `SELECT relname, - relrowsecurity::text AS rls_enabled, - relforcerowsecurity::text AS rls_forced - FROM pg_class - WHERE relname LIKE 'np_%' - AND relkind = 'r' - AND (NOT relrowsecurity OR NOT relforcerowsecurity) - ORDER BY relname;` - - cmd := exec.CommandContext(ctx, - "docker", "exec", "nself_postgres", - "psql", "-U", "postgres", "-t", "-c", query, - ) - out, err := cmd.Output() - if err != nil { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "warn", - Message: fmt.Sprintf("SEC-HARDENING-01: cannot query pg_class (%v) — ensure Postgres is running", err), - FixCmd: "nself start", - } - } - - // Trim whitespace; empty output means every np_* table has RLS+FORCE. - violations := strings.TrimSpace(string(out)) - if violations == "" { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "pass", - Message: "SEC-HARDENING-01: all np_* tables have ENABLE and FORCE RLS", - } - } - - // Count violating tables. - count := len(strings.Split(violations, "\n")) - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "fail", - Message: fmt.Sprintf("SEC-HARDENING-01: %d np_* table(s) missing ENABLE or FORCE RLS — run nself doctor --fix to remediate", count), - FixCmd: "nself doctor --fix --check SEC-HARDENING-01", - } -} - -// ─── SEC-HARDENING-02: Auth rate-limit env vars set ────────────────────────── - -// rateLimitEnvKeys are the required AUTH_RATE_LIMIT_* env vars. -var rateLimitEnvKeys = []string{ - "AUTH_RATE_LIMIT_ENABLED", - "AUTH_RATE_LIMIT_MAX_REQUESTS", - "AUTH_RATE_LIMIT_WINDOW_SECONDS", -} - -func checkHardeningRateLimit(projectDir string) CheckResult { - const checkID = "SEC-HARDENING-02" - - missing := envKeysMissing(projectDir, rateLimitEnvKeys) - if len(missing) == 0 { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "pass", - Message: "SEC-HARDENING-02: AUTH_RATE_LIMIT_* env vars are set", - } - } - - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "warn", - Message: fmt.Sprintf("SEC-HARDENING-02: missing rate-limit env vars: %s — add to .env.dev or .env.prod", strings.Join(missing, ", ")), - FixCmd: "nself config set AUTH_RATE_LIMIT_ENABLED=true AUTH_RATE_LIMIT_MAX_REQUESTS=100 AUTH_RATE_LIMIT_WINDOW_SECONDS=60", - } -} - -// ─── SEC-HARDENING-03: MFA throttle set ────────────────────────────────────── - -func checkHardeningMFAThrottle(projectDir string) CheckResult { - const checkID = "SEC-HARDENING-03" - const key = "AUTH_MFA_MAX_ATTEMPTS_PER_HOUR" - - if envKeySet(projectDir, key) { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "pass", - Message: fmt.Sprintf("SEC-HARDENING-03: %s is set", key), - } - } - - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "warn", - Message: fmt.Sprintf("SEC-HARDENING-03: %s not set — MFA brute-force throttle is off. Recommended: 5", key), - FixCmd: fmt.Sprintf("nself config set %s=5", key), - } -} - -// ─── SEC-HARDENING-04: SSRF guard imported by outbound-HTTP plugins ────────── - -// outboundHTTPPlugins are the plugins known to make outbound HTTP requests. -// These mirror the list in ssrf.go (ssrfServiceChecks). -var outboundHTTPPlugins = []string{"ai", "mux", "browser", "claw", "notify"} - -func checkHardeningSSRFImport(projectDir string) CheckResult { - const checkID = "SEC-HARDENING-04" - - // Resolve plugins-pro/paid directory relative to the nself project root. - paidDir := filepath.Join(projectDir, "..", "plugins-pro", "paid") - if _, err := os.Stat(paidDir); os.IsNotExist(err) { - // plugins-pro may not be present on a dev machine; treat as warn not fail. - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "warn", - Message: "SEC-HARDENING-04: plugins-pro/paid directory not found — skipped (run on a full checkout)", - } - } - - var missing []string - for _, plugin := range outboundHTTPPlugins { - pluginDir := filepath.Join(paidDir, plugin) - if _, err := os.Stat(pluginDir); os.IsNotExist(err) { - continue // plugin not present in this checkout — skip - } - - // Use `go list` to check imports, falling back to a simple file search. - if !pluginHasSSRFGuard(pluginDir) { - missing = append(missing, plugin) - } - } - - if len(missing) == 0 { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "pass", - Message: "SEC-HARDENING-04: SSRF guard present in all outbound-HTTP plugins", - } - } - - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "fail", - Message: fmt.Sprintf("SEC-HARDENING-04: SSRF guard missing in plugin(s): %s", strings.Join(missing, ", ")), - FixCmd: "See plugins-pro docs: add shared/ssrf import to each listed plugin", - } -} - -// pluginHasSSRFGuard returns true when the plugin directory contains a file -// with a recognised SSRF guard symbol. This reuses ssrfGuardSymbols from ssrf.go. -func pluginHasSSRFGuard(pluginDir string) bool { - guardPaths := []string{ - filepath.Join(pluginDir, "shared", "ssrf.go"), - filepath.Join(pluginDir, "internal", "safety", "url_guard.go"), - filepath.Join(pluginDir, "internal", "safety", "urlcheck.go"), - filepath.Join(pluginDir, "internal", "tools", "browser", "client.go"), - filepath.Join(pluginDir, "internal", "push", "ssrf.go"), - filepath.Join(pluginDir, "providers", "ssrf.go"), - } - - for _, p := range guardPaths { - data, err := os.ReadFile(p) - if err != nil { - continue - } - content := string(data) - for _, sym := range ssrfGuardSymbols { - if strings.Contains(content, sym) { - return true - } - } - } - return false -} - -// ─── SEC-HARDENING-05: JWT_PUBLIC_KEYS has ≥2 keys ────────────────────────── - -func checkHardeningJWTPublicKeys(projectDir string) CheckResult { - const checkID = "SEC-HARDENING-05" - const key = "JWT_PUBLIC_KEYS" - - val := envKeyValue(projectDir, key) - if val == "" { - // Single-key mode (JWT_SECRET only) is functional but not rotation-ready. - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "warn", - Message: fmt.Sprintf("SEC-HARDENING-05: %s not set — JWT key rotation requires ≥2 public keys", key), - FixCmd: "nself self-heal --jwt", - } - } - - // Count comma-separated PEM blocks or JSON array elements. - count := countJWTKeys(val) - if count < 2 { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "warn", - Message: fmt.Sprintf("SEC-HARDENING-05: %s has only %d key — add a second key to enable zero-downtime rotation", key, count), - FixCmd: "nself self-heal --jwt", - } - } - - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "pass", - Message: fmt.Sprintf("SEC-HARDENING-05: %s has %d keys (rotation-ready)", key, count), - } -} - -// countJWTKeys counts distinct JWT public-key entries in a value string. -// Accepts a comma-separated list of PEM blocks or a JSON array of JWK objects. -func countJWTKeys(val string) int { - val = strings.TrimSpace(val) - - // JSON array: count "{" occurrences as a proxy for array elements. - if strings.HasPrefix(val, "[") { - return strings.Count(val, "{") - } - - // PEM / base64 list: count "-----BEGIN" blocks. - if strings.Contains(val, "BEGIN") { - return strings.Count(val, "-----BEGIN") - } - - // Comma-separated opaque list. - parts := strings.Split(val, ",") - count := 0 - for _, p := range parts { - if strings.TrimSpace(p) != "" { - count++ - } - } - return count -} - -// ─── SEC-HARDENING-06: Nginx rate-limit zones for /auth/login + /api/ ──────── - -func checkHardeningNginxRateZones(ctx context.Context, projectDir string) CheckResult { - const checkID = "SEC-HARDENING-06" - - // Search nginx/conf.d/ and nginx/sites/ for limit_req_zone + limit_req directives - // covering the two required paths. - nginxDirs := []string{ - filepath.Join(projectDir, "nginx", "conf.d"), - filepath.Join(projectDir, "nginx", "sites"), - filepath.Join(projectDir, "nginx", "nginx.conf"), - } - - hasAuthZone := false - hasAPIZone := false - - for _, root := range nginxDirs { - info, err := os.Stat(root) - if err != nil { - continue - } - - var files []string - if info.IsDir() { - entries, err := os.ReadDir(root) - if err != nil { - continue - } - for _, e := range entries { - if !e.IsDir() { - files = append(files, filepath.Join(root, e.Name())) - } - } - } else { - files = []string{root} - } - - for _, f := range files { - data, err := os.ReadFile(f) - if err != nil { - continue - } - content := string(data) - if strings.Contains(content, "/auth/login") && strings.Contains(content, "limit_req") { - hasAuthZone = true - } - if strings.Contains(content, "/api/") && strings.Contains(content, "limit_req") { - hasAPIZone = true - } - } - } - - // Fallback: inspect nginx container config if local files not found. - if !hasAuthZone || !hasAPIZone { - cmd := exec.CommandContext(ctx, "docker", "exec", "nself_nginx", - "grep", "-r", "limit_req", "/etc/nginx/") - out, err := cmd.Output() - if err == nil { - content := string(out) - if strings.Contains(content, "auth/login") { - hasAuthZone = true - } - if strings.Contains(content, "/api/") { - hasAPIZone = true - } - } - } - - switch { - case hasAuthZone && hasAPIZone: - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "pass", - Message: "SEC-HARDENING-06: nginx rate-limit zones set for /auth/login and /api/", - } - case !hasAuthZone && !hasAPIZone: - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "fail", - Message: "SEC-HARDENING-06: nginx missing rate-limit zones for /auth/login and /api/ — add limit_req_zone directives", - FixCmd: "See docs.nself.org/security/nginx-rate-limiting", - } - default: - missing := "/api/" - if !hasAuthZone { - missing = "/auth/login" - } - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "warn", - Message: fmt.Sprintf("SEC-HARDENING-06: nginx rate-limit zone missing for %s — add limit_req_zone directive", missing), - FixCmd: "See docs.nself.org/security/nginx-rate-limiting", - } - } -} - -// ─── SEC-HARDENING-07: Audit log enabled ───────────────────────────────────── - -// auditTableName is the canonical table created by the nself-audit plugin migration -// (plugins-pro/paid/nself-audit/migrations/001_init.sql). The previous check -// erroneously referenced "np_audit_log" — the correct table is "np_audit_events". -const auditTableName = "np_audit_events" - -// auditTriggerName is the append-only trigger installed by 001_init.sql that -// blocks UPDATE and DELETE on np_audit_events to ensure tamper-evidence. -const auditTriggerName = "trg_audit_immutable" - -func checkHardeningAuditLog(ctx context.Context, projectDir string) CheckResult { - const checkID = "SEC-HARDENING-07" - - // Check 1: nself-audit plugin listed as installed. - listCmd := exec.CommandContext(ctx, "nself", "plugin", "list", "--installed") - listOut, listErr := listCmd.Output() - if listErr == nil && strings.Contains(string(listOut), "nself-audit") { - // Plugin is installed; also verify the append-only trigger is present - // to confirm the tamper-evident guarantee is actually in place. - triggerCheck := checkAuditTrigger(ctx) - if triggerCheck == "pass" { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "pass", - Message: fmt.Sprintf("SEC-HARDENING-07: nself-audit plugin installed, %s present with %s append-only trigger", auditTableName, auditTriggerName), - } - } - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "warn", - Message: fmt.Sprintf("SEC-HARDENING-07: nself-audit plugin installed but %s trigger not found — migration may not have run", auditTriggerName), - FixCmd: "nself plugin run nself-audit migrate", - } - } - - // Check 2: np_audit_events table exists and is writable (INSERT privilege check). - // The table is INSERT+SELECT only — UPDATE and DELETE are blocked by trigger. - query := fmt.Sprintf(`SELECT has_table_privilege(current_user, '%s', 'INSERT')::text;`, auditTableName) - cmd := exec.CommandContext(ctx, - "docker", "exec", "nself_postgres", - "psql", "-U", "postgres", "-t", "-c", query, - ) - out, err := cmd.Output() - if err == nil && strings.TrimSpace(string(out)) == "t" { - // Table writable — also verify append-only trigger. - triggerCheck := checkAuditTrigger(ctx) - if triggerCheck == "pass" { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "pass", - Message: fmt.Sprintf("SEC-HARDENING-07: %s present, writable, and append-only trigger active", auditTableName), - } - } - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "warn", - Message: fmt.Sprintf("SEC-HARDENING-07: %s writable but %s trigger missing — audit events not tamper-evident", auditTableName, auditTriggerName), - FixCmd: "nself plugin run nself-audit migrate", - } - } - - // Check 3: table exists but INSERT privilege not confirmed. - existsQuery := fmt.Sprintf(`SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name='%s')::text;`, auditTableName) - existsCmd := exec.CommandContext(ctx, - "docker", "exec", "nself_postgres", - "psql", "-U", "postgres", "-t", "-c", existsQuery, - ) - existsOut, existsErr := existsCmd.Output() - if existsErr == nil && strings.TrimSpace(string(existsOut)) == "t" { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "warn", - Message: fmt.Sprintf("SEC-HARDENING-07: %s table exists but INSERT privilege not confirmed — verify write access", auditTableName), - } - } - - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "warn", - Message: fmt.Sprintf("SEC-HARDENING-07: audit log not detected — install nself-audit plugin (table: %s)", auditTableName), - FixCmd: "nself plugin install nself-audit", - } -} - -// checkAuditTrigger verifies that the append-only trigger trg_audit_immutable -// exists on np_audit_events. Returns "pass" if confirmed, "fail" otherwise. -func checkAuditTrigger(ctx context.Context) string { - triggerQuery := fmt.Sprintf( - `SELECT EXISTS(SELECT 1 FROM information_schema.triggers WHERE trigger_name='%s' AND event_object_table='%s')::text;`, - auditTriggerName, auditTableName, - ) - triggerCmd := exec.CommandContext(ctx, - "docker", "exec", "nself_postgres", - "psql", "-U", "postgres", "-t", "-c", triggerQuery, - ) - triggerOut, triggerErr := triggerCmd.Output() - if triggerErr == nil && strings.TrimSpace(string(triggerOut)) == "t" { - return "pass" - } - return "fail" -} - -// ─── SEC-HARDENING-08: Encryption at rest ──────────────────────────────────── - -// encryptionEnvKeys lists env vars that declare encryption-at-rest is active. -var encryptionEnvKeys = []string{ - "POSTGRES_DATA_ENCRYPTED", - "NSELF_DISK_ENCRYPTED", - "NSELF_ENCRYPTION_AT_REST", -} - -// encryptedDiskMarkers are filesystem paths whose presence signals -// the volume is backed by an encrypted disk (dm-crypt, LUKS, FileVault, etc.). -var encryptedDiskMarkers = []string{ - "/etc/crypttab", // LUKS-managed volumes (Linux) - "/sys/block/dm-0/dm/name", // device-mapper (dm-crypt) present -} - -func checkHardeningEncryptionAtRest(projectDir string) CheckResult { - const checkID = "SEC-HARDENING-08" - - // Check 1: env var declares encryption-at-rest. - for _, key := range encryptionEnvKeys { - val := envKeyValue(projectDir, key) - if strings.EqualFold(strings.TrimSpace(val), "true") || - strings.EqualFold(strings.TrimSpace(val), "1") || - strings.EqualFold(strings.TrimSpace(val), "yes") { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "pass", - Message: fmt.Sprintf("SEC-HARDENING-08: encryption-at-rest confirmed via %s=%s", key, val), - } - } - } - - // Check 2: filesystem markers indicate encrypted disk. - for _, marker := range encryptedDiskMarkers { - if _, err := os.Stat(marker); err == nil { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "pass", - Message: fmt.Sprintf("SEC-HARDENING-08: encrypted disk detected via %s", marker), - } - } - } - - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "warn", - Message: "SEC-HARDENING-08: encryption-at-rest not confirmed — set POSTGRES_DATA_ENCRYPTED=true or deploy on an encrypted volume", - FixCmd: "nself config set POSTGRES_DATA_ENCRYPTED=true # then verify disk-level encryption in your VPS settings", - } -} - -// ─── SEC-CORS-01: CORS domain not wildcard in staging/prod ─────────────────── - -// checkCORSDomain fails when HASURA_GRAPHQL_CORS_DOMAIN contains a bare "*" -// in a staging or production environment. SEC-CORS-01. A wildcard CORS policy -// allows any origin to read Hasura responses, which bypasses the authentication -// layer for browsers. The validator.go check catches this at startup; this -// doctor check surfaces it for running deployments. -func checkCORSDomain(projectDir string) CheckResult { - const checkID = "SEC-CORS-01" - - // Detect environment from env files. - nenv := envKeyValue(projectDir, "NSELF_ENV") - if nenv == "" { - nenv = envKeyValue(projectDir, "NODE_ENV") - } - isProd := nenv == "staging" || nenv == "prod" || nenv == "production" - if !isProd { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "pass", - Message: "SEC-CORS-01: CORS check skipped (non-production environment)", - } - } - - domain := envKeyValue(projectDir, "HASURA_GRAPHQL_CORS_DOMAIN") - if domain == "" { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "warn", - Message: "SEC-CORS-01: HASURA_GRAPHQL_CORS_DOMAIN is not set — explicit domain required in production", - FixCmd: `nself config set HASURA_GRAPHQL_CORS_DOMAIN="https://yourdomain.example"`, - } - } - - // Block bare wildcard "*" and patterns like "https://*". - if strings.TrimSpace(domain) == "*" || strings.HasPrefix(strings.TrimSpace(domain), "*") { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "fail", - Message: fmt.Sprintf("SEC-CORS-01: HASURA_GRAPHQL_CORS_DOMAIN=%q is a wildcard — any origin can read Hasura in production", domain), - FixCmd: `nself config set HASURA_GRAPHQL_CORS_DOMAIN="https://yourdomain.example"`, - } - } - - // Warn on sub-domain wildcard patterns like "https://*.example.com" — less - // dangerous than bare "*" but still allows any subdomain as origin. - if strings.Contains(domain, "*") { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "warn", - Message: fmt.Sprintf("SEC-CORS-01: HASURA_GRAPHQL_CORS_DOMAIN=%q contains a wildcard — restrict to explicit origins if possible", domain), - FixCmd: `nself config set HASURA_GRAPHQL_CORS_DOMAIN="https://app.yourdomain.example"`, - } - } - - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "pass", - Message: fmt.Sprintf("SEC-CORS-01: CORS domain is explicit: %q", domain), - } -} - -// ─── SEC-DEVMODE-01: Hasura dev-mode not enabled in staging/prod ────────────── - -// checkHasuraDevMode fails when HASURA_GRAPHQL_DEV_MODE=true is found in any -// production or staging env file. Dev mode exposes the Hasura Console and -// introspection to the public internet and must never be enabled in deployed -// environments. ValidateHasuraDevMode (cli/internal/config/validator.go) also -// emits a structured slog.Error when the runtime block fires; this doctor check -// surfaces the misconfiguration before deployment. -func checkHasuraDevMode(projectDir string) CheckResult { - const checkID = "SEC-DEVMODE-01" - - // Detect environment from env files. - nenv := envKeyValue(projectDir, "NSELF_ENV") - if nenv == "" { - nenv = envKeyValue(projectDir, "ENV") - } - isProd := nenv == "staging" || nenv == "prod" || nenv == "production" - if !isProd { - // Skip check in dev environments — dev-mode is intentional there. - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "pass", - Message: "SEC-DEVMODE-01: Hasura dev-mode check skipped (non-production environment)", - } - } - - devMode := envKeyValue(projectDir, "HASURA_GRAPHQL_DEV_MODE") - if strings.ToLower(strings.TrimSpace(devMode)) == "true" { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "fail", - Message: fmt.Sprintf( - "SEC-DEVMODE-01: HASURA_GRAPHQL_DEV_MODE=true in %s — "+ - "Hasura Console and schema introspection are exposed to the public internet", - nenv, - ), - FixCmd: "nself config set HASURA_GRAPHQL_DEV_MODE=false", - } - } - - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "pass", - Message: fmt.Sprintf("SEC-DEVMODE-01: HASURA_GRAPHQL_DEV_MODE is not true in %s", nenv), - } -} - -// ─── SEC-OFFLINE-01: license cache fresh (<24h) ──────────────────────────────── - -// checkLicenseOffline warns when the license cache's fetched_at timestamp is -// older than 24 hours. This indicates the license server has been unreachable -// for a day or more, which is unexpected under normal connectivity and may -// signal a misconfigured firewall or DNS failure blocking ping.nself.org. -// -// The check is advisory-only (warn, never fail): the fail-open policy in -// validator.go handles the actual go/no-go decision. This doctor check surfaces -// the offline duration early so operators can investigate before plugins go -// dormant (FailOpenSoftTTL = 72h, FailOpenHardTTL = 14d). -// -// Cache location: ~/.cache/nself/license.json (overridable via LICENSE_CACHE_PATH). -func checkLicenseOffline() CheckResult { - const checkID = "SEC-OFFLINE-01" - - // Determine cache path (same logic as license.CachePath). - cachePath := os.Getenv("LICENSE_CACHE_PATH") - if cachePath == "" { - home, err := os.UserHomeDir() - if err != nil { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "warn", - Message: "SEC-OFFLINE-01: cannot determine home directory — license cache location unknown", - } - } - cachePath = filepath.Join(home, ".cache", "nself", "license.json") - } - - data, err := os.ReadFile(cachePath) - if err != nil { - if os.IsNotExist(err) { - // No cache yet (fresh install or no license key set). Not a problem. - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "pass", - Message: "SEC-OFFLINE-01: no license cache present (run nself license validate to populate)", - } - } - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "warn", - Message: fmt.Sprintf("SEC-OFFLINE-01: cannot read license cache: %v", err), - } - } - - var entry struct { - FetchedAt int64 `json:"fetched_at"` - } - if err := json.Unmarshal(data, &entry); err != nil || entry.FetchedAt == 0 { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "warn", - Message: "SEC-OFFLINE-01: license cache is unreadable or missing fetched_at — run nself license validate", - FixCmd: "nself license validate", - } - } - - age := time.Since(time.Unix(entry.FetchedAt, 0)) - const warnThreshold = 24 * time.Hour - - if age <= warnThreshold { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "pass", - Message: fmt.Sprintf("SEC-OFFLINE-01: license cache is fresh (%s old)", formatAge(age)), - } - } - - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "warn", - Message: fmt.Sprintf( - "SEC-OFFLINE-01: license server unreachable for %s (cache age exceeds 24h) — "+ - "plugins go dormant at 72h if unreachable", - formatAge(age), - ), - FixCmd: "nself license validate # run while connected to the internet", - } -} - -// formatAge formats a duration as a human-readable string (e.g. "2d 3h" or "45m"). -func formatAge(d time.Duration) string { - d = d.Truncate(time.Minute) - days := int(d.Hours()) / 24 - hours := int(d.Hours()) % 24 - mins := int(d.Minutes()) % 60 - switch { - case days > 0 && hours > 0: - return fmt.Sprintf("%dd %dh", days, hours) - case days > 0: - return fmt.Sprintf("%dd", days) - case hours > 0 && mins > 0: - return fmt.Sprintf("%dh %dm", hours, mins) - case hours > 0: - return fmt.Sprintf("%dh", hours) - default: - return fmt.Sprintf("%dm", mins) - } -} - -// ─── SEC-METRICS-01: Prometheus port not externally reachable ───────────────── - -// prometheusContainerName is the Docker container name used by the nself -// monitoring stack (docker-compose.monitoring.yml — PROJECT_NAME_prometheus). -// The name is checked as a suffix so it matches any project prefix. -const prometheusContainerSuffix = "_prometheus" - -// prometheusExpectedPort is the canonical loopback binding for the Prometheus -// web UI and /metrics endpoint. Any binding to 0.0.0.0:9090 would expose -// scraped metrics (including secrets in labels) to the public internet. -const prometheusExpectedPort = "127.0.0.1:9090" - -// prometheusWildcardPort is the dangerous binding that exposes Prometheus to -// all interfaces. -const prometheusWildcardPort = "0.0.0.0:9090" - -// checkMetricsPortBinding verifies that the Prometheus container port 9090 is -// bound to 127.0.0.1 (loopback) and not to 0.0.0.0 (all interfaces). -// -// The nself monitoring docker-compose template already enforces "127.0.0.1:9090:9090" -// (see cli/internal/compose/docker-compose.monitoring.yml). This check detects if a -// user hand-edited the generated docker-compose.yml to loosen the binding, or if a -// future template change accidentally removes the loopback constraint. -// -// Severity: FAIL when the container is running and wildcard-bound; -// WARN when the container is not running (can't verify, may be intentional); -// PASS when bound to 127.0.0.1 as expected. -func checkMetricsPortBinding(projectDir string) CheckResult { - const checkID = "SEC-METRICS-01" - - // Check 1: inspect the generated docker-compose.yml for the port binding. - // This catches the misconfiguration before deployment. - composePath := filepath.Join(projectDir, "docker-compose.yml") - if data, err := os.ReadFile(composePath); err == nil { - content := string(data) - // Look for the prometheus service port section. - if strings.Contains(content, "prometheus") { - if strings.Contains(content, prometheusWildcardPort) { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "fail", - Message: fmt.Sprintf( - "SEC-METRICS-01: Prometheus port is bound to 0.0.0.0:9090 in docker-compose.yml — "+ - "Prometheus /metrics is accessible from the public internet. "+ - "Change to %q or run `nself build` to regenerate from the secure template.", - prometheusExpectedPort, - ), - FixCmd: "nself build # regenerates docker-compose.yml with 127.0.0.1:9090 binding", - } - } - if strings.Contains(content, prometheusExpectedPort) { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "pass", - Message: fmt.Sprintf("SEC-METRICS-01: Prometheus port bound to %s (loopback only)", prometheusExpectedPort), - } - } - } - } - - // Check 2: inspect the live Docker container binding (runtime verification). - // docker port 9090 returns the host binding for port 9090. - cmd := exec.CommandContext(context.Background(), "docker", "port", prometheusContainerSuffix[1:], "9090") - out, err := cmd.Output() - if err != nil { - // Container not running or docker unavailable — check the docker-compose source template. - templatePath := filepath.Join(projectDir, "docker-compose.monitoring.yml") - if tdata, terr := os.ReadFile(templatePath); terr == nil { - content := string(tdata) - if strings.Contains(content, prometheusWildcardPort) { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "warn", - Message: fmt.Sprintf( - "SEC-METRICS-01: docker-compose.monitoring.yml contains wildcard Prometheus binding (%s) — "+ - "container is not running but will be externally reachable when started", - prometheusWildcardPort, - ), - FixCmd: "nself build # regenerates from the secure template with 127.0.0.1:9090", - } - } - if strings.Contains(content, prometheusExpectedPort) { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "pass", - Message: "SEC-METRICS-01: Prometheus port binding is loopback-only in compose template (container not running)", - } - } - } - // Monitoring stack not deployed — not an error. - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "pass", - Message: "SEC-METRICS-01: monitoring stack not deployed (Prometheus not running) — binding check skipped", - } - } - - // Parse docker port output: "0.0.0.0:9090" or "127.0.0.1:9090". - binding := strings.TrimSpace(string(out)) - if strings.HasPrefix(binding, "0.0.0.0") || strings.HasPrefix(binding, ":::") { - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "fail", - Message: fmt.Sprintf( - "SEC-METRICS-01: Prometheus container port 9090 is externally bound (%s) — "+ - "Prometheus /metrics is accessible from the public internet. "+ - "Restart with the loopback binding: %s", - binding, prometheusExpectedPort, - ), - FixCmd: "nself build && nself restart # regenerates secure docker-compose.yml", - } - } - - return CheckResult{ - Section: hardeningSection, - Name: checkID, - Status: "pass", - Message: fmt.Sprintf("SEC-METRICS-01: Prometheus port 9090 bound to loopback (%s)", binding), - } -} - -// ─── helpers ────────────────────────────────────────────────────────────────── - -// envKeysMissing returns the subset of keys not found in any env file under projectDir. -func envKeysMissing(projectDir string, keys []string) []string { - var missing []string - for _, key := range keys { - if !envKeySet(projectDir, key) { - missing = append(missing, key) - } - } - return missing -} - -// envKeySet reports whether key is present (with a non-empty value) in any -// standard env file under projectDir. -func envKeySet(projectDir string, key string) bool { - return envKeyValue(projectDir, key) != "" -} - -// envKeyValue returns the value of key from the first env file where it appears, -// searching in order: .env.prod, .env.staging, .env.dev, .env.secrets, .env.local, .env. -// Returns "" if the key is not found or has no value. -func envKeyValue(projectDir string, key string) string { - candidates := []string{ - ".env.prod", ".env.staging", ".env.dev", - ".env.secrets", ".env.local", ".env", - } - for _, fname := range candidates { - val, found := readEnvFileKey(filepath.Join(projectDir, fname), key) - if found { - return val - } - } - return "" -} - -// readEnvFileKey reads a single KEY=VALUE line from an env file. -// Returns (value, true) on match, ("", false) when not found. -func readEnvFileKey(path, key string) (string, bool) { - f, err := os.Open(path) - if err != nil { - return "", false - } - defer f.Close() - - prefix := key + "=" - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if strings.HasPrefix(line, "#") || line == "" { - continue - } - if strings.HasPrefix(line, prefix) { - val := strings.TrimPrefix(line, prefix) - // Strip surrounding quotes. - val = strings.Trim(val, `"'`) - return val, true - } - } - return "", false -} diff --git a/internal/doctor/hardening_check_auth_net.go b/internal/doctor/hardening_check_auth_net.go new file mode 100644 index 00000000..56e600cd --- /dev/null +++ b/internal/doctor/hardening_check_auth_net.go @@ -0,0 +1,323 @@ +package doctor + +// hardening_check_auth_net.go — authentication and network security checks for `nself doctor`. +// +// Purpose: SEC-HARDENING-02 (rate-limit env vars set), SEC-HARDENING-03 (MFA +// brute-force throttle), SEC-HARDENING-04 (SSRF guard imported by all +// outbound-HTTP plugins), SEC-HARDENING-05 (JWT_PUBLIC_KEYS has ≥2 keys), +// SEC-HARDENING-06 (nginx rate-limit zones for /auth/login and /api/). +// Inputs: projectDir string for env-file reads and filesystem traversal. +// ctx context.Context for docker exec fallback in SEC-HARDENING-06. +// Outputs: CheckResult for each check — pass/warn/fail with remediation hint. +// Constraints: pluginHasSSRFGuard reads ssrfGuardSymbols from ssrf.go in the +// same package — no import needed. rateLimitEnvKeys and +// outboundHTTPPlugins are package-level vars so tests can override +// them without touching the function under test. +// SPORT: cli/internal/doctor — decomposed from hardening_check.go (T-E2-06). + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// rateLimitEnvKeys are the required AUTH_RATE_LIMIT_* env vars. +var rateLimitEnvKeys = []string{ + "AUTH_RATE_LIMIT_ENABLED", + "AUTH_RATE_LIMIT_MAX_REQUESTS", + "AUTH_RATE_LIMIT_WINDOW_SECONDS", +} + +// outboundHTTPPlugins are the plugins known to make outbound HTTP requests. +// These mirror the list in ssrf.go (ssrfServiceChecks). +var outboundHTTPPlugins = []string{"ai", "mux", "browser", "claw", "notify"} + +// ─── SEC-HARDENING-02: Auth rate-limit env vars set ────────────────────────── + +func checkHardeningRateLimit(projectDir string) CheckResult { + const checkID = "SEC-HARDENING-02" + + missing := envKeysMissing(projectDir, rateLimitEnvKeys) + if len(missing) == 0 { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "pass", + Message: "SEC-HARDENING-02: AUTH_RATE_LIMIT_* env vars are set", + } + } + + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "warn", + Message: fmt.Sprintf("SEC-HARDENING-02: missing rate-limit env vars: %s — add to .env.dev or .env.prod", strings.Join(missing, ", ")), + FixCmd: "nself config set AUTH_RATE_LIMIT_ENABLED=true AUTH_RATE_LIMIT_MAX_REQUESTS=100 AUTH_RATE_LIMIT_WINDOW_SECONDS=60", + } +} + +// ─── SEC-HARDENING-03: MFA throttle set ────────────────────────────────────── + +func checkHardeningMFAThrottle(projectDir string) CheckResult { + const checkID = "SEC-HARDENING-03" + const key = "AUTH_MFA_MAX_ATTEMPTS_PER_HOUR" + + if envKeySet(projectDir, key) { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "pass", + Message: fmt.Sprintf("SEC-HARDENING-03: %s is set", key), + } + } + + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "warn", + Message: fmt.Sprintf("SEC-HARDENING-03: %s not set — MFA brute-force throttle is off. Recommended: 5", key), + FixCmd: fmt.Sprintf("nself config set %s=5", key), + } +} + +// ─── SEC-HARDENING-04: SSRF guard imported by outbound-HTTP plugins ────────── + +func checkHardeningSSRFImport(projectDir string) CheckResult { + const checkID = "SEC-HARDENING-04" + + // Resolve plugins-pro/paid directory relative to the nself project root. + paidDir := filepath.Join(projectDir, "..", "plugins-pro", "paid") + if _, err := os.Stat(paidDir); os.IsNotExist(err) { + // plugins-pro may not be present on a dev machine; treat as warn not fail. + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "warn", + Message: "SEC-HARDENING-04: plugins-pro/paid directory not found — skipped (run on a full checkout)", + } + } + + var missing []string + for _, plugin := range outboundHTTPPlugins { + pluginDir := filepath.Join(paidDir, plugin) + if _, err := os.Stat(pluginDir); os.IsNotExist(err) { + continue // plugin not present in this checkout — skip + } + + // Use a simple file search for recognised SSRF guard symbols. + if !pluginHasSSRFGuard(pluginDir) { + missing = append(missing, plugin) + } + } + + if len(missing) == 0 { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "pass", + Message: "SEC-HARDENING-04: SSRF guard present in all outbound-HTTP plugins", + } + } + + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "fail", + Message: fmt.Sprintf("SEC-HARDENING-04: SSRF guard missing in plugin(s): %s", strings.Join(missing, ", ")), + FixCmd: "See plugins-pro docs: add shared/ssrf import to each listed plugin", + } +} + +// pluginHasSSRFGuard returns true when the plugin directory contains a file +// with a recognised SSRF guard symbol. ssrfGuardSymbols is declared in ssrf.go +// in the same package — no import needed. +func pluginHasSSRFGuard(pluginDir string) bool { + guardPaths := []string{ + filepath.Join(pluginDir, "shared", "ssrf.go"), + filepath.Join(pluginDir, "internal", "safety", "url_guard.go"), + filepath.Join(pluginDir, "internal", "safety", "urlcheck.go"), + filepath.Join(pluginDir, "internal", "tools", "browser", "client.go"), + filepath.Join(pluginDir, "internal", "push", "ssrf.go"), + filepath.Join(pluginDir, "providers", "ssrf.go"), + } + + for _, p := range guardPaths { + data, err := os.ReadFile(p) + if err != nil { + continue + } + content := string(data) + for _, sym := range ssrfGuardSymbols { + if strings.Contains(content, sym) { + return true + } + } + } + return false +} + +// ─── SEC-HARDENING-05: JWT_PUBLIC_KEYS has ≥2 keys ────────────────────────── + +func checkHardeningJWTPublicKeys(projectDir string) CheckResult { + const checkID = "SEC-HARDENING-05" + const key = "JWT_PUBLIC_KEYS" + + val := envKeyValue(projectDir, key) + if val == "" { + // Single-key mode (JWT_SECRET only) is functional but not rotation-ready. + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "warn", + Message: fmt.Sprintf("SEC-HARDENING-05: %s not set — JWT key rotation requires ≥2 public keys", key), + FixCmd: "nself self-heal --jwt", + } + } + + // Count comma-separated PEM blocks or JSON array elements. + count := countJWTKeys(val) + if count < 2 { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "warn", + Message: fmt.Sprintf("SEC-HARDENING-05: %s has only %d key — add a second key to enable zero-downtime rotation", key, count), + FixCmd: "nself self-heal --jwt", + } + } + + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "pass", + Message: fmt.Sprintf("SEC-HARDENING-05: %s has %d keys (rotation-ready)", key, count), + } +} + +// countJWTKeys counts distinct JWT public-key entries in a value string. +// Accepts a comma-separated list of PEM blocks or a JSON array of JWK objects. +func countJWTKeys(val string) int { + val = strings.TrimSpace(val) + + // JSON array: count "{" occurrences as a proxy for array elements. + if strings.HasPrefix(val, "[") { + return strings.Count(val, "{") + } + + // PEM / base64 list: count "-----BEGIN" blocks. + if strings.Contains(val, "BEGIN") { + return strings.Count(val, "-----BEGIN") + } + + // Comma-separated opaque list. + parts := strings.Split(val, ",") + count := 0 + for _, p := range parts { + if strings.TrimSpace(p) != "" { + count++ + } + } + return count +} + +// ─── SEC-HARDENING-06: Nginx rate-limit zones for /auth/login + /api/ ──────── + +func checkHardeningNginxRateZones(ctx context.Context, projectDir string) CheckResult { + const checkID = "SEC-HARDENING-06" + + // Search nginx/conf.d/ and nginx/sites/ for limit_req_zone + limit_req directives + // covering the two required paths. + nginxDirs := []string{ + filepath.Join(projectDir, "nginx", "conf.d"), + filepath.Join(projectDir, "nginx", "sites"), + filepath.Join(projectDir, "nginx", "nginx.conf"), + } + + hasAuthZone := false + hasAPIZone := false + + for _, root := range nginxDirs { + info, err := os.Stat(root) + if err != nil { + continue + } + + var files []string + if info.IsDir() { + entries, err := os.ReadDir(root) + if err != nil { + continue + } + for _, e := range entries { + if !e.IsDir() { + files = append(files, filepath.Join(root, e.Name())) + } + } + } else { + files = []string{root} + } + + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + continue + } + content := string(data) + if strings.Contains(content, "/auth/login") && strings.Contains(content, "limit_req") { + hasAuthZone = true + } + if strings.Contains(content, "/api/") && strings.Contains(content, "limit_req") { + hasAPIZone = true + } + } + } + + // Fallback: inspect nginx container config if local files not found. + if !hasAuthZone || !hasAPIZone { + cmd := exec.CommandContext(ctx, "docker", "exec", "nself_nginx", + "grep", "-r", "limit_req", "/etc/nginx/") + out, err := cmd.Output() + if err == nil { + content := string(out) + if strings.Contains(content, "auth/login") { + hasAuthZone = true + } + if strings.Contains(content, "/api/") { + hasAPIZone = true + } + } + } + + switch { + case hasAuthZone && hasAPIZone: + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "pass", + Message: "SEC-HARDENING-06: nginx rate-limit zones set for /auth/login and /api/", + } + case !hasAuthZone && !hasAPIZone: + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "fail", + Message: "SEC-HARDENING-06: nginx missing rate-limit zones for /auth/login and /api/ — add limit_req_zone directives", + FixCmd: "See docs.nself.org/security/nginx-rate-limiting", + } + default: + missing := "/api/" + if !hasAuthZone { + missing = "/auth/login" + } + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "warn", + Message: fmt.Sprintf("SEC-HARDENING-06: nginx rate-limit zone missing for %s — add limit_req_zone directive", missing), + FixCmd: "See docs.nself.org/security/nginx-rate-limiting", + } + } +} diff --git a/internal/doctor/hardening_check_db.go b/internal/doctor/hardening_check_db.go new file mode 100644 index 00000000..9c02f536 --- /dev/null +++ b/internal/doctor/hardening_check_db.go @@ -0,0 +1,182 @@ +package doctor + +// hardening_check_db.go — database-layer security checks for `nself doctor`. +// +// Purpose: SEC-HARDENING-01 (RLS enabled + FORCE RLS on all np_* tables) and +// SEC-HARDENING-07 (audit log enabled with append-only trigger). +// Inputs: ctx context.Context for docker exec / psql; projectDir string for +// the nself-audit plugin list check. +// Outputs: CheckResult for each check — pass/warn/fail with remediation hint. +// Constraints: Both checks invoke docker exec nself_postgres for DB access. +// checkAuditTrigger returns "pass"/"fail" (not CheckResult) so it +// can be reused from two call sites in checkHardeningAuditLog. +// auditTableName and auditTriggerName are exported as package-level +// consts so tests can reference them directly. +// SPORT: cli/internal/doctor — decomposed from hardening_check.go (T-E2-06). + +import ( + "context" + "fmt" + "os/exec" + "strings" +) + +// auditTableName is the canonical table created by the nself-audit plugin +// migration (plugins-pro/paid/nself-audit/migrations/001_init.sql). +const auditTableName = "np_audit_events" + +// auditTriggerName is the append-only trigger installed by 001_init.sql that +// blocks UPDATE and DELETE on np_audit_events to ensure tamper-evidence. +const auditTriggerName = "trg_audit_immutable" + +// ─── SEC-HARDENING-01: RLS enabled + FORCE RLS on every np_* table ────────── + +func checkHardeningRLS(ctx context.Context) CheckResult { + const checkID = "SEC-HARDENING-01" + + // Query pg_class for np_* tables that are missing RLS enable or force. + query := `SELECT relname, + relrowsecurity::text AS rls_enabled, + relforcerowsecurity::text AS rls_forced + FROM pg_class + WHERE relname LIKE 'np_%' + AND relkind = 'r' + AND (NOT relrowsecurity OR NOT relforcerowsecurity) + ORDER BY relname;` + + cmd := exec.CommandContext(ctx, + "docker", "exec", "nself_postgres", + "psql", "-U", "postgres", "-t", "-c", query, + ) + out, err := cmd.Output() + if err != nil { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "warn", + Message: fmt.Sprintf("SEC-HARDENING-01: cannot query pg_class (%v) — ensure Postgres is running", err), + FixCmd: "nself start", + } + } + + // Trim whitespace; empty output means every np_* table has RLS+FORCE. + violations := strings.TrimSpace(string(out)) + if violations == "" { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "pass", + Message: "SEC-HARDENING-01: all np_* tables have ENABLE and FORCE RLS", + } + } + + // Count violating tables. + count := len(strings.Split(violations, "\n")) + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "fail", + Message: fmt.Sprintf("SEC-HARDENING-01: %d np_* table(s) missing ENABLE or FORCE RLS — run nself doctor --fix to remediate", count), + FixCmd: "nself doctor --fix --check SEC-HARDENING-01", + } +} + +// ─── SEC-HARDENING-07: Audit log enabled ───────────────────────────────────── + +func checkHardeningAuditLog(ctx context.Context, projectDir string) CheckResult { + const checkID = "SEC-HARDENING-07" + + // Check 1: nself-audit plugin listed as installed. + listCmd := exec.CommandContext(ctx, "nself", "plugin", "list", "--installed") + listOut, listErr := listCmd.Output() + if listErr == nil && strings.Contains(string(listOut), "nself-audit") { + // Plugin is installed; also verify the append-only trigger is present + // to confirm the tamper-evident guarantee is actually in place. + triggerCheck := checkAuditTrigger(ctx) + if triggerCheck == "pass" { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "pass", + Message: fmt.Sprintf("SEC-HARDENING-07: nself-audit plugin installed, %s present with %s append-only trigger", auditTableName, auditTriggerName), + } + } + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "warn", + Message: fmt.Sprintf("SEC-HARDENING-07: nself-audit plugin installed but %s trigger not found — migration may not have run", auditTriggerName), + FixCmd: "nself plugin run nself-audit migrate", + } + } + + // Check 2: np_audit_events table exists and is writable (INSERT privilege check). + // The table is INSERT+SELECT only — UPDATE and DELETE are blocked by trigger. + query := fmt.Sprintf(`SELECT has_table_privilege(current_user, '%s', 'INSERT')::text;`, auditTableName) + cmd := exec.CommandContext(ctx, + "docker", "exec", "nself_postgres", + "psql", "-U", "postgres", "-t", "-c", query, + ) + out, err := cmd.Output() + if err == nil && strings.TrimSpace(string(out)) == "t" { + // Table writable — also verify append-only trigger. + triggerCheck := checkAuditTrigger(ctx) + if triggerCheck == "pass" { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "pass", + Message: fmt.Sprintf("SEC-HARDENING-07: %s present, writable, and append-only trigger active", auditTableName), + } + } + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "warn", + Message: fmt.Sprintf("SEC-HARDENING-07: %s writable but %s trigger missing — audit events not tamper-evident", auditTableName, auditTriggerName), + FixCmd: "nself plugin run nself-audit migrate", + } + } + + // Check 3: table exists but INSERT privilege not confirmed. + existsQuery := fmt.Sprintf(`SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name='%s')::text;`, auditTableName) + existsCmd := exec.CommandContext(ctx, + "docker", "exec", "nself_postgres", + "psql", "-U", "postgres", "-t", "-c", existsQuery, + ) + existsOut, existsErr := existsCmd.Output() + if existsErr == nil && strings.TrimSpace(string(existsOut)) == "t" { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "warn", + Message: fmt.Sprintf("SEC-HARDENING-07: %s table exists but INSERT privilege not confirmed — verify write access", auditTableName), + } + } + + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "warn", + Message: fmt.Sprintf("SEC-HARDENING-07: audit log not detected — install nself-audit plugin (table: %s)", auditTableName), + FixCmd: "nself plugin install nself-audit", + } +} + +// checkAuditTrigger verifies that the append-only trigger trg_audit_immutable +// exists on np_audit_events. Returns "pass" if confirmed, "fail" otherwise. +func checkAuditTrigger(ctx context.Context) string { + triggerQuery := fmt.Sprintf( + `SELECT EXISTS(SELECT 1 FROM information_schema.triggers WHERE trigger_name='%s' AND event_object_table='%s')::text;`, + auditTriggerName, auditTableName, + ) + triggerCmd := exec.CommandContext(ctx, + "docker", "exec", "nself_postgres", + "psql", "-U", "postgres", "-t", "-c", triggerQuery, + ) + triggerOut, triggerErr := triggerCmd.Output() + if triggerErr == nil && strings.TrimSpace(string(triggerOut)) == "t" { + return "pass" + } + return "fail" +} diff --git a/internal/doctor/hardening_check_helpers.go b/internal/doctor/hardening_check_helpers.go new file mode 100644 index 00000000..24677fb4 --- /dev/null +++ b/internal/doctor/hardening_check_helpers.go @@ -0,0 +1,84 @@ +package doctor + +// hardening_check_helpers.go — shared env-file helpers for hardening checks. +// +// Purpose: Read KEY=VALUE pairs from standard nSelf env files (.env.prod, +// .env.staging, .env.dev, .env.secrets, .env.local, .env) under +// projectDir. Used by hardening_check_auth_net.go and +// hardening_check_infra.go to check whether required env vars are set. +// Inputs: projectDir string (nSelf working directory) and key string(s). +// Outputs: Missing key list (envKeysMissing), bool presence (envKeySet), or +// string value (envKeyValue). readEnvFileKey is the leaf reader. +// Constraints: Searches env files in precedence order (prod first, bare .env +// last). Comments (#) and blank lines are skipped. Values are +// stripped of surrounding single or double quotes. +// SPORT: cli/internal/doctor — decomposed from hardening_check.go (T-E2-06). + +import ( + "bufio" + "os" + "path/filepath" + "strings" +) + +// envKeysMissing returns the subset of keys not found in any standard env file +// under projectDir. +func envKeysMissing(projectDir string, keys []string) []string { + var missing []string + for _, key := range keys { + if !envKeySet(projectDir, key) { + missing = append(missing, key) + } + } + return missing +} + +// envKeySet reports whether key is present (non-empty) in any standard env +// file under projectDir. +func envKeySet(projectDir string, key string) bool { + return envKeyValue(projectDir, key) != "" +} + +// envKeyValue returns the value of key from the first standard env file under +// projectDir where it appears. Searches in precedence order: +// .env.prod → .env.staging → .env.dev → .env.secrets → .env.local → .env. +// Returns "" when the key is not found in any file. +func envKeyValue(projectDir string, key string) string { + candidates := []string{ + ".env.prod", ".env.staging", ".env.dev", + ".env.secrets", ".env.local", ".env", + } + for _, fname := range candidates { + val, found := readEnvFileKey(filepath.Join(projectDir, fname), key) + if found { + return val + } + } + return "" +} + +// readEnvFileKey reads a single KEY=VALUE line from the env file at path. +// Returns the value and true on success; "", false when the file does not +// exist, cannot be opened, or does not contain the key. +func readEnvFileKey(path, key string) (string, bool) { + f, err := os.Open(path) + if err != nil { + return "", false + } + defer f.Close() + + prefix := key + "=" + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "#") || line == "" { + continue + } + if strings.HasPrefix(line, prefix) { + val := strings.TrimPrefix(line, prefix) + val = strings.Trim(val, `"'`) + return val, true + } + } + return "", false +} diff --git a/internal/doctor/hardening_check_infra.go b/internal/doctor/hardening_check_infra.go new file mode 100644 index 00000000..c9abd607 --- /dev/null +++ b/internal/doctor/hardening_check_infra.go @@ -0,0 +1,426 @@ +package doctor + +// hardening_check_infra.go — infrastructure and operational security checks for `nself doctor`. +// +// Purpose: SEC-HARDENING-08 (encryption-at-rest env var or disk marker), +// SEC-CORS-01 (HASURA_GRAPHQL_CORS_DOMAIN not wildcard in prod), +// SEC-DEVMODE-01 (Hasura dev-mode not true in staging/prod), +// SEC-OFFLINE-01 (license cache fetched_at within 24h), +// SEC-METRICS-01 (Prometheus port 9090 bound to 127.0.0.1 only). +// Inputs: projectDir string for env-file reads, docker-compose.yml inspection, +// and nginx config traversal. ctx context.Context for docker port check. +// Outputs: CheckResult for each check — pass/warn/fail with remediation hint. +// Constraints: formatAge is a private helper used only by checkLicenseOffline. +// prometheusContainerSuffix/ExpectedPort/WildcardPort are package- +// level consts so tests can reference them without magic strings. +// encryptionEnvKeys and encryptedDiskMarkers are vars (not consts) +// so tests can inject alternative lists. +// SPORT: cli/internal/doctor — decomposed from hardening_check.go (T-E2-06). + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// encryptionEnvKeys lists env vars that declare encryption-at-rest is active. +var encryptionEnvKeys = []string{ + "POSTGRES_DATA_ENCRYPTED", + "NSELF_DISK_ENCRYPTED", + "NSELF_ENCRYPTION_AT_REST", +} + +// encryptedDiskMarkers are filesystem paths whose presence signals +// the volume is backed by an encrypted disk (dm-crypt, LUKS, FileVault, etc.). +var encryptedDiskMarkers = []string{ + "/etc/crypttab", // LUKS-managed volumes (Linux) + "/sys/block/dm-0/dm/name", // device-mapper (dm-crypt) present +} + +// prometheusContainerSuffix is the Docker container name suffix used by the nself +// monitoring stack (docker-compose.monitoring.yml — PROJECT_NAME_prometheus). +// The name is checked as a suffix so it matches any project prefix. +const prometheusContainerSuffix = "_prometheus" + +// prometheusExpectedPort is the canonical loopback binding for the Prometheus +// web UI and /metrics endpoint. Any binding to 0.0.0.0:9090 would expose +// scraped metrics (including secrets in labels) to the public internet. +const prometheusExpectedPort = "127.0.0.1:9090" + +// prometheusWildcardPort is the dangerous binding that exposes Prometheus to +// all interfaces. +const prometheusWildcardPort = "0.0.0.0:9090" + +// ─── SEC-HARDENING-08: Encryption-at-rest env var or disk marker ───────────── + +func checkHardeningEncryptionAtRest(projectDir string) CheckResult { + const checkID = "SEC-HARDENING-08" + + // Check 1: env var declares encryption-at-rest. + for _, key := range encryptionEnvKeys { + val := envKeyValue(projectDir, key) + if strings.EqualFold(strings.TrimSpace(val), "true") || + strings.EqualFold(strings.TrimSpace(val), "1") || + strings.EqualFold(strings.TrimSpace(val), "yes") { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "pass", + Message: fmt.Sprintf("SEC-HARDENING-08: encryption-at-rest confirmed via %s=%s", key, val), + } + } + } + + // Check 2: filesystem markers indicate encrypted disk. + for _, marker := range encryptedDiskMarkers { + if _, err := os.Stat(marker); err == nil { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "pass", + Message: fmt.Sprintf("SEC-HARDENING-08: encrypted disk detected via %s", marker), + } + } + } + + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "warn", + Message: "SEC-HARDENING-08: encryption-at-rest not confirmed — set POSTGRES_DATA_ENCRYPTED=true or deploy on an encrypted volume", + FixCmd: "nself config set POSTGRES_DATA_ENCRYPTED=true # then verify disk-level encryption in your VPS settings", + } +} + +// ─── SEC-CORS-01: CORS domain not wildcard in staging/prod ─────────────────── + +// checkCORSDomain fails when HASURA_GRAPHQL_CORS_DOMAIN contains a bare "*" +// in a staging or production environment. A wildcard CORS policy allows any +// origin to read Hasura responses, bypassing the authentication layer for +// browsers. The validator.go check catches this at startup; this doctor check +// surfaces it for running deployments. +func checkCORSDomain(projectDir string) CheckResult { + const checkID = "SEC-CORS-01" + + // Detect environment from env files. + nenv := envKeyValue(projectDir, "NSELF_ENV") + if nenv == "" { + nenv = envKeyValue(projectDir, "NODE_ENV") + } + isProd := nenv == "staging" || nenv == "prod" || nenv == "production" + if !isProd { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "pass", + Message: "SEC-CORS-01: CORS check skipped (non-production environment)", + } + } + + domain := envKeyValue(projectDir, "HASURA_GRAPHQL_CORS_DOMAIN") + if domain == "" { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "warn", + Message: "SEC-CORS-01: HASURA_GRAPHQL_CORS_DOMAIN is not set — explicit domain required in production", + FixCmd: `nself config set HASURA_GRAPHQL_CORS_DOMAIN="https://yourdomain.example"`, + } + } + + // Block bare wildcard "*" and patterns like "https://*". + if strings.TrimSpace(domain) == "*" || strings.HasPrefix(strings.TrimSpace(domain), "*") { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "fail", + Message: fmt.Sprintf("SEC-CORS-01: HASURA_GRAPHQL_CORS_DOMAIN=%q is a wildcard — any origin can read Hasura in production", domain), + FixCmd: `nself config set HASURA_GRAPHQL_CORS_DOMAIN="https://yourdomain.example"`, + } + } + + // Warn on sub-domain wildcard patterns like "https://*.example.com" — less + // dangerous than bare "*" but still allows any subdomain as origin. + if strings.Contains(domain, "*") { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "warn", + Message: fmt.Sprintf("SEC-CORS-01: HASURA_GRAPHQL_CORS_DOMAIN=%q contains a wildcard — restrict to explicit origins if possible", domain), + FixCmd: `nself config set HASURA_GRAPHQL_CORS_DOMAIN="https://app.yourdomain.example"`, + } + } + + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "pass", + Message: fmt.Sprintf("SEC-CORS-01: CORS domain is explicit: %q", domain), + } +} + +// ─── SEC-DEVMODE-01: Hasura dev-mode not enabled in staging/prod ────────────── + +// checkHasuraDevMode fails when HASURA_GRAPHQL_DEV_MODE=true is found in any +// production or staging env file. Dev mode exposes the Hasura Console and +// introspection to the public internet and must never be enabled in deployed +// environments. ValidateHasuraDevMode (cli/internal/config/validator.go) also +// emits a structured slog.Error when the runtime block fires; this doctor check +// surfaces the misconfiguration before deployment. +func checkHasuraDevMode(projectDir string) CheckResult { + const checkID = "SEC-DEVMODE-01" + + nenv := envKeyValue(projectDir, "NSELF_ENV") + if nenv == "" { + nenv = envKeyValue(projectDir, "ENV") + } + isProd := nenv == "staging" || nenv == "prod" || nenv == "production" + if !isProd { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "pass", + Message: "SEC-DEVMODE-01: Hasura dev-mode check skipped (non-production environment)", + } + } + + devMode := envKeyValue(projectDir, "HASURA_GRAPHQL_DEV_MODE") + if strings.ToLower(strings.TrimSpace(devMode)) == "true" { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "fail", + Message: fmt.Sprintf( + "SEC-DEVMODE-01: HASURA_GRAPHQL_DEV_MODE=true in %s — "+ + "Hasura Console and schema introspection are exposed to the public internet", + nenv, + ), + FixCmd: "nself config set HASURA_GRAPHQL_DEV_MODE=false", + } + } + + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "pass", + Message: fmt.Sprintf("SEC-DEVMODE-01: HASURA_GRAPHQL_DEV_MODE is not true in %s", nenv), + } +} + +// ─── SEC-OFFLINE-01: license cache fresh (<24h) ──────────────────────────────── + +// checkLicenseOffline warns when the license cache's fetched_at timestamp is +// older than 24 hours. This indicates the license server has been unreachable +// for a day or more. The check is advisory-only (warn, never fail): the +// fail-open policy in validator.go handles the go/no-go decision. This doctor +// check surfaces the offline duration early so operators can investigate before +// plugins go dormant (FailOpenSoftTTL = 72h, FailOpenHardTTL = 14d). +// +// Cache location: ~/.cache/nself/license.json (overridable via LICENSE_CACHE_PATH). +func checkLicenseOffline() CheckResult { + const checkID = "SEC-OFFLINE-01" + + cachePath := os.Getenv("LICENSE_CACHE_PATH") + if cachePath == "" { + home, err := os.UserHomeDir() + if err != nil { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "warn", + Message: "SEC-OFFLINE-01: cannot determine home directory — license cache location unknown", + } + } + cachePath = filepath.Join(home, ".cache", "nself", "license.json") + } + + data, err := os.ReadFile(cachePath) + if err != nil { + if os.IsNotExist(err) { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "pass", + Message: "SEC-OFFLINE-01: no license cache present (run nself license validate to populate)", + } + } + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "warn", + Message: fmt.Sprintf("SEC-OFFLINE-01: cannot read license cache: %v", err), + } + } + + var entry struct { + FetchedAt int64 `json:"fetched_at"` + } + if err := json.Unmarshal(data, &entry); err != nil || entry.FetchedAt == 0 { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "warn", + Message: "SEC-OFFLINE-01: license cache is unreadable or missing fetched_at — run nself license validate", + FixCmd: "nself license validate", + } + } + + age := time.Since(time.Unix(entry.FetchedAt, 0)) + const warnThreshold = 24 * time.Hour + + if age <= warnThreshold { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "pass", + Message: fmt.Sprintf("SEC-OFFLINE-01: license cache is fresh (%s old)", formatAge(age)), + } + } + + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "warn", + Message: fmt.Sprintf( + "SEC-OFFLINE-01: license server unreachable for %s (cache age exceeds 24h) — "+ + "plugins go dormant at 72h if unreachable", + formatAge(age), + ), + FixCmd: "nself license validate # run while connected to the internet", + } +} + +// formatAge formats a duration as a human-readable string (e.g. "2d 3h" or "45m"). +func formatAge(d time.Duration) string { + d = d.Truncate(time.Minute) + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + mins := int(d.Minutes()) % 60 + switch { + case days > 0 && hours > 0: + return fmt.Sprintf("%dd %dh", days, hours) + case days > 0: + return fmt.Sprintf("%dd", days) + case hours > 0 && mins > 0: + return fmt.Sprintf("%dh %dm", hours, mins) + case hours > 0: + return fmt.Sprintf("%dh", hours) + default: + return fmt.Sprintf("%dm", mins) + } +} + +// ─── SEC-METRICS-01: Prometheus port not externally reachable ───────────────── + +// checkMetricsPortBinding verifies that the Prometheus container port 9090 is +// bound to 127.0.0.1 (loopback) and not to 0.0.0.0 (all interfaces). +// +// The nself monitoring docker-compose template already enforces "127.0.0.1:9090:9090" +// (see cli/internal/compose/docker-compose.monitoring.yml). This check detects if a +// user hand-edited the generated docker-compose.yml to loosen the binding, or if a +// future template change accidentally removes the loopback constraint. +// +// Severity: FAIL when the container is running and wildcard-bound; +// WARN when the container is not running (can't verify, may be intentional); +// PASS when bound to 127.0.0.1 as expected. +func checkMetricsPortBinding(projectDir string) CheckResult { + const checkID = "SEC-METRICS-01" + + // Check 1: inspect the generated docker-compose.yml for the port binding. + composePath := filepath.Join(projectDir, "docker-compose.yml") + if data, err := os.ReadFile(composePath); err == nil { + content := string(data) + if strings.Contains(content, "prometheus") { + if strings.Contains(content, prometheusWildcardPort) { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "fail", + Message: fmt.Sprintf( + "SEC-METRICS-01: Prometheus port is bound to 0.0.0.0:9090 in docker-compose.yml — "+ + "Prometheus /metrics is accessible from the public internet. "+ + "Change to %q or run `nself build` to regenerate from the secure template.", + prometheusExpectedPort, + ), + FixCmd: "nself build # regenerates docker-compose.yml with 127.0.0.1:9090 binding", + } + } + if strings.Contains(content, prometheusExpectedPort) { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "pass", + Message: fmt.Sprintf("SEC-METRICS-01: Prometheus port bound to %s (loopback only)", prometheusExpectedPort), + } + } + } + } + + // Check 2: inspect the live Docker container binding (runtime verification). + cmd := exec.CommandContext(context.Background(), "docker", "port", prometheusContainerSuffix[1:], "9090") + out, err := cmd.Output() + if err != nil { + // Container not running — check the docker-compose source template. + templatePath := filepath.Join(projectDir, "docker-compose.monitoring.yml") + if tdata, terr := os.ReadFile(templatePath); terr == nil { + content := string(tdata) + if strings.Contains(content, prometheusWildcardPort) { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "warn", + Message: fmt.Sprintf( + "SEC-METRICS-01: docker-compose.monitoring.yml contains wildcard Prometheus binding (%s) — "+ + "container is not running but will be externally reachable when started", + prometheusWildcardPort, + ), + FixCmd: "nself build # regenerates from the secure template with 127.0.0.1:9090", + } + } + if strings.Contains(content, prometheusExpectedPort) { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "pass", + Message: "SEC-METRICS-01: Prometheus port binding is loopback-only in compose template (container not running)", + } + } + } + // Monitoring stack not deployed — not an error. + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "pass", + Message: "SEC-METRICS-01: monitoring stack not deployed (Prometheus not running) — binding check skipped", + } + } + + // Parse docker port output: "0.0.0.0:9090" or "127.0.0.1:9090". + binding := strings.TrimSpace(string(out)) + if strings.HasPrefix(binding, "0.0.0.0") || strings.HasPrefix(binding, ":::") { + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "fail", + Message: fmt.Sprintf( + "SEC-METRICS-01: Prometheus container port 9090 is externally bound (%s) — "+ + "Prometheus /metrics is accessible from the public internet. "+ + "Restart with the loopback binding: %s", + binding, prometheusExpectedPort, + ), + FixCmd: "nself build && nself restart # regenerates secure docker-compose.yml", + } + } + + return CheckResult{ + Section: hardeningSection, + Name: checkID, + Status: "pass", + Message: fmt.Sprintf("SEC-METRICS-01: Prometheus port 9090 bound to loopback (%s)", binding), + } +} diff --git a/internal/plugin/scaffold/scaffold.go b/internal/plugin/scaffold/scaffold.go index 8cff86e8..aa8b6032 100644 --- a/internal/plugin/scaffold/scaffold.go +++ b/internal/plugin/scaffold/scaffold.go @@ -5,6 +5,16 @@ // Both entry points call scaffold.Run with a Params struct; the output is // identical so that plugin authors get the same result regardless of whether // they have nself installed or use the SDK devkit directly. +// +// Purpose: Core types and all logic functions for plugin scaffold generation. +// Template strings are in scaffold_templates_infra.go (infrastructure, +// devops, metadata templates) and scaffold_templates_code.go (Go code +// templates: main, config, server, server_test). +// Inputs: Options struct — name, tier, language, tenancy mode, overrides. +// Outputs: Result struct — output directory path and list of emitted files. +// Constraints: Must remain import-compatible with plugin-sdk-go/devkit. +// Template strings must live in the _templates_*.go files, not here. +// SPORT: cli/internal/plugin/scaffold — decomposed from scaffold.go (T-E2-06). package scaffold import ( @@ -480,459 +490,3 @@ func licenseForTier(tier string) string { } return "MIT" } - -// --- tenancy + plugin.json templates --- - -// tmplPluginJSON is the plugin.json template. It requires a struct with all -// Params fields plus MultiAppSupported (bool) and IsolationColumn (string). -const tmplPluginJSON = `{ - "name": "{{.Name}}", - "version": "0.1.0", - "description": "{{.Description}}", - "author": "{{.Author}}", - "license": {{if eq .Tier "pro"}}"Source-Available"{{else}}"MIT"{{end}}, - "isCommercial": {{if eq .Tier "pro"}}true{{else}}false{{end}}, - {{- if eq .Tier "pro"}} - "licenseType": "pro", - "requiredEntitlements": ["pro"], - "requires_license": true, - {{- end}} - "homepage": "https://nself.org/plugins", - "minNselfVersion": "{{.MinCLI}}", - "minSdkVersion": "{{.MinSDK}}", - "category": "{{.Category}}", - {{- if .Bundle}} - "bundle": "{{.Bundle}}", - {{- end}} - "multiApp": { - "supported": {{.MultiAppSupported}}, - "isolationColumn": "{{.IsolationColumn}}" - }, - "tags": ["{{.Name}}"] -} -` - -// tmplMigrationNone is emitted when the plugin stores no per-user Postgres data. -const tmplMigrationNone = `-- {{.Name}} initial migration --- No multi-tenancy columns required for this plugin. --- Add your schema here. - -CREATE TABLE IF NOT EXISTS np_{{.Name}}_items ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); -` - -// tmplMigrationAppIsolation is emitted for multi-app isolation within one nSelf deploy. -// Uses source_account_id per the Multi-Tenant Convention Wall. -const tmplMigrationAppIsolation = `-- {{.Name}} initial migration --- Multi-app isolation: source_account_id separates independent consumer apps --- within one nSelf deploy. See: docs/architecture/multi-tenant-conventions.md - -CREATE TABLE IF NOT EXISTS np_{{.Name}}_items ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - source_account_id TEXT NOT NULL DEFAULT 'primary', - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Row-level security: each app sees only its own rows. -ALTER TABLE np_{{.Name}}_items ENABLE ROW LEVEL SECURITY; - -CREATE POLICY np_{{.Name}}_items_isolation ON np_{{.Name}}_items - USING (source_account_id = current_setting('app.source_account_id', true)); -` - -// tmplMigrationCloudTenant is emitted for Cloud SaaS tenancy. -// Uses tenant_id UUID per the Multi-Tenant Convention Wall. -const tmplMigrationCloudTenant = `-- {{.Name}} initial migration --- Cloud multi-tenancy: tenant_id separates paying customers in nSelf Cloud SaaS. --- See: docs/architecture/multi-tenant-conventions.md --- NEVER use tenant_id for per-app isolation within one deploy — use the app-isolation column instead. - -CREATE TABLE IF NOT EXISTS np_{{.Name}}_items ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Row-level security: each tenant sees only its own rows. -ALTER TABLE np_{{.Name}}_items ENABLE ROW LEVEL SECURITY; - -CREATE POLICY np_{{.Name}}_items_tenant ON np_{{.Name}}_items - USING (tenant_id = current_setting('app.tenant_id', true)::UUID); -` - -// tmplMigrationBoth emits both columns for developers who are unsure which -// convention they need. Remove the unused column before going to production. -const tmplMigrationBoth = `-- {{.Name}} initial migration --- Both multi-tenancy columns included. Remove the one you do not need before --- going to production. See: docs/architecture/multi-tenant-conventions.md --- --- source_account_id: per-app isolation within one nSelf deploy --- tenant_id: Cloud SaaS paying-customer isolation - -CREATE TABLE IF NOT EXISTS np_{{.Name}}_items ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - source_account_id TEXT NOT NULL DEFAULT 'primary', - tenant_id UUID, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -ALTER TABLE np_{{.Name}}_items ENABLE ROW LEVEL SECURITY; - --- Choose ONE of the two policies below and delete the other. -CREATE POLICY np_{{.Name}}_items_isolation ON np_{{.Name}}_items - USING (source_account_id = current_setting('app.source_account_id', true)); - --- CREATE POLICY np_{{.Name}}_items_tenant ON np_{{.Name}}_items --- USING (tenant_id = current_setting('app.tenant_id', true)::UUID); -` - -// tmplHasuraNoFilter is the Hasura metadata stub for plugins that use -// source_account_id (app isolation) or no tenancy at all. No tenant row filter -// is required because isolation is handled at the Postgres RLS layer. -const tmplHasuraNoFilter = `{ - "table": { - "schema": "public", - "name": "np_{{.Name}}_items" - }, - "select_permissions": [ - { - "role": "user", - "permission": { - "columns": "*", - "filter": {} - } - } - ] -} -` - -// tmplHasuraCloudFilter is the Hasura metadata stub for Cloud multi-tenant -// plugins. The row filter enforces that each tenant only sees its own rows via -// the X-Hasura-Tenant-Id session variable. -const tmplHasuraCloudFilter = `{ - "table": { - "schema": "public", - "name": "np_{{.Name}}_items" - }, - "select_permissions": [ - { - "role": "user", - "permission": { - "columns": "*", - "filter": { - "tenant_id": { - "_eq": "X-Hasura-Tenant-Id" - } - } - } - } - ] -} -` - -// --- template strings --- - -const tmplCompose = `# docker-compose.plugin.yml for {{.Name}} -# Merged into the generated stack by ` + "`nself build`" + `. Do not hand-edit. -services: - {{.Name}}: - image: nself/{{.Name}}:${{"{"}}{{.EnvPrefix}}_VERSION:-latest} - container_name: ${PROJECT_NAME:-nself}_{{.Name}} - restart: unless-stopped - environment: - LOG_LEVEL: ${LOG_LEVEL:-info} - DATABASE_URL: ${DATABASE_URL} - {{.EnvPrefix}}_LISTEN_ADDR: ":{{.Port}}" - ports: - - "127.0.0.1:{{.Port}}:{{.Port}}" - networks: - - nself_net -networks: - nself_net: - external: true -` - -const tmplDockerignore = `.git -.gitignore -README.md -Dockerfile -docker-compose*.yml -.air.toml -tmp/ -*.test -coverage.out -` - -const tmplAirToml = `# air.toml — hot-reload for {{.Name}} dev (pair with nself plugin dev) -root = "." -tmp_dir = "tmp" - -[build] - cmd = "go build -o ./tmp/{{.Name}} ./cmd" - bin = "tmp/{{.Name}}" - delay = 500 - include_ext = ["go", "yaml", "yml"] - exclude_dir = ["tmp", "vendor", ".git"] - -[log] - time = true - -[color] - app = "magenta" -` - -const tmplReadme = `# {{.PascalName}} Plugin - -{{.Description}} - -Tier: ` + "`{{.Tier}}`" + `{{if .Bundle}} · Bundle: ` + "`{{.Bundle}}`" + `{{end}} · Category: ` + "`{{.Category}}`" + ` - -## Local development - -` + "```bash" + ` -go mod tidy -go test ./... -go run ./cmd # runs on :{{.Port}} -` + "```" + ` - -With hot-reload (install [air](https://github.com/air-verse/air)): - -` + "```bash" + ` -nself plugin dev {{.Name}} -` + "```" + ` - -## Endpoints - -- ` + "`GET /healthz`" + ` — liveness -- ` + "`GET /readyz`" + ` — readiness -- ` + "`GET /metrics`" + ` — Prometheus metrics -- ` + "`GET /version`" + ` — plugin version -- ` + "`GET /v1/hello`" + ` — starter handler - -## License - -{{if eq .Tier "pro"}}Source-Available (pro tier). Requires an active nSelf license key.{{else}}MIT.{{end}} -` - -const tmplCI = `name: CI -on: [push, pull_request] -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: "1.23" - - name: Test - run: go test ./... - - name: Build Docker image - run: docker build -t {{.Name}}:test . - - name: Health check - run: | - docker run -d -p {{.Port}}:{{.Port}} --name test {{.Name}}:test - sleep 2 - curl -f http://localhost:{{.Port}}/healthz - docker stop test -` - -const tmplMain = `// Package main is the entrypoint for the {{.Name}} plugin. -package main - -import ( - "context" - "errors" - "net/http" - "os" - "os/signal" - "syscall" - "time" - - "github.com/nself-org/{{.RepoBucket}}/{{.Name}}/internal/config" - "github.com/nself-org/{{.RepoBucket}}/{{.Name}}/internal/server" - - "github.com/nself-org/plugin-sdk-go/logger" -) - -// Version is stamped at build time via -ldflags. -var Version = "0.1.0" - -func main() { - cfg := config.FromEnv() - log := logger.New(logger.Options{ - Plugin: "{{.Name}}", - Version: Version, - Level: logger.ParseLevel(cfg.LogLevel), - }) - - srv := server.New(server.Deps{Config: cfg, Logger: log, Version: Version}) - - httpSrv := &http.Server{ - Addr: cfg.ListenAddr, - Handler: srv, - ReadHeaderTimeout: 10 * time.Second, - } - - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - - errCh := make(chan error, 1) - go func() { - defer func() { - if r := recover(); r != nil { - errCh <- fmt.Errorf("server goroutine panic: %v", r) - } - }() - log.Info("{{.Name}} listening", "addr", cfg.ListenAddr) - errCh <- httpSrv.ListenAndServe() - }() - - select { - case <-ctx.Done(): - log.Info("shutdown signal received") - case err := <-errCh: - if err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Error("server failed", "error", err) - } - } - - shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - if err := httpSrv.Shutdown(shutdownCtx); err != nil { - log.Error("graceful shutdown failed", "error", err) - os.Exit(1) - } - log.Info("{{.Name}} stopped cleanly") -} -` - -const tmplConfig = `// Package config loads {{.Name}} config from environment variables. -package config - -import ( - "fmt" - "os" - "strings" -) - -// Config holds runtime config. -type Config struct { - ListenAddr string - LogLevel string - DatabaseURL string -} - -// FromEnv reads config from env vars with sensible defaults. -func FromEnv() Config { - return Config{ - ListenAddr: envOr("{{.EnvPrefix}}_LISTEN_ADDR", ":{{.Port}}"), - LogLevel: envOr("LOG_LEVEL", "info"), - DatabaseURL: os.Getenv("DATABASE_URL"), - } -} - -// Validate returns an error if required fields are missing. -func (c Config) Validate() error { - if c.ListenAddr == "" { - return fmt.Errorf("{{.Name}}: listen address must not be empty") - } - return nil -} - -func envOr(key, def string) string { - if v := strings.TrimSpace(os.Getenv(key)); v != "" { - return v - } - return def -} -` - -const tmplServer = `// Package server wires the HTTP router for the {{.Name}} plugin. -package server - -import ( - "context" - "log/slog" - "net/http" - - "github.com/go-chi/chi/v5" - - "github.com/nself-org/{{.RepoBucket}}/{{.Name}}/internal/config" - - sdkmetrics "github.com/nself-org/plugin-sdk-go/metrics" - sdkserver "github.com/nself-org/plugin-sdk-go/server" -) - -// Deps wires runtime dependencies. -type Deps struct { - Config config.Config - Logger *slog.Logger - Version string -} - -type readyFn func(ctx context.Context) error - -func (f readyFn) Ready(ctx context.Context) error { return f(ctx) } - -// New returns a ready-to-serve http.Handler. -func New(d Deps) http.Handler { - return sdkserver.New(sdkserver.Options{ - Plugin: "{{.Name}}", - Version: d.Version, - Ready: readyFn(func(ctx context.Context) error { - return d.Config.Validate() - }), - Routes: func(r chi.Router, m *sdkmetrics.Registry) { - r.Route("/v1", func(r chi.Router) { - r.With(m.Middleware("/v1/hello")).Get("/hello", helloHandler(d.Logger)) - }) - }, - }) -} - -func helloHandler(log *slog.Logger) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if log != nil { - log.Info("hello called", "method", r.Method, "path", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(` + "`" + `{"plugin":"{{.Name}}","hello":"world"}` + "`" + `)) - } -} -` - -const tmplServerTest = `package server - -import ( - "net/http/httptest" - "testing" - - "github.com/nself-org/{{.RepoBucket}}/{{.Name}}/internal/config" -) - -func TestHelloEndpoint(t *testing.T) { - h := New(Deps{Config: config.Config{ListenAddr: ":{{.Port}}"}, Version: "test"}) - req := httptest.NewRequest("GET", "/v1/hello", nil) - rr := httptest.NewRecorder() - h.ServeHTTP(rr, req) - if rr.Code != 200 { - t.Fatalf("expected 200, got %d", rr.Code) - } -} - -func TestHealthz(t *testing.T) { - h := New(Deps{Config: config.Config{ListenAddr: ":{{.Port}}"}, Version: "test"}) - req := httptest.NewRequest("GET", "/healthz", nil) - rr := httptest.NewRecorder() - h.ServeHTTP(rr, req) - if rr.Code != 200 { - t.Fatalf("expected 200 from /healthz, got %d", rr.Code) - } -} -` diff --git a/internal/plugin/scaffold/scaffold_templates_code.go b/internal/plugin/scaffold/scaffold_templates_code.go new file mode 100644 index 00000000..7c1a03ce --- /dev/null +++ b/internal/plugin/scaffold/scaffold_templates_code.go @@ -0,0 +1,209 @@ +package scaffold + +// scaffold_templates_code.go — Go source code template strings. +// +// Purpose: Hold all Go const template strings that emit plugin source code: +// cmd/main.go, internal/config/config.go, internal/server/server.go, +// and internal/server/server_test.go. Separated from +// scaffold_templates_infra.go (infra/devops templates) and +// scaffold.go (logic) so each file has a single clear concern. +// Inputs: none (package-level consts, referenced by addGoFiles in scaffold.go). +// Outputs: Exported const strings consumed by render at run time. +// Constraints: All consts here are Go text/template strings that render valid +// Go source code. Use backtick literals only. No logic allowed. +// SPORT: cli/internal/plugin/scaffold — decomposed from scaffold.go (T-E2-06). + +const tmplMain = `// Package main is the entrypoint for the {{.Name}} plugin. +package main + +import ( + "context" + "errors" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/nself-org/{{.RepoBucket}}/{{.Name}}/internal/config" + "github.com/nself-org/{{.RepoBucket}}/{{.Name}}/internal/server" + + "github.com/nself-org/plugin-sdk-go/logger" +) + +// Version is stamped at build time via -ldflags. +var Version = "0.1.0" + +func main() { + cfg := config.FromEnv() + log := logger.New(logger.Options{ + Plugin: "{{.Name}}", + Version: Version, + Level: logger.ParseLevel(cfg.LogLevel), + }) + + srv := server.New(server.Deps{Config: cfg, Logger: log, Version: Version}) + + httpSrv := &http.Server{ + Addr: cfg.ListenAddr, + Handler: srv, + ReadHeaderTimeout: 10 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + errCh := make(chan error, 1) + go func() { + defer func() { + if r := recover(); r != nil { + errCh <- fmt.Errorf("server goroutine panic: %v", r) + } + }() + log.Info("{{.Name}} listening", "addr", cfg.ListenAddr) + errCh <- httpSrv.ListenAndServe() + }() + + select { + case <-ctx.Done(): + log.Info("shutdown signal received") + case err := <-errCh: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Error("server failed", "error", err) + } + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := httpSrv.Shutdown(shutdownCtx); err != nil { + log.Error("graceful shutdown failed", "error", err) + os.Exit(1) + } + log.Info("{{.Name}} stopped cleanly") +} +` + +const tmplConfig = `// Package config loads {{.Name}} config from environment variables. +package config + +import ( + "fmt" + "os" + "strings" +) + +// Config holds runtime config. +type Config struct { + ListenAddr string + LogLevel string + DatabaseURL string +} + +// FromEnv reads config from env vars with sensible defaults. +func FromEnv() Config { + return Config{ + ListenAddr: envOr("{{.EnvPrefix}}_LISTEN_ADDR", ":{{.Port}}"), + LogLevel: envOr("LOG_LEVEL", "info"), + DatabaseURL: os.Getenv("DATABASE_URL"), + } +} + +// Validate returns an error if required fields are missing. +func (c Config) Validate() error { + if c.ListenAddr == "" { + return fmt.Errorf("{{.Name}}: listen address must not be empty") + } + return nil +} + +func envOr(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} +` + +const tmplServer = `// Package server wires the HTTP router for the {{.Name}} plugin. +package server + +import ( + "context" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/nself-org/{{.RepoBucket}}/{{.Name}}/internal/config" + + sdkmetrics "github.com/nself-org/plugin-sdk-go/metrics" + sdkserver "github.com/nself-org/plugin-sdk-go/server" +) + +// Deps wires runtime dependencies. +type Deps struct { + Config config.Config + Logger *slog.Logger + Version string +} + +type readyFn func(ctx context.Context) error + +func (f readyFn) Ready(ctx context.Context) error { return f(ctx) } + +// New returns a ready-to-serve http.Handler. +func New(d Deps) http.Handler { + return sdkserver.New(sdkserver.Options{ + Plugin: "{{.Name}}", + Version: d.Version, + Ready: readyFn(func(ctx context.Context) error { + return d.Config.Validate() + }), + Routes: func(r chi.Router, m *sdkmetrics.Registry) { + r.Route("/v1", func(r chi.Router) { + r.With(m.Middleware("/v1/hello")).Get("/hello", helloHandler(d.Logger)) + }) + }, + }) +} + +func helloHandler(log *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if log != nil { + log.Info("hello called", "method", r.Method, "path", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(` + "`" + `{"plugin":"{{.Name}}","hello":"world"}` + "`" + `)) + } +} +` + +const tmplServerTest = `package server + +import ( + "net/http/httptest" + "testing" + + "github.com/nself-org/{{.RepoBucket}}/{{.Name}}/internal/config" +) + +func TestHelloEndpoint(t *testing.T) { + h := New(Deps{Config: config.Config{ListenAddr: ":{{.Port}}"}, Version: "test"}) + req := httptest.NewRequest("GET", "/v1/hello", nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != 200 { + t.Fatalf("expected 200, got %d", rr.Code) + } +} + +func TestHealthz(t *testing.T) { + h := New(Deps{Config: config.Config{ListenAddr: ":{{.Port}}"}, Version: "test"}) + req := httptest.NewRequest("GET", "/healthz", nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != 200 { + t.Fatalf("expected 200 from /healthz, got %d", rr.Code) + } +} +` diff --git a/internal/plugin/scaffold/scaffold_templates_infra.go b/internal/plugin/scaffold/scaffold_templates_infra.go new file mode 100644 index 00000000..5b33546f --- /dev/null +++ b/internal/plugin/scaffold/scaffold_templates_infra.go @@ -0,0 +1,272 @@ +package scaffold + +// scaffold_templates_infra.go — infrastructure and DevOps template strings. +// +// Purpose: Hold all Go const template strings for plugin.json, database +// migrations, Hasura metadata, Docker Compose, .dockerignore, +// .air.toml, README, and CI workflow. Separated from scaffold.go +// so that logic functions and template data do not co-reside in one +// file. +// Inputs: none (package-level consts, referenced by buildFiles and +// addTenancyFiles in scaffold.go). +// Outputs: Exported const strings consumed by render/renderAny at run time. +// Constraints: All consts here are Go text/template strings. Use backtick +// literals only. No logic allowed in this file. +// SPORT: cli/internal/plugin/scaffold — decomposed from scaffold.go (T-E2-06). + +// tmplPluginJSON is the plugin.json template. It requires a struct with all +// Params fields plus MultiAppSupported (bool) and IsolationColumn (string). +const tmplPluginJSON = `{ + "name": "{{.Name}}", + "version": "0.1.0", + "description": "{{.Description}}", + "author": "{{.Author}}", + "license": {{if eq .Tier "pro"}}"Source-Available"{{else}}"MIT"{{end}}, + "isCommercial": {{if eq .Tier "pro"}}true{{else}}false{{end}}, + {{- if eq .Tier "pro"}} + "licenseType": "pro", + "requiredEntitlements": ["pro"], + "requires_license": true, + {{- end}} + "homepage": "https://nself.org/plugins", + "minNselfVersion": "{{.MinCLI}}", + "minSdkVersion": "{{.MinSDK}}", + "category": "{{.Category}}", + {{- if .Bundle}} + "bundle": "{{.Bundle}}", + {{- end}} + "multiApp": { + "supported": {{.MultiAppSupported}}, + "isolationColumn": "{{.IsolationColumn}}" + }, + "tags": ["{{.Name}}"] +} +` + +// tmplMigrationNone is emitted when the plugin stores no per-user Postgres data. +const tmplMigrationNone = `-- {{.Name}} initial migration +-- No multi-tenancy columns required for this plugin. +-- Add your schema here. + +CREATE TABLE IF NOT EXISTS np_{{.Name}}_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +` + +// tmplMigrationAppIsolation is emitted for multi-app isolation within one nSelf deploy. +// Uses source_account_id per the Multi-Tenant Convention Wall. +const tmplMigrationAppIsolation = `-- {{.Name}} initial migration +-- Multi-app isolation: source_account_id separates independent consumer apps +-- within one nSelf deploy. See: docs/architecture/multi-tenant-conventions.md + +CREATE TABLE IF NOT EXISTS np_{{.Name}}_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_account_id TEXT NOT NULL DEFAULT 'primary', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Row-level security: each app sees only its own rows. +ALTER TABLE np_{{.Name}}_items ENABLE ROW LEVEL SECURITY; + +CREATE POLICY np_{{.Name}}_items_isolation ON np_{{.Name}}_items + USING (source_account_id = current_setting('app.source_account_id', true)); +` + +// tmplMigrationCloudTenant is emitted for Cloud SaaS tenancy. +// Uses tenant_id UUID per the Multi-Tenant Convention Wall. +const tmplMigrationCloudTenant = `-- {{.Name}} initial migration +-- Cloud multi-tenancy: tenant_id separates paying customers in nSelf Cloud SaaS. +-- See: docs/architecture/multi-tenant-conventions.md +-- NEVER use tenant_id for per-app isolation within one deploy — use the app-isolation column instead. + +CREATE TABLE IF NOT EXISTS np_{{.Name}}_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Row-level security: each tenant sees only its own rows. +ALTER TABLE np_{{.Name}}_items ENABLE ROW LEVEL SECURITY; + +CREATE POLICY np_{{.Name}}_items_tenant ON np_{{.Name}}_items + USING (tenant_id = current_setting('app.tenant_id', true)::UUID); +` + +// tmplMigrationBoth emits both columns for developers who are unsure which +// convention they need. Remove the unused column before going to production. +const tmplMigrationBoth = `-- {{.Name}} initial migration +-- Both multi-tenancy columns included. Remove the one you do not need before +-- going to production. See: docs/architecture/multi-tenant-conventions.md +-- +-- source_account_id: per-app isolation within one nSelf deploy +-- tenant_id: Cloud SaaS paying-customer isolation + +CREATE TABLE IF NOT EXISTS np_{{.Name}}_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_account_id TEXT NOT NULL DEFAULT 'primary', + tenant_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE np_{{.Name}}_items ENABLE ROW LEVEL SECURITY; + +-- Choose ONE of the two policies below and delete the other. +CREATE POLICY np_{{.Name}}_items_isolation ON np_{{.Name}}_items + USING (source_account_id = current_setting('app.source_account_id', true)); + +-- CREATE POLICY np_{{.Name}}_items_tenant ON np_{{.Name}}_items +-- USING (tenant_id = current_setting('app.tenant_id', true)::UUID); +` + +// tmplHasuraNoFilter is the Hasura metadata stub for plugins that use +// source_account_id (app isolation) or no tenancy at all. No tenant row filter +// is required because isolation is handled at the Postgres RLS layer. +const tmplHasuraNoFilter = `{ + "table": { + "schema": "public", + "name": "np_{{.Name}}_items" + }, + "select_permissions": [ + { + "role": "user", + "permission": { + "columns": "*", + "filter": {} + } + } + ] +} +` + +// tmplHasuraCloudFilter is the Hasura metadata stub for Cloud multi-tenant +// plugins. The row filter enforces that each tenant only sees its own rows via +// the X-Hasura-Tenant-Id session variable. +const tmplHasuraCloudFilter = `{ + "table": { + "schema": "public", + "name": "np_{{.Name}}_items" + }, + "select_permissions": [ + { + "role": "user", + "permission": { + "columns": "*", + "filter": { + "tenant_id": { + "_eq": "X-Hasura-Tenant-Id" + } + } + } + } + ] +} +` + +const tmplCompose = `# docker-compose.plugin.yml for {{.Name}} +# Merged into the generated stack by ` + "`nself build`" + `. Do not hand-edit. +services: + {{.Name}}: + image: nself/{{.Name}}:${{"{"}}{{.EnvPrefix}}_VERSION:-latest} + container_name: ${PROJECT_NAME:-nself}_{{.Name}} + restart: unless-stopped + environment: + LOG_LEVEL: ${LOG_LEVEL:-info} + DATABASE_URL: ${DATABASE_URL} + {{.EnvPrefix}}_LISTEN_ADDR: ":{{.Port}}" + ports: + - "127.0.0.1:{{.Port}}:{{.Port}}" + networks: + - nself_net +networks: + nself_net: + external: true +` + +const tmplDockerignore = `.git +.gitignore +README.md +Dockerfile +docker-compose*.yml +.air.toml +tmp/ +*.test +coverage.out +` + +const tmplAirToml = `# air.toml — hot-reload for {{.Name}} dev (pair with nself plugin dev) +root = "." +tmp_dir = "tmp" + +[build] + cmd = "go build -o ./tmp/{{.Name}} ./cmd" + bin = "tmp/{{.Name}}" + delay = 500 + include_ext = ["go", "yaml", "yml"] + exclude_dir = ["tmp", "vendor", ".git"] + +[log] + time = true + +[color] + app = "magenta" +` + +const tmplReadme = `# {{.PascalName}} Plugin + +{{.Description}} + +Tier: ` + "`{{.Tier}}`" + `{{if .Bundle}} · Bundle: ` + "`{{.Bundle}}`" + `{{end}} · Category: ` + "`{{.Category}}`" + ` + +## Local development + +` + "```bash" + ` +go mod tidy +go test ./... +go run ./cmd # runs on :{{.Port}} +` + "```" + ` + +With hot-reload (install [air](https://github.com/air-verse/air)): + +` + "```bash" + ` +nself plugin dev {{.Name}} +` + "```" + ` + +## Endpoints + +- ` + "`GET /healthz`" + ` — liveness +- ` + "`GET /readyz`" + ` — readiness +- ` + "`GET /metrics`" + ` — Prometheus metrics +- ` + "`GET /version`" + ` — plugin version +- ` + "`GET /v1/hello`" + ` — starter handler + +## License + +{{if eq .Tier "pro"}}Source-Available (pro tier). Requires an active nSelf license key.{{else}}MIT.{{end}} +` + +const tmplCI = `name: CI +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.23" + - name: Test + run: go test ./... + - name: Build Docker image + run: docker build -t {{.Name}}:test . + - name: Health check + run: | + docker run -d -p {{.Port}}:{{.Port}} --name test {{.Name}}:test + sleep 2 + curl -f http://localhost:{{.Port}}/healthz + docker stop test +`