Files
privatae-bastion/client-go/service_mirror_test.go
T
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

381 lines
14 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build !windows
package main
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func TestMirrorSourceRefusal(t *testing.T) {
ok := mirrorSource{URL: "https://github.com/pallets/flask.git", Repo: "mirrors/flask"}
if why := mirrorSourceRefusal(ok); why != "" {
t.Fatalf("a plain https repo is fine: %s", why)
}
for label, m := range map[string]mirrorSource{
"http": {URL: "http://github.com/a/b", Repo: "m/b"},
"file": {URL: "file:///srv/repo", Repo: "m/b"},
"ssh": {URL: "ssh://git@github.com/a/b", Repo: "m/b"},
"userinfo": {URL: "https://user:pw@github.com/a/b", Repo: "m/b"},
"query": {URL: "https://github.com/a/b?x=1", Repo: "m/b"},
"no path": {URL: "https://github.com/", Repo: "m/b"},
"comma": {URL: "https://github.com/a/b,c", Repo: "m/b"},
"repo no /": {URL: "https://github.com/a/b", Repo: "flask"},
"repo dotdot": {URL: "https://github.com/a/b", Repo: "m/..x"},
"repo pipe": {URL: "https://github.com/a/b", Repo: "m/b|c"},
} {
if mirrorSourceRefusal(m) == "" {
t.Errorf("%s must be refused", label)
}
}
}
// Every address must be public; git is pinned to a vetted one.
func TestMirrorVetHost(t *testing.T) {
old := mirrorResolve
defer func() { mirrorResolve = old }()
answers := map[string][]net.IP{
"github.com": {net.ParseIP("140.82.112.3")},
"evil.test": {net.ParseIP("8.8.8.8"), net.ParseIP("192.168.1.10")},
"loop.test": {net.ParseIP("127.0.0.1")},
"cgnat.test": {net.ParseIP("100.64.0.9")},
"nat64.test": {net.ParseIP("64:ff9b::a00:1")},
"v6ula.test": {net.ParseIP("fd12:3456::1")},
}
mirrorResolve = func(ctx context.Context, h string) ([]net.IP, error) { return answers[h], nil }
if ip, err := mirrorVetHost(context.Background(), "github.com"); err != nil || ip != "140.82.112.3" {
t.Fatalf("a public host is pinned to its address: %q %v", ip, err)
}
for _, h := range []string{"evil.test", "loop.test", "cgnat.test", "nat64.test", "v6ula.test", "localhost", "10.0.0.1", "nowhere.test"} {
if _, err := mirrorVetHost(context.Background(), h); err == nil {
t.Errorf("%s must be refused", h)
}
}
}
// The purpose is the 0.36.0 string when there are no mirrors, and binds the
// mirror list (sorted) when there are — the host pins the same strings.
func TestReachPurposeWithMirrors(t *testing.T) {
base := &reachTerms{Server: "box1", Slot: "forge", Subject: "h.example", TTLDays: 30}
if p, _ := purposeForReach(base); p != "service_reach_grant|box1|forge|h.example|ttl:30" {
t.Fatalf("no mirrors: unchanged, got %q", p)
}
base.Mirrors = []mirrorSource{
{URL: "https://github.com/psf/requests.git", Repo: "mirrors/requests"},
{URL: "https://github.com/pallets/flask.git", Repo: "mirrors/flask"},
}
want := "service_reach_grant|box1|forge|h.example|ttl:30|mirrors:" +
"https://github.com/pallets/flask.git>mirrors/flask,https://github.com/psf/requests.git>mirrors/requests"
if p, err := purposeForReach(base); err != nil || p != want {
t.Fatalf("mirrors bound, sorted:\n got %q\n want %q (%v)", p, want, err)
}
if p, _ := purposeFromDetails(map[string]any{"kind": reachKind, "server": "box1", "slot": "forge",
"subject": "h.example", "ttl_days": float64(30), "mirrors": []any{
map[string]any{"url": "https://github.com/pallets/flask.git", "repo": "mirrors/flask"},
map[string]any{"url": "https://github.com/psf/requests.git", "repo": "mirrors/requests"}}}); p != want {
t.Fatalf("the words the person reads build the same purpose: %q", p)
}
}
type gitCall struct {
env []string
args []string
}
// A sync, end to end with git and DNS stubbed and a real socket standing in for
// the server: the fetch is pinned to the vetted address, the push goes only to
// the loopback bridge, the token never appears in argv, an internal upstream is
// refused while the others sync, and only the granted host may run it.
func TestServiceMirrorSync(t *testing.T) {
setupBox(t)
stubServiceRuntime(t, "docker")
oldGit, oldRes, oldFound := mirrorGit, mirrorResolve, mirrorGitUsable
defer func() { mirrorGit, mirrorResolve, mirrorGitUsable = oldGit, oldRes, oldFound }()
mirrorGitUsable = func() string { return "" }
mirrorResolve = func(ctx context.Context, h string) ([]net.IP, error) {
if h == "inside.test" {
return []net.IP{net.ParseIP("192.168.1.5")}, nil
}
return []net.IP{net.ParseIP("140.82.112.3")}, nil
}
var mu sync.Mutex
var calls []gitCall
mirrorGit = func(d time.Duration, env []string, args ...string) (string, error) {
mu.Lock()
calls = append(calls, gitCall{env: env, args: args})
mu.Unlock()
return "", nil
}
spec := forgeSpec("forge")
spec.owner = tAnchor
if b, code := runService(spec); code != 0 {
t.Fatalf("up: %s", b)
}
data, _ := serviceDataDir("forge")
ln, err := net.Listen("unix", filepath.Join(data, "gitea.sock"))
if err != nil {
t.Skip("no unix sockets here")
}
defer ln.Close()
cfgs, _ := loadPairings()
var dir string
for _, c := range cfgs {
if c.HostName == tAnchor {
dir = c.dcnDir()
}
}
_, priv, _ := ed25519.GenerateKey(nil)
pub := base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey))
put := func(terms map[string]any) {
tc, sig := signedTerms(t, priv, terms)
if err := dcnSaveFile(dir, &dcnGrantFile{VerifyPubkey: pub, Stores: map[string]dcnGrantRec{},
Reach: map[string]dcnGrantRec{"1": {TermsCanonical: tc, Signature: sig}}}); err != nil {
t.Fatal(err)
}
}
job := harnessSpec{Mode: "service", ServiceOp: "mirror", ServiceName: "forge", PushToken: "SECRET-TOKEN",
owner: tAnchor, dcnDir: dir}
// No grant: refused.
put(reachTermsFor(tMember, "forge", 30))
if b, code := runService(job); code == 0 || !strings.Contains(string(b), "no reach grant") {
t.Fatalf("another host's grant does not let this one sync: %s", b)
}
// A grant with no mirrors: refused.
put(reachTermsFor(tAnchor, "forge", 30))
if b, code := runService(job); code == 0 || !strings.Contains(string(b), "no mirror sources") {
t.Fatalf("nothing approved to fetch: %s", b)
}
// Two public sources and one that resolves inside the network.
terms := reachTermsFor(tAnchor, "forge", 30)
terms["mirrors"] = []map[string]any{
{"url": "https://github.com/pallets/flask.git", "repo": "mirrors/flask"},
{"url": "https://inside.test/secret/repo.git", "repo": "mirrors/secret"},
{"url": "https://github.com/psf/requests.git", "repo": "mirrors/requests"},
}
put(terms)
calls = nil
b, code := runService(job)
if code == 0 || !strings.Contains(string(b), "public internet only") {
t.Fatalf("the internal upstream must fail the sync with its reason: %s", b)
}
res := decodeRes(t, b)
oks := 0
for _, r := range res["mirrors"].([]any) {
if m := r.(map[string]any); m["ok"] == true {
oks++
}
}
if oks != 2 {
t.Fatalf("the two public sources still sync: %s", b)
}
var fetches, pushes int
for _, c := range calls {
joined := strings.Join(c.args, " ")
if strings.Contains(joined, "SECRET-TOKEN") {
t.Fatalf("the token must never be on the command line: %s", joined)
}
if strings.Contains(joined, "inside.test") && strings.Contains(joined, " fetch ") {
t.Fatalf("the internal upstream must never be fetched: %s", joined)
}
switch {
case strings.Contains(joined, " fetch "):
fetches++
if !strings.Contains(joined, "http.curloptResolve=github.com:443:140.82.112.3") ||
!strings.Contains(joined, "protocol.allow=never") || !strings.Contains(joined, "protocol.https.allow=always") ||
!strings.Contains(joined, "http.followRedirects=false") {
t.Fatalf("a fetch must be pinned and locked down: %s", joined)
}
case strings.Contains(joined, " push "):
pushes++
if !strings.Contains(joined, "http://127.0.0.1:") || strings.Contains(joined, "protocol.https.allow") {
t.Fatalf("a push goes only to the loopback bridge: %s", joined)
}
if !strings.Contains(strings.Join(c.env, "\n"), "Authorization: token SECRET-TOKEN") {
t.Fatal("the push carries the token in its environment")
}
}
}
if fetches != 2 || pushes != 2 {
t.Fatalf("two fetches and two pushes, got %d / %d", fetches, pushes)
}
// The cache is the bastion's, not inside the server's data dir.
if _, err := os.Stat(filepath.Join(data, "mirror-cache")); err == nil {
t.Fatal("the mirror cache must not be inside the server's data dir")
}
}
func TestGitVersionRefusal(t *testing.T) {
for out, ok := range map[string]bool{
"git version 2.37.0\n": true,
"git version 2.43.0": true,
"git version 2.39.5 (Apple Git-154)": true,
"git version 3.0.1": true,
"git version 2.36.6": false,
"git version 1.9.9": false,
"not git at all": false,
} {
if got := gitVersionRefusal(out) == ""; got != ok {
t.Errorf("%q: usable=%v, want %v", out, got, ok)
}
}
}
// Entries are visible ASCII only, and no two sources may land in one repo.
func TestMirrorListRefusal(t *testing.T) {
good := []mirrorSource{
{URL: "https://github.com/pallets/flask.git", Repo: "mirrors/flask"},
{URL: "https://github.com/psf/requests.git", Repo: "mirrors/requests"},
}
if why := mirrorListRefusal(good); why != "" {
t.Fatalf("a clean list is fine: %s", why)
}
for label, ms := range map[string][]mirrorSource{
"space": {{URL: "https://github.com/a/b c", Repo: "m/b"}},
"tab": {{URL: "https://github.com/a/b", Repo: "m/\tb"}},
"unicode": {{URL: "https://gíthub.com/a/b", Repo: "m/b"}},
"rtl override": {{URL: "https://github.com/a/‮b", Repo: "m/b"}},
"duplicate": {good[0], {URL: "https://github.com/x/flask.git", Repo: "mirrors/flask"}},
"dup any case": {good[0], {URL: "https://github.com/x/flask.git", Repo: "Mirrors/Flask"}},
} {
if mirrorListRefusal(ms) == "" {
t.Errorf("%s must be refused", label)
}
}
// A grant carrying a bad list does not verify, and no purpose is built for it.
if _, err := purposeForReach(&reachTerms{Server: "b", Slot: "s", Subject: "h", TTLDays: 1,
Mirrors: []mirrorSource{good[0], good[0]}}); err == nil {
t.Fatal("a duplicate destination must not produce a purpose")
}
}
func TestInternalIPRanges(t *testing.T) {
for _, s := range []string{"0.1.2.3", "198.18.0.1", "198.19.255.1", "240.0.0.1", "255.255.255.254",
"64:ff9b::a00:1", "64:ff9b::7f00:1", "64:ff9b:1::a00:1", "64:ff9b:1::8c52:7003", "2002:a00:1::1", "2002:c0a8:101::1", "100.64.0.1", "::ffff:10.0.0.1"} {
if !internalIP(net.ParseIP(s)) {
t.Errorf("%s must be internal", s)
}
}
// NAT64 and 6to4 are judged by the IPv4 inside them: a public one is public.
for _, s := range []string{"140.82.112.3", "198.20.0.1", "8.8.8.8", "2606:4700::1111",
"64:ff9b::8c52:7003", "2002:8c52:7003::1"} {
if internalIP(net.ParseIP(s)) {
t.Errorf("%s is public", s)
}
}
}
// The push bridge answers only its random path, strips it before the server
// sees the request, and refuses a socket answered by any uid but the server's.
func TestMirrorPushBridge(t *testing.T) {
setupBox(t)
stubServiceRuntime(t, "docker")
if b, code := runService(forgeSpec("forge")); code != 0 {
t.Fatalf("up: %s", b)
}
data, _ := serviceDataDir("forge")
ln, err := net.Listen("unix", filepath.Join(data, "gitea.sock"))
if err != nil {
t.Skip("no unix sockets here")
}
defer ln.Close()
var mu sync.Mutex
var seen []string
go http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
seen = append(seen, r.URL.Path)
mu.Unlock()
_, _ = w.Write([]byte("server"))
}))
get := func(u string) int {
resp, err := http.Get(u)
if err != nil {
return -1
}
resp.Body.Close()
return resp.StatusCode
}
// The server runs as uid 1000 by its record; this test's socket is answered
// by our own uid, so the bridge must refuse to carry anything to it.
base, closeBridge, err := mirrorPushBridge("forge", 1000)
if err != nil {
t.Fatal(err)
}
u, _ := url.Parse(base)
root := "http://" + u.Host
if code := get(root + "/mirrors/flask.git/info/refs"); code != http.StatusNotFound {
t.Fatalf("a path outside the random prefix gets 404, got %d", code)
}
if code := get(root + u.Path[:len(u.Path)-2] + "/x"); code != http.StatusNotFound {
t.Fatalf("a near-miss prefix gets 404, got %d", code)
}
if code := get(base + "mirrors/flask.git/info/refs"); code != http.StatusBadGateway {
t.Fatalf("a socket answered by the wrong uid is refused, got %d", code)
}
closeBridge()
if code := get(base + "x"); code != -1 {
t.Fatalf("the bridge is gone after its push, got %d", code)
}
mu.Lock()
if len(seen) != 0 {
t.Fatalf("nothing may have reached the server: %v", seen)
}
mu.Unlock()
// With the uid that really answers, the request arrives with the prefix gone.
if os.Getuid() < 1 {
t.Log("running as uid 0: the forwarding half is covered by the opt-in e2e run")
return
}
base, closeBridge, err = mirrorPushBridge("forge", os.Getuid())
if err != nil {
t.Fatal(err)
}
defer closeBridge()
if code := get(base + "mirrors/flask.git/info/refs"); code != 200 {
t.Fatalf("the granted path is forwarded, got %d", code)
}
mu.Lock()
defer mu.Unlock()
if len(seen) != 1 || seen[0] != "/mirrors/flask.git/info/refs" {
t.Fatalf("the server sees the path without the prefix: %v", seen)
}
}
// The host (service_reach.py) runs the SAME vectors, from a copy of this file:
// both sides must accept exactly the same mirror URLs.
func TestMirrorURLVectors(t *testing.T) {
b, err := os.ReadFile("testdata/mirror_url_vectors.json")
if err != nil {
t.Fatal(err)
}
var vs []struct {
URL string `json:"url"`
OK bool `json:"ok"`
}
if err := json.Unmarshal(b, &vs); err != nil {
t.Fatal(err)
}
for _, v := range vs {
got := mirrorSourceRefusal(mirrorSource{URL: v.URL, Repo: "m/b"}) == ""
if got != v.OK {
t.Errorf("%q: accepted=%v, want %v", v.URL, got, v.OK)
}
}
}