From 2b410653389bbd201f69ebb521134d9b0982c09f Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Wed, 29 Apr 2026 10:25:06 +0300 Subject: [PATCH] knotserver: add migrate-isolation command --- cmd/knot/main.go | 3 + knotserver/migrate_isolation.go | 159 ++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 knotserver/migrate_isolation.go diff --git a/cmd/knot/main.go b/cmd/knot/main.go index 89c6c7ef..64357b54 100644 --- a/cmd/knot/main.go +++ b/cmd/knot/main.go @@ -10,6 +10,7 @@ import ( "tangled.org/core/hook" "tangled.org/core/keyfetch" "tangled.org/core/knotserver" + "tangled.org/core/knotserver/sandbox/sandboxexec" tlog "tangled.org/core/log" ) @@ -22,6 +23,8 @@ func main() { knotserver.Command(), keyfetch.Command(), hook.Command(), + knotserver.MigrateIsolationCommand(), + sandboxexec.Command(), }, } diff --git a/knotserver/migrate_isolation.go b/knotserver/migrate_isolation.go new file mode 100644 index 00000000..52cdb445 --- /dev/null +++ b/knotserver/migrate_isolation.go @@ -0,0 +1,159 @@ +package knotserver + +import ( + "context" + "fmt" + "os" + "syscall" + + "github.com/urfave/cli/v3" + "tangled.org/core/hook" + "tangled.org/core/knotserver/db" + "tangled.org/core/knotserver/sandbox" + "tangled.org/core/log" +) + +// MigrateIsolationCommand returns the CLI command for migrate-isolation. +func MigrateIsolationCommand() *cli.Command { + return &cli.Command{ + Name: "migrate-isolation", + Usage: "chown existing repos to their owner virtual UIDs and refresh hooks (run once before enabling --secure-mode)", + Action: RunMigrateIsolation, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "git-dir", + Usage: "base directory for git repos", + Value: "/home/git", + }, + &cli.StringFlag{ + Name: "db", + Usage: "path to knotserver SQLite database", + Value: "knotserver.db", + }, + &cli.StringFlag{ + Name: "internal-api", + Usage: "internal API address for hook configuration", + Value: "127.0.0.1:5444", + }, + &cli.BoolFlag{ + Name: "force", + Usage: "re-run chown/chmod even on already-migrated repos", + }, + }, + } +} + +// RunMigrateIsolation iterates over all repos in the DB, assigns virtual UIDs +// to their owners, chowns the repo trees recursively, and records isolated_at. +func RunMigrateIsolation(ctx context.Context, cmd *cli.Command) error { + logger := log.FromContext(ctx) + logger = log.SubLogger(logger, "migrate-isolation") + + gitDir := cmd.String("git-dir") + dbPath := cmd.String("db") + internalApi := cmd.String("internal-api") + + serviceGid, err := sandbox.ServiceGid(gitDir) + if err != nil { + return fmt.Errorf("resolve service gid: %w", err) + } + + hookCfg := hook.Config( + hook.WithScanPath(gitDir), + hook.WithInternalApi(internalApi), + ) + + d, err := db.Setup(ctx, dbPath) + if err != nil { + return fmt.Errorf("failed to open db: %w", err) + } + + repos, err := d.AllReposForMigration(cmd.Bool("force")) + if err != nil { + return fmt.Errorf("failed to list repos: %w", err) + } + + if len(repos) == 0 { + logger.Info("no repos need migration") + return nil + } + + logger.Info("starting isolation migration", "total", len(repos)) + + var migrated, skipped, failed int + + for _, repo := range repos { + repoPath, _, _, err := d.ResolveRepoDIDOnDisk(gitDir, repo.RepoDID) + if err != nil { + logger.Error("repo not on disk, skipping", + "repo_did", repo.RepoDID, "error", err) + skipped++ + continue + } + + ownerUID, err := d.GetOrAssignOwnerUID(repo.OwnerDID) + if err != nil { + logger.Error("failed to get/assign UID", + "repo_did", repo.RepoDID, "owner_did", repo.OwnerDID, "error", err) + failed++ + continue + } + + // regenerate hooks first so the chown walk preserves their 0755 mode + // via the executable-bit check in ChownRepoTree. + if err := hook.SetupRepo(hookCfg, repoPath); err != nil { + logger.Error("failed to set up hooks", + "repo_did", repo.RepoDID, "path", repoPath, "error", err) + failed++ + continue + } + + if err := sandbox.ChmodRepoTree(repoPath); err != nil { + logger.Error("chmod failed", + "repo_did", repo.RepoDID, "path", repoPath, "error", err) + failed++ + continue + } + + if err := sandbox.ChownRepoTree(repoPath, int(ownerUID), int(serviceGid)); err != nil { + // EPERM: process lacks cap_chown; log and continue rather than + // aborting so all failures are reported. + if isEPERM(err) { + logger.Error("chown failed: process lacks cap_chown "+ + "(run as root or grant cap_chown to the binary)", + "repo_did", repo.RepoDID, "path", repoPath, "uid", ownerUID) + } else { + logger.Error("chown failed", + "repo_did", repo.RepoDID, "path", repoPath, "uid", ownerUID, "error", err) + } + failed++ + continue + } + + if err := d.MarkRepoIsolated(repo.RepoDID); err != nil { + logger.Error("failed to record isolated_at", + "repo_did", repo.RepoDID, "error", err) + failed++ + continue + } + + logger.Info("migrated repo", + "repo_did", repo.RepoDID, "owner_did", repo.OwnerDID, "uid", ownerUID, "path", repoPath) + migrated++ + } + + logger.Info("migration complete", + "migrated", migrated, "skipped", skipped, "failed", failed, "total", len(repos)) + + if failed > 0 { + return fmt.Errorf("%d repo(s) failed to migrate", failed) + } + return nil +} + +func isEPERM(err error) bool { + if pathErr, ok := err.(*os.PathError); ok { + return pathErr.Err == syscall.EPERM + } + return false +} -- 2.51.2