diff --git a/cmd/knotserver/main.go b/cmd/knotserver/main.go index 378f14d7..362f8ab9 100644 --- a/cmd/knotserver/main.go +++ b/cmd/knotserver/main.go @@ -46,10 +46,16 @@ func main() { l.Error("failed to setup server", "error", err) return } - addr := fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port) + imux := knotserver.Internal(ctx, db, e) + iaddr := fmt.Sprintf("%s:%d", c.Server.Host, c.Server.InternalPort) + + l.Info("starting internal server", "address", iaddr) + go http.ListenAndServe(iaddr, imux) + l.Info("starting main server", "address", addr) l.Error("server error", "error", http.ListenAndServe(addr, mux)) + return } diff --git a/cmd/repoguard/main.go b/cmd/repoguard/main.go index d5027a8d..b490fb2c 100644 --- a/cmd/repoguard/main.go +++ b/cmd/repoguard/main.go @@ -5,6 +5,8 @@ import ( "flag" "fmt" "log" + "net/http" + "net/url" "os" "os/exec" "path" @@ -21,9 +23,10 @@ var ( clientIP string // Command line flags - allowedUser = flag.String("user", "", "Allowed git user") - baseDirFlag = flag.String("base-dir", "/home/git", "Base directory for git repositories") - logPathFlag = flag.String("log-path", "/var/log/git-wrapper.log", "Path to log file") + incomingUser = flag.String("user", "", "Allowed git user") + baseDirFlag = flag.String("base-dir", "/home/git", "Base directory for git repositories") + logPathFlag = flag.String("log-path", "/var/log/git-wrapper.log", "Path to log file") + endpoint = flag.String("internal-api", "http://localhost:5555", "Internal API endpoint") ) func main() { @@ -40,14 +43,14 @@ func main() { } } - if *allowedUser == "" { + if *incomingUser == "" { exitWithLog("access denied: no user specified") } sshCommand := os.Getenv("SSH_ORIGINAL_COMMAND") logEvent("Connection attempt", map[string]interface{}{ - "user": *allowedUser, + "user": *incomingUser, "command": sshCommand, "client": clientIP, }) @@ -63,9 +66,17 @@ func main() { gitCommand := cmdParts[0] - // example.com/repo - handlePath := strings.Trim(cmdParts[1], "'") - repoName := handleToDid(handlePath) + // did:foo/repo-name or + // handle/repo-name + components := filepath.SplitList(cmdParts[2]) + if len(components) != 2 { + exitWithLog("invalid repo format, needs /") + } + + didOrHandle := components[0] + did := resolveToDid(didOrHandle) + repoName := components[1] + qualifiedRepoName := filepath.Join(did, repoName) validCommands := map[string]bool{ "git-receive-pack": true, @@ -76,18 +87,17 @@ func main() { exitWithLog("access denied: invalid git command") } - did := path.Dir(repoName) if gitCommand != "git-upload-pack" { - if !isAllowedUser(*allowedUser, did) { + if !isPushPermitted(*incomingUser, qualifiedRepoName) { exitWithLog("access denied: user not allowed") } } - fullPath := filepath.Join(*baseDirFlag, repoName) + fullPath := filepath.Join(*baseDirFlag, qualifiedRepoName) fullPath = filepath.Clean(fullPath) logEvent("Processing command", map[string]interface{}{ - "user": *allowedUser, + "user": *incomingUser, "command": gitCommand, "repo": repoName, "fullPath": fullPath, @@ -104,13 +114,23 @@ func main() { } logEvent("Command completed", map[string]interface{}{ - "user": *allowedUser, + "user": *incomingUser, "command": gitCommand, "repo": repoName, "success": true, }) } +func resolveToDid(didOrHandle string) string { + ident, err := auth.ResolveIdent(context.Background(), didOrHandle) + if err != nil { + exitWithLog(fmt.Sprintf("error resolving handle: %v", err)) + } + + // did:plc:foobarbaz/repo + return ident.DID.String() +} + func handleToDid(handlePath string) string { handle := path.Dir(handlePath) @@ -166,6 +186,15 @@ func cleanup() { } } -func isAllowedUser(user, did string) bool { - return user == did +func isPushPermitted(user, qualifiedRepoName string) bool { + url, _ := url.Parse(*endpoint + "/push-allowed/") + url.Query().Add(user, user) + url.Query().Add(user, qualifiedRepoName) + + req, err := http.Get(url.String()) + if err != nil { + exitWithLog(fmt.Sprintf("error verifying permissions: %v", err)) + } + + return req.StatusCode == http.StatusNoContent } diff --git a/knotserver/config/config.go b/knotserver/config/config.go index bb218173..ca3ecc02 100644 --- a/knotserver/config/config.go +++ b/knotserver/config/config.go @@ -13,10 +13,11 @@ type Repo struct { } type Server struct { - Host string `env:"HOST, default=0.0.0.0"` - Port int `env:"PORT, default=5555"` - Secret string `env:"SECRET, required"` - DBPath string `env:"DB_PATH, default=knotserver.db"` + Host string `env:"HOST, default=0.0.0.0"` + Port int `env:"PORT, default=5555"` + InternalPort int `env:"PORT, default=5444"` + Secret string `env:"SECRET, required"` + DBPath string `env:"DB_PATH, default=knotserver.db"` // This disables signature verification so use with caution. Dev bool `env:"DEV, default=false"` } diff --git a/knotserver/internal.go b/knotserver/internal.go new file mode 100644 index 00000000..397fbabe --- /dev/null +++ b/knotserver/internal.go @@ -0,0 +1,47 @@ +package knotserver + +import ( + "context" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/sotangled/tangled/knotserver/db" + "github.com/sotangled/tangled/rbac" +) + +type InternalHandle struct { + db *db.DB + e *rbac.Enforcer +} + +func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) { + user := r.URL.Query().Get("user") + repo := r.URL.Query().Get("repo") + + if user == "" || repo == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + + ok, err := h.e.IsPushAllowed(user, ThisServer, repo) + if err != nil || !ok { + w.WriteHeader(http.StatusForbidden) + return + } + + w.WriteHeader(http.StatusNoContent) + return +} + +func Internal(ctx context.Context, db *db.DB, e *rbac.Enforcer) http.Handler { + r := chi.NewRouter() + + h := InternalHandle{ + db, + e, + } + + r.Get("/push-allowed", h.PushAllowed) + + return r +} diff --git a/rbac/rbac.go b/rbac/rbac.go index 6bf781e7..bc8e854e 100644 --- a/rbac/rbac.go +++ b/rbac/rbac.go @@ -131,6 +131,10 @@ func (e *Enforcer) IsServerMember(user, domain string) (bool, error) { return e.isRole(user, "server:member", domain) } +func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) { + return e.E.Enforce(user, domain, repo, "repo:push") +} + // keyMatch2Func is a wrapper for keyMatch2 to make it compatible with Casbin func keyMatch2Func(args ...interface{}) (interface{}, error) { name1 := args[0].(string)