clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/ui-updates
tinyrelay / tinygit / repair.go
repair.go 10.8K
1package tinygit23import (4 "context"5 "errors"6 "fmt"7 "net"8 "net/url"9 "os"10 "os/exec"11 "path/filepath"12 "strconv"13 "strings"14)1516// AdmitSource enforces the source restrictions from GRASP's Git sync model.17// Only HTTPS sources are accepted; localhost, private IPs, credentials,18// query/fragment data, and this relay itself are rejected.19func (g *GitRelay) AdmitSource(raw string) (string, error) {20 return g.admitSource(context.Background(), raw, false)21}2223func (g *GitRelay) admitSource(ctx context.Context, raw string, resolve bool) (string, error) {24 u, err := url.Parse(raw)25 privatePeer := g.privatePeer(raw)26 if err != nil || (u.Scheme != "https" && !(privatePeer && u.Scheme == "http")) || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {27 return "", errors.New("invalid: Git source must be a credential-free HTTPS URL")28 }29 host := strings.ToLower(u.Hostname())30 if host == "localhost" || strings.HasSuffix(host, ".localhost") || host == "metadata.google.internal" {31 return "", errors.New("blocked: Git source host is local or address-like")32 }33 if ip := net.ParseIP(host); ip != nil && !g.allowPrivate {34 return "", errors.New("blocked: Git source host is an IP literal")35 }36 if strings.HasSuffix(host, ".internal") || strings.HasSuffix(host, ".local") {37 return "", errors.New("blocked: Git source host is private")38 }39 if resolve {40 ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)41 if err != nil {42 return "", fmt.Errorf("Git source DNS lookup: %w", err)43 }44 for _, ip := range ips {45 if privateIP(ip) && !g.allowPrivate {46 return "", errors.New("blocked: Git source resolves to a private address")47 }48 }49 }50 if g.serviceURL != "" && strings.TrimRight(raw, "/") == g.serviceURL {51 return "", errors.New("blocked: Git source points to this relay")52 }53 return strings.TrimRight(raw, "/"), nil54}5556func (g *GitRelay) resolvedSource(ctx context.Context, raw string) (string, string, error) {57 source, err := g.admitSource(ctx, raw, false)58 if err != nil {59 return "", "", err60 }61 u, err := url.Parse(source)62 if err != nil {63 return "", "", err64 }65 ips, err := net.DefaultResolver.LookupIP(ctx, "ip", u.Hostname())66 if err != nil {67 return "", "", fmt.Errorf("Git source DNS lookup: %w", err)68 }69 for _, ip := range ips {70 if !privateIP(ip) || g.allowPrivate {71 return source, ip.String(), nil72 }73 }74 return "", "", errors.New("blocked: Git source resolves to a private address")75}7677func privateIP(ip net.IP) bool {78 if ip == nil {79 return true80 }81 if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {82 return true83 }84 return ip.IsUnspecified()85}8687// FetchMissing downloads only the expected refs from an admitted source. Git88// verifies pack checksums and object connectivity; refs are installed only89// after every expected object exists locally.90func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []string, expected map[string]string) error {91 return g.fetchMissing(ctx, r, sources, expected, 0, 0)92}9394// fetchMissing is shared by ordinary branch repair and the more restrictive95// PR repair path. A non-zero depth keeps an untrusted PR clone from making the96// hosted repository walk arbitrary history. maxBytes bounds transfer traffic97// across all sources and the isolated destination after each fetch. These98// limits apply only to isolated repositories, never the hosted branch repo.99func (g *GitRelay) fetchMissing(ctx context.Context, r Repository, sources []string, expected map[string]string, depth int, maxBytes int64) error {100 if err := g.ensureRepo(r); err != nil {101 return err102 }103 local := r104 local.Refs = expected105 if g.stateObjectsPresent(ctx, local) {106 return g.installFetchedRefs(r, expected)107 }108 missing := make(map[string]struct{}, len(expected))109 var budget *byteBudget110 if maxBytes > 0 {111 budget = &byteBudget{}112 }113 var lastErr error114 for ref, oid := range expected {115 if oid != "" {116 missing[ref] = struct{}{}117 }118 }119 for _, raw := range sources {120 if err := ctx.Err(); err != nil {121 return err122 }123 if budget != nil && len(missing) == 0 {124 break125 }126 if budget != nil && budget.used.Load() >= maxBytes {127 return errRepairByteLimit128 }129 // Private object IDs must never be requested from a public source.130 private := r.Private || g.policyValue().Reads == "members"131 if private && !g.privatePeer(raw) {132 lastErr = errors.New("blocked: private Git source is not a configured peer")133 continue134 }135 source, ip, err := g.resolvedSource(ctx, raw)136 if err != nil {137 lastErr = err138 continue139 }140 var proxy *privateProxy141 if peerBase := g.privatePeerBase(source); peerBase != "" {142 if err := probePrivatePeer(ctx, peerBase, g.allowPrivate); err != nil {143 continue144 }145 g.mu.RLock()146 signer := g.httpAuth147 g.mu.RUnlock()148 proxy, err = newPrivateProxy(ctx, source, signer, g.allowPrivate)149 if err != nil {150 continue151 }152 defer proxy.Close(context.Background())153 }154 fetchSource := source155 if proxy != nil {156 fetchSource = proxy.URL()157 }158 var budgetProxy *repairProxy159 if budget != nil {160 targetIP := ip161 if proxy != nil {162 if parsed, parseErr := url.Parse(proxy.URL()); parseErr == nil {163 targetIP = parsed.Hostname()164 }165 }166 parsedSource, parseErr := url.Parse(fetchSource)167 if parseErr != nil {168 lastErr = parseErr169 continue170 }171 targetHost := parsedSource.Hostname()172 targetPort := parsedSource.Port()173 if targetPort == "" {174 targetPort = "443"175 if parsedSource.Scheme == "http" {176 targetPort = "80"177 }178 }179 budgetProxy, err = newRepairProxy(ctx, targetIP, targetHost, targetPort, budget, maxBytes)180 if err != nil {181 lastErr = err182 continue183 }184 defer budgetProxy.Close()185 }186 for ref, oid := range expected {187 if err := ctx.Err(); err != nil {188 return err189 }190 if budget != nil && budget.used.Load() >= maxBytes {191 return errRepairByteLimit192 }193 if _, needed := missing[ref]; budget != nil && !needed {194 continue195 }196 if oid == "" {197 delete(missing, ref)198 continue199 }200 port := uPort(source)201 resolve := []string{"-c", "http.curloptResolve=" + urlHost(source) + ":" + port + ":" + ip}202 if proxy != nil {203 fetchSource = proxy.URL()204 resolve = nil205 }206 if budgetProxy != nil {207 resolve = nil208 }209 fetchRef := ref210 if strings.HasPrefix(ref, "refs/nostr/") {211 // NIP-34 clone URLs may point to ordinary Git hosts. The212 // signed commit is the source; refs/nostr is our local name.213 if !isObjectID(oid) {214 return errors.New("invalid: PR commit ID")215 }216 fetchRef = oid217 }218 proxySetting := ""219 if budgetProxy != nil {220 proxySetting = budgetProxy.URL()221 }222 args := []string{"-c", "http.followRedirects=false", "-c", "credential.helper=", "-c", "http.proxy=" + proxySetting, "--git-dir", g.repoPath(r), "fetch", "--no-tags"}223 if depth > 0 {224 args = append(args, "--depth", strconv.Itoa(depth))225 }226 args = append(args, fetchSource, "+"+fetchRef+":"+"refs/tinyrelay/source/"+safeRef(ref))227 // Keep the resolve option before the subcommand; Git accepts config228 // options only before the command name.229 if len(resolve) > 0 {230 args = append([]string{"-c", "http.curloptResolve=" + urlHost(source) + ":" + port + ":" + ip}, args...)231 }232 cmd := exec.CommandContext(ctx, "git", args...)233 if depth > 0 && maxBytes > 0 {234 // Git receives pack data in a child process and does not expose a235 // portable receive-byte limit. Apply the shell's file-size limit236 // to the isolated fetch process so an oversized pack is stopped237 // while it is being written. The unit is 512 bytes in dash and238 // 1024 in bash, so this is a coarse guard; the proxy budget and239 // the cumulative size check below enforce the exact bound.240 limitBlocks := (maxBytes + 511) / 512241 limited := []string{"ulimit -f \"$1\"; shift; exec \"$@\"", "gitfetch", strconv.FormatInt(limitBlocks, 10), "git"}242 limited = append(limited, args...)243 cmd = exec.CommandContext(ctx, "sh", append([]string{"-c"}, limited...)...)244 }245 cmd.Env = append(os.Environ(), "HTTP_PROXY=", "HTTPS_PROXY=", "ALL_PROXY=", "http_proxy=", "https_proxy=", "all_proxy=", "NO_PROXY=", "no_proxy=", "GIT_CONFIG_NOSYSTEM=1")246 if budget != nil {247 // A URL-specific proxy or rewrite in the user's Git config must248 // not bypass the bounded transport selected for this repair.249 cmd.Env = append(cmd.Env, "GIT_CONFIG_GLOBAL="+os.DevNull)250 }251 if out, err := cmd.CombinedOutput(); err != nil {252 lastErr = fmt.Errorf("fetch %s: %w: %s", fetchSource, err, strings.TrimSpace(string(out)))253 continue254 }255 if maxBytes > 0 {256 size, err := gitDirSize(g.repoPath(r))257 if err != nil {258 lastErr = fmt.Errorf("inspect isolated Git repository: %w", err)259 continue260 }261 if size > maxBytes {262 return fmt.Errorf("Git source exceeded isolated repair budget: %d bytes", size)263 }264 }265 if !g.objectsPresent(ctx, r, []string{oid}) {266 continue267 }268 delete(missing, ref)269 }270 }271 if len(missing) != 0 {272 if lastErr != nil {273 return fmt.Errorf("Git sources did not provide every expected object: %w", lastErr)274 }275 return errors.New("Git sources did not provide every expected object")276 }277 return g.installFetchedRefs(r, expected)278}279280func (g *GitRelay) installFetchedRefs(r Repository, expected map[string]string) error {281 native, err := g.nativeRefs(r)282 if err != nil {283 return err284 }285 for ref, oid := range expected {286 if oid != "" && native[ref] != oid {287 if err := g.updateRef(r, ref, oid); err != nil {288 return err289 }290 }291 }292 return nil293}294295func urlHost(raw string) string {296 u, _ := url.Parse(raw)297 h := u.Hostname()298 if strings.Contains(h, ":") {299 return "[" + h + "]"300 }301 return h302}303func uPort(raw string) string {304 u, _ := url.Parse(raw)305 if p := u.Port(); p != "" {306 return p307 }308 return "443"309}310311func safeRef(ref string) string {312 return strings.NewReplacer("/", "_", "\\", "_").Replace(strings.TrimPrefix(ref, "refs/"))313}314315func gitDirSize(root string) (int64, error) {316 var total int64317 err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {318 if err != nil {319 if os.IsNotExist(err) {320 return nil321 }322 return err323 }324 if entry.IsDir() {325 return nil326 }327 info, err := entry.Info()328 if err != nil {329 if os.IsNotExist(err) {330 return nil331 }332 return err333 }334 total += info.Size()335 return nil336 })337 return total, err338}339340func (g *GitRelay) updateRef(r Repository, ref, oid string) error {341 cmd := exec.Command("git", "--git-dir", g.repoPath(r), "update-ref", ref, oid)342 if out, err := cmd.CombinedOutput(); err != nil {343 return fmt.Errorf("update ref %s: %w: %s", ref, err, strings.TrimSpace(string(out)))344 }345 return nil346}347func isObjectID(s string) bool {348 if len(s) != 40 {349 return false350 }351 for _, c := range s {352 if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {353 return false354 }355 }356 return true357}358func contains(values []string, want string) bool {359 for _, value := range values {360 if value == want {361 return true362 }363 }364 return false365}