381 lines
14 KiB
Go
381 lines
14 KiB
Go
//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)
|
||
}
|
||
}
|
||
}
|