back interdiff of round #6 and #5

nix: knot: pass config to knot guard #700

closed
opened by boltless.me targeting master from sandboxed-atmosphere

this is a temporary fix. ideal solution would be introducing json file configuration or serving ssh server by our own instead of relying on sshd

Signed-off-by: Seongmin Lee git@boltless.me

files
guard
knotserver
nix
modules
REVERTED
nix/modules/knot.nix
··· 157 157 ''; 158 158 }; 159 159 160 - # TODO: abstract this to share same env table with systemd.services.knot 161 - environment.variables = { 162 - "KNOT_REPO_SCAN_PATH" = cfg.repo.scanPath; 163 - "KNOT_REPO_MAIN_BRANCH" = cfg.repo.mainBranch; 164 - "APPVIEW_ENDPOINT" = cfg.appviewEndpoint; 165 - "KNOT_SERVER_INTERNAL_LISTEN_ADDR" = cfg.server.internalListenAddr; 166 - "KNOT_SERVER_LISTEN_ADDR" = cfg.server.listenAddr; 167 - "KNOT_SERVER_DB_PATH" = cfg.server.dbPath; 168 - "KNOT_SERVER_HOSTNAME" = cfg.server.hostname; 169 - "KNOT_SERVER_PLC_URL" = cfg.server.plcUrl; 170 - "KNOT_SERVER_JETSTREAM_ENDPOINT" = cfg.server.jetstreamEndpoint; 171 - "KNOT_SERVER_OWNER" = cfg.server.owner; 172 - }; 173 160 environment.etc."ssh/keyfetch_wrapper" = { 174 161 mode = "0555"; 175 162 text = ''
NEW
guard/guard.go
··· 12 12 "os/exec" 13 13 "strings" 14 14 15 - "github.com/bluesky-social/indigo/atproto/identity" 16 15 securejoin "github.com/cyphar/filepath-securejoin" 17 16 "github.com/urfave/cli/v3" 18 - "tangled.org/core/idresolver" 19 - "tangled.org/core/knotserver/config" 20 17 "tangled.org/core/log" 21 18 ) 22 19 ··· 58 55 func Run(ctx context.Context, cmd *cli.Command) error { 59 56 l := log.FromContext(ctx) 60 57 61 - c, err := config.Load(ctx) 62 - if err != nil { 63 - return fmt.Errorf("failed to load config: %w", err) 64 - } 65 - 66 58 incomingUser := cmd.String("user") 67 59 gitDir := cmd.String("git-dir") 68 60 logPath := cmd.String("log-path") ··· 99 91 "command", sshCommand, 100 92 "client", clientIP) 101 93 94 + // TODO: greet user with their resolved handle instead of did 102 95 if sshCommand == "" { 103 96 l.Info("access denied: no interactive shells", "user", incomingUser) 104 97 fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser) ··· 113 106 } 114 107 115 108 gitCommand := cmdParts[0] 116 - 117 - // did:foo/repo-name or 118 - // handle/repo-name or 119 - // any of the above with a leading slash (/) 120 - 121 - components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/") 122 - l.Info("command components", "components", components) 123 - 124 - if len(components) != 2 { 125 - l.Error("invalid repo format", "components", components) 126 - fmt.Fprintln(os.Stderr, "invalid repo format, needs <user>/<repo> or /<user>/<repo>") 127 - os.Exit(-1) 128 - } 129 - 130 - didOrHandle := components[0] 131 - identity := resolveIdentity(ctx, c, l, didOrHandle) 132 - did := identity.DID.String() 133 - repoName := components[1] 134 - qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName) 109 + repoPath := cmdParts[1] 135 110 136 111 validCommands := map[string]bool{ 137 112 "git-receive-pack": true, ··· 143 118 fmt.Fprintln(os.Stderr, "access denied: invalid git command") 144 119 return fmt.Errorf("access denied: invalid git command") 145 120 } 146 - 147 - if gitCommand != "git-upload-pack" { 148 - if !isPushPermitted(l, incomingUser, qualifiedRepoName, endpoint) { 149 - l.Error("access denied: user not allowed", 150 - "did", incomingUser, 151 - "reponame", qualifiedRepoName) 152 - fmt.Fprintln(os.Stderr, "access denied: user not allowed") 153 - os.Exit(-1) 154 - } 121 + 122 + // qualify repo path from internal server which holds the knot config 123 + qualifiedRepoPath, err := guardAndQualifyRepo(l, endpoint, incomingUser, repoPath, gitCommand) 124 + if err != nil { 125 + l.Error("failed to run guard", "err", err) 126 + fmt.Fprintln(os.Stderr, err) 127 + os.Exit(1) 155 128 } 156 129 157 - fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName) 130 + fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath) 158 131 159 132 l.Info("processing command", 160 133 "user", incomingUser, 161 134 "command", gitCommand, 162 - "repo", repoName, 135 + "repo", repoPath, 163 136 "fullPath", fullPath, 164 137 "client", clientIP) 165 138 ··· 183 156 gitCmd.Stdin = os.Stdin 184 157 gitCmd.Env = append(os.Environ(), 185 158 fmt.Sprintf("GIT_USER_DID=%s", incomingUser), 186 - fmt.Sprintf("GIT_USER_PDS_ENDPOINT=%s", identity.PDSEndpoint()), 187 159 ) 188 160 189 161 if err := gitCmd.Run(); err != nil { ··· 195 167 l.Info("command completed", 196 168 "user", incomingUser, 197 169 "command", gitCommand, 198 - "repo", repoName, 170 + "repo", repoPath, 199 171 "success", true) 200 172 201 173 return nil 202 174 } 203 175 204 - func resolveIdentity(ctx context.Context, c *config.Config, l *slog.Logger, didOrHandle string) *identity.Identity { 205 - resolver := idresolver.DefaultResolver(c.Server.PlcUrl) 206 - ident, err := resolver.ResolveIdent(ctx, didOrHandle) 207 - if err != nil { 208 - l.Error("Error resolving handle", "error", err, "handle", didOrHandle) 209 - fmt.Fprintf(os.Stderr, "error resolving handle: %v\n", err) 210 - os.Exit(1) 211 - } 212 - if ident.Handle.IsInvalidHandle() { 213 - l.Error("Error resolving handle", "invalid handle", didOrHandle) 214 - fmt.Fprintf(os.Stderr, "error resolving handle: invalid handle\n") 215 - os.Exit(1) 216 - } 217 - return ident 218 - } 219 - 220 - func isPushPermitted(l *slog.Logger, user, qualifiedRepoName, endpoint string) bool { 221 - u, _ := url.Parse(endpoint + "/push-allowed") 176 + // runs guardAndQualifyRepo logic 177 + func guardAndQualifyRepo(l *slog.Logger, endpoint, incomingUser, repo, gitCommand string) (string, error) { 178 + u, _ := url.Parse(endpoint + "/guard") 222 179 q := u.Query() 223 - q.Add("user", user) 224 - q.Add("repo", qualifiedRepoName) 180 + q.Add("user", incomingUser) 181 + q.Add("repo", repo) 182 + q.Add("gitCmd", gitCommand) 225 183 u.RawQuery = q.Encode() 226 184 227 - req, err := http.Get(u.String()) 185 + resp, err := http.Get(u.String()) 228 186 if err != nil { 229 - l.Error("Error verifying permissions", "error", err) 230 - fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err) 231 - os.Exit(1) 187 + return "", err 232 188 } 189 + defer resp.Body.Close() 190 + 191 + l.Info("Running guard", "url", u.String(), "status", resp.Status) 233 192 234 - l.Info("Checking push permission", 235 - "url", u.String(), 236 - "status", req.Status) 193 + body, err := io.ReadAll(resp.Body) 194 + if err != nil { 195 + return "", err 196 + } 197 + text := string(body) 237 198 238 - return req.StatusCode == http.StatusNoContent 199 + switch resp.StatusCode { 200 + case http.StatusOK: 201 + return text, nil 202 + case http.StatusForbidden: 203 + l.Error("access denied: user not allowed", "did", incomingUser, "reponame", text) 204 + return text, errors.New("access denied: user not allowed") 205 + default: 206 + return "", errors.New(text) 207 + } 239 208 }
NEW
knotserver/internal.go
··· 67 67 writeJSON(w, data) 68 68 } 69 69 70 + // response in text/plain format 71 + // the body will be qualified repository path on success/push-denied 72 + // or an error message when process failed 73 + func (h *InternalHandle) Guard(w http.ResponseWriter, r *http.Request) { 74 + l := h.l.With("handler", "PostReceiveHook") 75 + 76 + var ( 77 + incomingUser = r.URL.Query().Get("user") 78 + repo = r.URL.Query().Get("repo") 79 + gitCommand = r.URL.Query().Get("gitCmd") 80 + ) 81 + 82 + if incomingUser == "" || repo == "" || gitCommand == "" { 83 + w.WriteHeader(http.StatusBadRequest) 84 + l.Error("invalid request", "incomingUser", incomingUser, "repo", repo, "gitCommand", gitCommand) 85 + fmt.Fprintln(w, "invalid internal request") 86 + return 87 + } 88 + 89 + // did:foo/repo-name or 90 + // handle/repo-name or 91 + // any of the above with a leading slash (/) 92 + components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/") 93 + l.Info("command components", "components", components) 94 + 95 + if len(components) != 2 { 96 + w.WriteHeader(http.StatusBadRequest) 97 + l.Error("invalid repo format", "components", components) 98 + fmt.Fprintln(w, "invalid repo format, needs <user>/<repo> or /<user>/<repo>") 99 + return 100 + } 101 + repoOwner := components[0] 102 + repoName := components[1] 103 + 104 + resolver := idresolver.DefaultResolver(h.c.Server.PlcUrl) 105 + 106 + repoOwnerIdent, err := resolver.ResolveIdent(r.Context(), repoOwner) 107 + if err != nil || repoOwnerIdent.Handle.IsInvalidHandle() { 108 + l.Error("Error resolving handle", "handle", repoOwner, "err", err) 109 + w.WriteHeader(http.StatusInternalServerError) 110 + fmt.Fprintf(w, "error resolving handle: invalid handle\n") 111 + return 112 + } 113 + repoOwnerDid := repoOwnerIdent.DID.String() 114 + 115 + qualifiedRepo, _ := securejoin.SecureJoin(repoOwnerDid, repoName) 116 + 117 + if gitCommand == "git-receive-pack" { 118 + ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, qualifiedRepo) 119 + if err != nil || !ok { 120 + w.WriteHeader(http.StatusForbidden) 121 + fmt.Fprint(w, repo) 122 + return 123 + } 124 + } 125 + 126 + w.WriteHeader(http.StatusOK) 127 + fmt.Fprint(w, qualifiedRepo) 128 + } 129 + 70 130 type PushOptions struct { 71 131 skipCi bool 72 132 verboseCi bool ··· 330 390 331 391 r.Get("/push-allowed", h.PushAllowed) 332 392 r.Get("/keys", h.InternalKeys) 393 + r.Get("/guard", h.Guard) 333 394 r.Post("/hooks/post-receive", h.PostReceiveHook) 334 395 r.Mount("/debug", middleware.Profiler()) 335 396