diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e0c9b66e0..fdf4006a4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,8 +37,7 @@ jobs: - name: Configure Kubo Gateway run: | ipfs init; - source ./gateway-conformance/kubo-config.example.sh; - IPFS_NS_MAP=$(cat ./fixtures/dnslinks.json | jq -r 'to_entries | map("\(.key).example.com:\(.value)") | join(",")') + source ./gateway-conformance/kubo-config.example.sh "$(pwd)/fixtures" echo "IPFS_NS_MAP=${IPFS_NS_MAP}" >> $GITHUB_ENV # note: the IPFS_NS_MAP set above will be passed the daemon - uses: ipfs/start-ipfs-daemon-action@v1 diff --git a/fixtures/fixture.schema.json b/fixtures/fixture.schema.json index ae26a75bf..4b5f50476 100644 --- a/fixtures/fixture.schema.json +++ b/fixtures/fixture.schema.json @@ -7,6 +7,9 @@ "additionalProperties": { "type": "object", "properties": { + "domain": { + "type": "string" + }, "subdomain": { "type": "string" }, @@ -15,9 +18,20 @@ } }, "required": [ - "subdomain", "path" ], + "oneOf": [ + { + "required": [ + "domain" + ] + }, + { + "required": [ + "subdomain" + ] + } + ], "additionalProperties": false } } diff --git a/fixtures/t0114/12D3KooWLQzUv2FHWGVPXTXSZpdHs7oHbXub2G5WC8Tx4NQhyd2d.ipns-record b/fixtures/t0114/12D3KooWLQzUv2FHWGVPXTXSZpdHs7oHbXub2G5WC8Tx4NQhyd2d.ipns-record new file mode 100644 index 000000000..39b2f41a4 Binary files /dev/null and b/fixtures/t0114/12D3KooWLQzUv2FHWGVPXTXSZpdHs7oHbXub2G5WC8Tx4NQhyd2d.ipns-record differ diff --git a/fixtures/t0114/QmVujd5Vb7moysJj8itnGufN7MEtPRCNHkKpNuA4onsRa3.ipns-record b/fixtures/t0114/QmVujd5Vb7moysJj8itnGufN7MEtPRCNHkKpNuA4onsRa3.ipns-record new file mode 100644 index 000000000..b37d9b75b Binary files /dev/null and b/fixtures/t0114/QmVujd5Vb7moysJj8itnGufN7MEtPRCNHkKpNuA4onsRa3.ipns-record differ diff --git a/fixtures/t0114/README.md b/fixtures/t0114/README.md new file mode 100644 index 000000000..005a7f0c5 --- /dev/null +++ b/fixtures/t0114/README.md @@ -0,0 +1,111 @@ +# Dataset description/sources + +- fixtures.car + - raw CARv1 + +- QmUKd....ipns-record + - ipns record, encoded with protocol buffer + +- 12D3K....ipns-record + - ipns record, encoded with protocol buffer + +Generated with: + +```sh +# using ipfs version 0.21.0-dev (03a98280e3e642774776cd3d0435ab53e5dfa867) + +# CIDv0to1 is necessary because raw-leaves are enabled by default during +# "ipfs add" with CIDv1 and disabled with CIDv0 +CID_VAL="hello" +CIDv1=$(echo $CID_VAL | ipfs add --cid-version 1 -Q) +CIDv0=$(echo $CID_VAL | ipfs add --cid-version 0 -Q) +CIDv0to1=$(echo "$CIDv0" | ipfs cid base32) +# sha512 will be over 63char limit, even when represented in Base36 +CIDv1_TOO_LONG=$(echo $CID_VAL | ipfs add --cid-version 1 --hash sha2-512 -Q) + +echo CID_VAL=${CID_VAL} +echo CIDv1=${CIDv1} +echo CIDv0=${CIDv0} +echo CIDv0to1=${CIDv0to1} +echo CIDv1_TOO_LONG=${CIDv1_TOO_LONG} + +# Directory tree crafted to test for edge cases like "/ipfs/ipfs/ipns/bar" +mkdir -p testdirlisting/ipfs/ipns && +echo "hello" > testdirlisting/hello && +echo "text-file-content" > testdirlisting/ipfs/ipns/bar && +mkdir -p testdirlisting/api && +mkdir -p testdirlisting/ipfs && +echo "I am a txt file" > testdirlisting/api/file.txt && +echo "I am a txt file" > testdirlisting/ipfs/file.txt && +DIR_CID=$(ipfs add -Qr --cid-version 1 testdirlisting) + +echo DIR_CID=${DIR_CID} # ./testdirlisting + +ipfs files mkdir /t0114/ +ipfs files cp /ipfs/${CIDv1} /t0114/ +ipfs files cp /ipfs/${CIDv0} /t0114/ +ipfs files cp /ipfs/${CIDv0to1} /t0114/ +ipfs files cp /ipfs/${DIR_CID} /t0114/ +ipfs files cp /ipfs/${CIDv1_TOO_LONG} /t0114/ + +ROOT=`ipfs files stat /t0114/ --hash` + +ipfs dag export ${ROOT} > ./fixtures.car + +# Then the keys + +KEY_NAME=test_key_rsa_$RANDOM +RSA_KEY=$(ipfs key gen --ipns-base=b58mh --type=rsa --size=2048 ${KEY_NAME} | head -n1 | tr -d "\n") +RSA_IPNS_IDv0=$(echo "$RSA_KEY" | ipfs cid format -v 0) +RSA_IPNS_IDv1=$(echo "$RSA_KEY" | ipfs cid format -v 1 --mc libp2p-key -b base36) +RSA_IPNS_IDv1_DAGPB=$(echo "$RSA_IPNS_IDv0" | ipfs cid format -v 1 -b base36) + +# publish a record valid for a 100 years +ipfs name publish --key ${KEY_NAME} --allow-offline -Q --ttl=876600h --lifetime=876600h "/ipfs/$CIDv1" +ipfs routing get /ipns/${RSA_KEY} > ${RSA_KEY}.ipns-record + +echo RSA_KEY=${RSA_KEY} +echo RSA_IPNS_IDv0=${RSA_IPNS_IDv0} +echo RSA_IPNS_IDv1=${RSA_IPNS_IDv1} +echo RSA_IPNS_IDv1_DAGPB=${RSA_IPNS_IDv1_DAGPB} + +KEY_NAME=test_key_ed25519_$RANDOM +ED25519_KEY=$(ipfs key gen --ipns-base=b58mh --type=ed25519 ${KEY_NAME} | head -n1 | tr -d "\n") +ED25519_IPNS_IDv0=$ED25519_KEY +ED25519_IPNS_IDv1=$(ipfs key list -l --ipns-base=base36 | grep ${KEY_NAME} | cut -d " " -f1 | tr -d "\n") +ED25519_IPNS_IDv1_DAGPB=$(echo "$ED25519_IPNS_IDv1" | ipfs cid format -v 1 -b base36 --mc dag-pb) + +# ed25519 fits under 63 char limit when represented in base36 +IPNS_ED25519_B58MH=$(ipfs key list -l --ipns-base b58mh | grep $KEY_NAME | cut -d" " -f1 | tr -d "\n") +IPNS_ED25519_B36CID=$(ipfs key list -l --ipns-base base36 | grep $KEY_NAME | cut -d" " -f1 | tr -d "\n") + +# publish a record valid for a 100 years +ipfs name publish --key ${KEY_NAME} --allow-offline -Q --ttl=876600h --lifetime=876600h "/ipfs/$CIDv1" +ipfs routing get /ipns/${ED25519_KEY} > ${ED25519_KEY}.ipns-record + +echo ED25519_KEY=${ED25519_KEY} +echo ED25519_IPNS_IDv0=${ED25519_IPNS_IDv0} +echo ED25519_IPNS_IDv1=${ED25519_IPNS_IDv1} +echo ED25519_IPNS_IDv1_DAGPB=${ED25519_IPNS_IDv1_DAGPB} +echo IPNS_ED25519_B58MH=${IPNS_ED25519_B58MH} +echo IPNS_ED25519_B36CID=${IPNS_ED25519_B36CID} + +# CID_VAL=hello +# CIDv1=bafkreicysg23kiwv34eg2d7qweipxwosdo2py4ldv42nbauguluen5v6am +# CIDv0=QmZULkCELmmk5XNfCgTnCyFgAVxBRBXyDHGGMVoLFLiXEN +# CIDv0to1=bafybeiffndsajwhk3lwjewwdxqntmjm4b5wxaaanokonsggenkbw6slwk4 +# CIDv1_TOO_LONG=bafkrgqhhyivzstcz3hhswshfjgy6ertgmnqeleynhwt4dlfsthi4hn7zgh4uvlsb5xncykzapi3ocd4lzogukir6ksdy6wzrnz6ohnv4aglcs +# DIR_CID=bafybeiht6dtwk3les7vqm6ibpvz6qpohidvlshsfyr7l5mpysdw2vmbbhe # ./testdirlisting + +# RSA_KEY=QmVujd5Vb7moysJj8itnGufN7MEtPRCNHkKpNuA4onsRa3 +# RSA_IPNS_IDv0=QmVujd5Vb7moysJj8itnGufN7MEtPRCNHkKpNuA4onsRa3 +# RSA_IPNS_IDv1=k2k4r8m7xvggw5pxxk3abrkwyer625hg01hfyggrai7lk1m63fuihi7w +# RSA_IPNS_IDv1_DAGPB=k2jmtxu61bnhrtj301lw7zizknztocdbeqhxgv76l2q9t36fn9jbzipo + +# ED25519_KEY=12D3KooWLQzUv2FHWGVPXTXSZpdHs7oHbXub2G5WC8Tx4NQhyd2d +# ED25519_IPNS_IDv0=12D3KooWLQzUv2FHWGVPXTXSZpdHs7oHbXub2G5WC8Tx4NQhyd2d +# ED25519_IPNS_IDv1=k51qzi5uqu5dk3v4rmjber23h16xnr23bsggmqqil9z2gduiis5se8dht36dam +# ED25519_IPNS_IDv1_DAGPB=k50rm9yjlt0jey4fqg6wafvqprktgbkpgkqdg27tpqje6iimzxewnhvtin9hhq +# IPNS_ED25519_B58MH=12D3KooWLQzUv2FHWGVPXTXSZpdHs7oHbXub2G5WC8Tx4NQhyd2d +# IPNS_ED25519_B36CID=k51qzi5uqu5dk3v4rmjber23h16xnr23bsggmqqil9z2gduiis5se8dht36dam +``` diff --git a/fixtures/t0114/dnslink.yml b/fixtures/t0114/dnslink.yml new file mode 100644 index 000000000..54c793b72 --- /dev/null +++ b/fixtures/t0114/dnslink.yml @@ -0,0 +1,10 @@ +# yaml-language-server: $schema=../fixture.schema.json +dnslinks: + wikipedia: + domain: dnslink-subdomain-gw-test.example.org + # Wikipedia CID + path: /ipfs/bafybeiaysi4s6lnjev27ln5icwm6tueaw2vdykrtjkwiphwekaywqhcjze + test: + domain: dnslink-test.example.com + # CIDv1=$(echo "hello" | ipfs add --cid-version 1 -Q) + path: /ipfs/bafkreicysg23kiwv34eg2d7qweipxwosdo2py4ldv42nbauguluen5v6am \ No newline at end of file diff --git a/fixtures/t0114/fixtures.car b/fixtures/t0114/fixtures.car new file mode 100644 index 000000000..bc7b913df Binary files /dev/null and b/fixtures/t0114/fixtures.car differ diff --git a/kubo-config.example.sh b/kubo-config.example.sh index 14cf41969..48f17a82f 100755 --- a/kubo-config.example.sh +++ b/kubo-config.example.sh @@ -1,17 +1,22 @@ #! /usr/bin/env bash -ipfs config --json Gateway.PublicGateways '{ - "example.com": { - "UseSubdomains": true, - "Paths": ["/ipfs", "/ipns", "/api"] - }, - "localhost": { - "UseSubdomains": true, - "InlineDNSLink": true, - "Paths": ["/ipfs", "/ipns", "/api"] - } + +FIXTURES_PATH=${1:-$(pwd)} + +ipfs config --json Gateway.PublicGateways '{ + "example.com": { + "UseSubdomains": true, + "InlineDNSLink": true, + "Paths": ["/ipfs", "/ipns", "/api"] + }, + "localhost": { + "UseSubdomains": true, + "InlineDNSLink": true, + "Paths": ["/ipfs", "/ipns", "/api"] + } }' -export IPFS_NS_MAP=$(cat ./dnslinks.json | jq -r 'to_entries | map("\(.key).example.com:\(.value)") | join(",")') +export IPFS_NS_MAP=$(cat "${FIXTURES_PATH}/dnslinks.json" | jq -r '.subdomains | to_entries | map("\(.key).example.com:\(.value)") | join(",")') +export IPFS_NS_MAP="$(cat "${FIXTURES_PATH}/dnslinks.json" | jq -r '.domains | to_entries | map("\(.key):\(.value)") | join(",")'),${IPFS_NS_MAP}" echo "Set the following IPFS_NS_MAP before starting the kubo daemon:" echo "IPFS_NS_MAP=${IPFS_NS_MAP}" diff --git a/tests/t0114_gateway_subdomains_test.go b/tests/t0114_gateway_subdomains_test.go index c44e4d2d2..5713d6c7b 100644 --- a/tests/t0114_gateway_subdomains_test.go +++ b/tests/t0114_gateway_subdomains_test.go @@ -1,15 +1,18 @@ package tests import ( - "fmt" "net/url" "testing" "github.com/ipfs/gateway-conformance/tooling/car" . "github.com/ipfs/gateway-conformance/tooling/check" + "github.com/ipfs/gateway-conformance/tooling/dnslink" + "github.com/ipfs/gateway-conformance/tooling/helpers" + "github.com/ipfs/gateway-conformance/tooling/ipns" "github.com/ipfs/gateway-conformance/tooling/specs" . "github.com/ipfs/gateway-conformance/tooling/test" - "github.com/ipfs/gateway-conformance/tooling/tmpl" + "github.com/multiformats/go-multibase" + "github.com/multiformats/go-multicodec" ) func TestGatewaySubdomains(t *testing.T) { @@ -25,16 +28,6 @@ func TestGatewaySubdomains(t *testing.T) { tests := SugarTests{} - // sugar: readable way to add more tests - with := func(moreTests SugarTests) { - tests = append(tests, moreTests...) - } - - // sugar: nicer looking sprintf call - URL := func(path string, args ...interface{}) string { - return tmpl.Fmt(path, args...) - } - // We're going to run the same test against multiple gateways (localhost, and a subdomain gateway) gatewayURLs := []string{ SubdomainGatewayURL, @@ -47,397 +40,706 @@ func TestGatewaySubdomains(t *testing.T) { t.Fatal(err) } - with(testGatewayWithManyProtocols(t, - "request for example.com/ipfs/{CIDv1} redirects to subdomain", - ` - subdomains should not return payload directly, - but redirect to URL with proper origin isolation - `, - URL("{{url}}/ipfs/{{cid}}/", gatewayURL, CIDv1), - Expect(). - Status(301). - Headers( - Header("Location"). - Hint("request for example.com/ipfs/{CIDv1} returns Location HTTP header for subdomain redirect in browsers"). - Contains("{{scheme}}://{{cid}}.ipfs.{{host}}/", u.Scheme, CIDv1, u.Host), - ), - )) - - with(testGatewayWithManyProtocols(t, - "request for example.com/ipfs/{DirCID} redirects to subdomain", - ` - subdomains should not return payload directly, - but redirect to URL with proper origin isolation - `, - URL("{{url}}/ipfs/{{cid}}/", gatewayURL, DirCID), - Expect(). - Status(301). - Headers( - Header("Location"). - Hint("request for example.com/ipfs/{DirCID} returns Location HTTP header for subdomain redirect in browsers"). - Contains("{{scheme}}://{{cid}}.ipfs.{{host}}/", u.Scheme, DirCID, u.Host), - ), - )) - - with(testGatewayWithManyProtocols(t, - "request for example.com/ipfs/{CIDv0} redirects to CIDv1 representation in subdomain", - "", - URL("{{url}}/ipfs/{{cid}}/", gatewayURL, CIDv0), - Expect(). - Status(301). - Headers( - Header("Location"). - Hint("request for example.com/ipfs/{CIDv0to1} returns Location HTTP header for subdomain redirect in browsers"). - Contains("{{scheme}}://{{cid}}.ipfs.{{host}}/", u.Scheme, CIDv0to1, u.Host), - ), - )) - - // TODO: ipns - // TODO: dns link test - - // ============================================================================ - // Test subdomain-based requests to a local gateway with default config - // (origin per content root at http://*.example.com) - // ============================================================================ - - with(testGatewayWithManyProtocols(t, - "request for {CID}.ipfs.example.com should return expected payload", - "", - URL("{{scheme}}://{{cid}}.ipfs.{{host}}", u.Scheme, CIDv1, u.Host), - Expect(). - Status(200). - Body(Contains(CIDVal)), - )) - - with(testGatewayWithManyProtocols(t, - "request for {CID}.ipfs.example.com/ipfs/{CID} should return HTTP 404", - "ensure /ipfs/ namespace is not mounted on subdomain", - URL("{{scheme}}://{{cid}}.ipfs.{{host}}/ipfs/{{cid}}", u.Scheme, CIDv1, u.Host), - Expect(). - Status(404), - )) - - with(testGatewayWithManyProtocols(t, - "request for {CID}.ipfs.example.com/ipfs/file.txt should return data from a file in CID content root", - "ensure requests to /ipfs/* are not blocked, if content root has such subdirectory", - URL("{{scheme}}://{{cid}}.ipfs.{{host}}/ipfs/file.txt", u.Scheme, DirCID, u.Host), - Expect(). - Status(200). - Body(Contains("I am a txt file")), - )) - - with(testGatewayWithManyProtocols(t, - "valid file and subdirectory paths in directory listing at {cid}.ipfs.example.com", - "{CID}.ipfs.example.com/sub/dir (Directory Listing)", - URL("{{scheme}}://{{cid}}.ipfs.{{host}}/", u.Scheme, DirCID, u.Host), - Expect(). - Status(200). - Body(And( - // TODO: implement html expectations - Contains(`hello`), - Contains(`ipfs`), - )), - )) - - with(testGatewayWithManyProtocols(t, - "valid parent directory path in directory listing at {cid}.ipfs.example.com/sub/dir", - "", - URL("{{scheme}}://{{cid}}.ipfs.{{host}}/ipfs/ipns/", u.Scheme, DirCID, u.Host), - Expect(). - Status(200). - Body(And( - // TODO: implement html expectations - Contains(`..`), - Contains(`bar`), - )), - )) - - with(testGatewayWithManyProtocols(t, - "request for deep path resource at {cid}.ipfs.localhost/sub/dir/file", - "", - URL("{{scheme}}://{{cid}}.ipfs.{{host}}/ipfs/ipns/bar", u.Scheme, DirCID, u.Host), - Expect(). - Status(200). - Body(Contains("text-file-content")), - )) - - with(testGatewayWithManyProtocols(t, - "valid breadcrumb links in the header of directory listing at {cid}.ipfs.example.com/sub/dir", - ` + tests = append(tests, SugarTests{ + { + Name: "request for example.com/ipfs/{CIDv1} redirects to subdomain", + Hint: ` + subdomains should not return payload directly, + but redirect to URL with proper origin isolation + `, + Request: Request().URL("{{url}}/ipfs/{{cid}}/", gatewayURL, CIDv1), + Response: Expect(). + Status(301). + Headers( + Header("Location"). + Hint("request for example.com/ipfs/{CIDv1} returns Location HTTP header for subdomain redirect in browsers"). + Contains("{{scheme}}://{{cid}}.ipfs.{{host}}/", u.Scheme, CIDv1, u.Host), + ), + }, + { + Name: "request for example.com/ipfs/{DirCID} redirects to subdomain", + Hint: ` + subdomains should not return payload directly, + but redirect to URL with proper origin isolation + `, + Request: Request().URL("{{url}}/ipfs/{{cid}}/", gatewayURL, DirCID), + Response: Expect(). + Status(301). + Headers( + Header("Location"). + Hint("request for example.com/ipfs/{DirCID} returns Location HTTP header for subdomain redirect in browsers"). + Contains("{{scheme}}://{{cid}}.ipfs.{{host}}/", u.Scheme, DirCID, u.Host), + ), + }, + { + Name: "request for example.com/ipfs/{CIDv0} redirects to CIDv1 representation in subdomain", + Request: Request().URL("{{url}}/ipfs/{{cid}}/", gatewayURL, CIDv0), + Response: Expect(). + Status(301). + Headers( + Header("Location"). + Hint("request for example.com/ipfs/{CIDv0to1} returns Location HTTP header for subdomain redirect in browsers"). + Contains("{{scheme}}://{{cid}}.ipfs.{{host}}/", u.Scheme, CIDv0to1, u.Host), + ), + }, + // ============================================================================ + // Test subdomain-based requests to a local gateway with default config + // (origin per content root at http://*.example.com) + // ============================================================================ + { + Name: "request for {CID}.ipfs.example.com should return expected payload", + Request: Request().URL("{{scheme}}://{{cid}}.ipfs.{{host}}", u.Scheme, CIDv1, u.Host), + Response: Expect(). + Status(200). + Body(Contains(CIDVal)), + }, + { + Name: "request for {CID}.ipfs.example.com/ipfs/{CID} should return HTTP 404", + Hint: "ensure /ipfs/ namespace is not mounted on subdomain", + Request: Request().URL("{{scheme}}://{{cid}}.ipfs.{{host}}/ipfs/{{cid}}", u.Scheme, CIDv1, u.Host), + Response: Expect(). + Status(404), + }, + { + Name: "request for {CID}.ipfs.example.com/ipfs/file.txt should return data from a file in CID content root", + Hint: "ensure requests to /ipfs/* are not blocked, if content root has such subdirectory", + Request: Request().URL("{{scheme}}://{{cid}}.ipfs.{{host}}/ipfs/file.txt", u.Scheme, DirCID, u.Host), + Response: Expect(). + Status(200). + Body(Contains("I am a txt file")), + }, + { + Name: "valid file and subdirectory paths in directory listing at {cid}.ipfs.example.com", + Hint: "{CID}.ipfs.example.com/sub/dir (Directory Listing)", + Request: Request().URL("{{scheme}}://{{cid}}.ipfs.{{host}}/", u.Scheme, DirCID, u.Host), + Response: Expect(). + Status(200). + Body(And( + // TODO: implement html expectations + Contains(`hello`), + Contains(`ipfs`), + )), + }, + { + Name: "valid parent directory path in directory listing at {cid}.ipfs.example.com/sub/dir", + Request: Request().URL("{{scheme}}://{{cid}}.ipfs.{{host}}/ipfs/ipns/", u.Scheme, DirCID, u.Host), + Response: Expect(). + Status(200). + Body(And( + // TODO: implement html expectations + Contains(`..`), + Contains(`bar`), + )), + }, + { + Name: "request for deep path resource at {cid}.ipfs.localhost/sub/dir/file", + Request: Request().URL("{{scheme}}://{{cid}}.ipfs.{{host}}/ipfs/ipns/bar", u.Scheme, DirCID, u.Host), + Response: Expect(). + Status(200). + Body(Contains("text-file-content")), + }, + { + Name: "valid breadcrumb links in the header of directory listing at {cid}.ipfs.example.com/sub/dir", + Hint: ` Note 1: we test for sneaky subdir names {cid}.ipfs.example.com/ipfs/ipns/ :^) Note 2: example.com/ipfs/.. present in HTML will be redirected to subdomain, so this is expected behavior `, - URL("{{scheme}}://{{cid}}.ipfs.{{host}}/ipfs/ipns/", u.Scheme, DirCID, u.Host), - Expect(). - Status(200). - Body( - And( - Contains("Index of"), - Contains(`/ipfs/{{cid}}/ipfs/ipns`, - u.Host, DirCID), + Request: Request().URL("{{scheme}}://{{cid}}.ipfs.{{host}}/ipfs/ipns/", u.Scheme, DirCID, u.Host), + Response: Expect(). + Status(200). + Body( + And( + Contains("Index of"), + Contains(`/ipfs/{{cid}}/ipfs/ipns`, + u.Host, DirCID), + ), + ), + }, + // ## ============================================================================ + // ## Test subdomain-based requests with a custom hostname config + // ## (origin per content root at http://*.example.com) + // ## ============================================================================ + + // # example.com/ip(f|n)s/* + // # ============================================================================= + + // # path requests to the root hostname should redirect + // # to a subdomain URL with proper origin isolation + + { + Name: "request for example.com/ipfs/{CIDv1} produces redirect to {CIDv1}.ipfs.example.com", + Hint: "path requests to the root hostname should redirect to a subdomain URL with proper origin isolation", + Request: Request().URL("{{scheme}}://{{host}}/ipfs/{{cid}}/", u.Scheme, u.Host, CIDv1), + Response: Expect(). + Headers( + Header("Location").Equals("{{scheme}}://{{cid}}.ipfs.{{host}}/", u.Scheme, CIDv1, u.Host), + ), + }, + + { + Name: "request for example.com/ipfs/{InvalidCID} produces useful error before redirect", + Hint: "error message should include original CID (and it should be case-sensitive, as we can't assume everyone uses base32)", + Request: Request().URL("{{scheme}}://{{host}}/ipfs/QmInvalidCID", u.Scheme, u.Host), + Response: Expect(). + Body(Contains(`invalid path "/ipfs/QmInvalidCID"`)), + }, + + { + Name: "request for example.com/ipfs/{CIDv0} produces redirect to {CIDv1}.ipfs.example.com", + Request: Request().URL("{{scheme}}://{{host}}/ipfs/{{cid}}/", u.Scheme, u.Host, CIDv0), + Response: Expect(). + Status(301). + Headers( + Header("Location").Equals("{{scheme}}://{{cid}}.ipfs.{{host}}/", u.Scheme, CIDv0to1, u.Host), + ), + }, + + { + Name: "request for http://example.com/ipfs/{CID} with X-Forwarded-Proto: https produces redirect to HTTPS URL", + Hint: "Support X-Forwarded-Proto", + Request: Request().URL("{{scheme}}://{{host}}/ipfs/{{cid}}/", u.Scheme, u.Host, CIDv1). + Header("X-Forwarded-Proto", "https"), + Response: Expect(). + Status(301). + Headers( + Header("Location").Equals("https://{{cid}}.ipfs.{{host}}/", CIDv1, u.Host), + ), + }, + + { + Name: "request for example.com/ipfs/?uri=ipfs%3A%2F%2F.. produces redirect to /ipfs/.. content path", + Hint: "Support ipfs:// in https://developer.mozilla.org/en-US/docs/Web/API/Navigator/registerProtocolHandler", + Request: Request().URL("{{scheme}}://{{host}}/ipfs/", u.Scheme, u.Host). + Query( + "uri", "ipfs://{{host}}/wiki/Diego_Maradona.html", CIDWikipedia, + ), + Response: Expect(). + Status(301). + Headers( + Header("Location").Equals("/ipfs/{{cid}}/wiki/Diego_Maradona.html", CIDWikipedia), + ), + }, + { + Name: "request for a too long CID at localhost/ipfs/{CIDv1} returns human readable error", + Hint: "router should not redirect to hostnames that could fail due to DNS limits", + Request: Request().URL("{{url}}/ipfs/{{cid}}", gatewayURL, CIDv1_TOO_LONG), + Response: Expect(). + Status(400). + Body(Contains("CID incompatible with DNS label length limit of 63")), + }, + { + Name: "request for a too long CID at {CIDv1}.ipfs.localhost returns expected payload", + Hint: "direct request should also fail (provides the same UX as router and avoids confusion)", + Request: Request().URL("{{scheme}}://{{cid}}.ipfs.{{host}}/", u.Scheme, CIDv1_TOO_LONG, u.Host), + Response: Expect(). + Status(400). + Body(Contains("CID incompatible with DNS label length limit of 63")), + }, + // ## ============================================================================ + // ## Test support for X-Forwarded-Host + // ## ============================================================================ + { + Name: "request for http://fake.domain.com/ipfs/{CID} doesn't match the example.com gateway", + Request: Request().URL("{{scheme}}://{{domain}}/ipfs/{{cid}}", u.Scheme, "fake.domain.com", CIDv1), + Response: Expect(). + Status(200), + }, + { + Name: "request for http://fake.domain.com/ipfs/{CID} with X-Forwarded-Host: example.com match the example.com gateway", + Request: Request().URL("{{scheme}}://{{domain}}/ipfs/{{cid}}", u.Scheme, "fake.domain.com", CIDv1). + Header("X-Forwarded-Host", u.Host), + Response: Expect(). + Status(301). + Headers( + Header("Location").Equals("{{scheme}}://{{cid}}.ipfs.{{host}}/", u.Scheme, CIDv1, u.Host), ), - ), - )) - - // TODO: # *.ipns.localhost - // TODO: # .ipns.localhost - // TODO: # .ipns.localhost - - // ## ============================================================================ - // ## Test DNSLink inlining on HTTP gateways - // ## ============================================================================ - - // TODO - - // ## ============================================================================ - // ## Test subdomain-based requests with a custom hostname config - // ## (origin per content root at http://*.example.com) - // ## ============================================================================ - - // # example.com/ip(f|n)s/* - // # ============================================================================= - - // # path requests to the root hostname should redirect - // # to a subdomain URL with proper origin isolation - - with(testGatewayWithManyProtocols(t, - "request for example.com/ipfs/{CIDv1} produces redirect to {CIDv1}.ipfs.example.com", - "path requests to the root hostname should redirect to a subdomain URL with proper origin isolation", - URL("{{scheme}}://{{host}}/ipfs/{{cid}}/", u.Scheme, u.Host, CIDv1), - Expect(). - Headers( - Header("Location").Equals("{{scheme}}://{{cid}}.ipfs.{{host}}/", u.Scheme, CIDv1, u.Host), - ), - )) - - with(testGatewayWithManyProtocols(t, - "request for example.com/ipfs/{InvalidCID} produces useful error before redirect", - "error message should include original CID (and it should be case-sensitive, as we can't assume everyone uses base32)", - URL("{{scheme}}://{{host}}/ipfs/QmInvalidCID", u.Scheme, u.Host), - Expect(). - Body(Contains(`invalid path "/ipfs/QmInvalidCID"`)), - )) - - with(testGatewayWithManyProtocols(t, - "request for example.com/ipfs/{CIDv0} produces redirect to {CIDv1}.ipfs.example.com", - "", - URL("{{scheme}}://{{host}}/ipfs/{{cid}}/", u.Scheme, u.Host, CIDv0), - Expect(). - Status(301). - Headers( - Header("Location").Equals("{{scheme}}://{{cid}}.ipfs.{{host}}/", u.Scheme, CIDv0to1, u.Host), - ), - )) - - with(testGatewayWithManyProtocols(t, - "request for http://example.com/ipfs/{CID} with X-Forwarded-Proto: https produces redirect to HTTPS URL", - "Support X-Forwarded-Proto", - Request(). - URL("{{scheme}}://{{host}}/ipfs/{{cid}}/", u.Scheme, u.Host, CIDv1). - Header("X-Forwarded-Proto", "https"), - Expect(). - Status(301). - Headers( - Header("Location").Equals("https://{{cid}}.ipfs.{{host}}/", CIDv1, u.Host), - ), - )) - - with(testGatewayWithManyProtocols(t, - "request for example.com/ipfs/?uri=ipfs%3A%2F%2F.. produces redirect to /ipfs/.. content path", - "Support ipfs:// in https://developer.mozilla.org/en-US/docs/Web/API/Navigator/registerProtocolHandler", - Request(). - URL("{{scheme}}://{{host}}/ipfs/", u.Scheme, u.Host). - Query( - "uri", "ipfs://{{host}}/wiki/Diego_Maradona.html", CIDWikipedia, - ), - Expect(). - Status(301). - Headers( - Header("Location").Equals("/ipfs/{{cid}}/wiki/Diego_Maradona.html", CIDWikipedia), - ), - )) - - // # example.com/ipns/ - // TODO - - // # example.com/ipns/ - // TODO - - // # DNSLink on Public gateway with a single-level wildcard TLS cert - // # "Option C" from https://github.com/ipfs/in-web-browsers/issues/169 - // TODO - - // # Support ipns:// in https://developer.mozilla.org/en-US/docs/Web/API/Navigator/registerProtocolHandler - // TODO - - // # *.ipns.example.com - // # ============================================================================ - - // # .ipns.example.com - - // # API on subdomain gateway example.com - // # ============================================================================ - - // # DNSLink: .ipns.example.com - // # (not really useful outside of localhost, as setting TLS for more than one - // # level of wildcard is a pain, but we support it if someone really wants it) - // # ============================================================================ - // TODO - - // # DNSLink on Public gateway with a single-level wildcard TLS cert - // # "Option C" from https://github.com/ipfs/in-web-browsers/issues/169 - - // ## Test subdomain handling of CIDs that do not fit in a single DNS Label (>63chars) - // ## https://github.com/ipfs/go-ipfs/issues/7318 - // ## ============================================================================ - // TODO - - with(testGatewayWithManyProtocols(t, - "request for a too long CID at localhost/ipfs/{CIDv1} returns human readable error", - "router should not redirect to hostnames that could fail due to DNS limits", - URL("{{url}}/ipfs/{{cid}}", gatewayURL, CIDv1_TOO_LONG), - Expect(). - Status(400). - Body(Contains("CID incompatible with DNS label length limit of 63")), - )) - - with(testGatewayWithManyProtocols(t, - "request for a too long CID at {CIDv1}.ipfs.localhost returns expected payload", - "direct request should also fail (provides the same UX as router and avoids confusion)", - URL("{{scheme}}://{{cid}}.ipfs.{{host}}/", u.Scheme, CIDv1_TOO_LONG, u.Host), - Expect(). - Status(400). - Body(Contains("CID incompatible with DNS label length limit of 63")), - )) - - // # public subdomain gateway: *.example.com - // TODO: IPNS - - // # Disable selected Paths for the subdomain gateway hostname - // # ============================================================================= - - // # disable /ipns for the hostname by not whitelisting it - - // # refuse requests to Paths that were not explicitly whitelisted for the hostname - - // MANY TODOs here - - // ## ============================================================================ - // ## Test support for X-Forwarded-Host - // ## ============================================================================ - - with(testGatewayWithManyProtocols(t, - "request for http://fake.domain.com/ipfs/{CID} doesn't match the example.com gateway", - "", - URL("{{scheme}}://{{domain}}/ipfs/{{cid}}", u.Scheme, "fake.domain.com", CIDv1), - Expect(). - Status(200), - )) - - with(testGatewayWithManyProtocols(t, - "request for http://fake.domain.com/ipfs/{CID} with X-Forwarded-Host: example.com match the example.com gateway", - "", - Request(). - URL("{{scheme}}://{{domain}}/ipfs/{{cid}}", u.Scheme, "fake.domain.com", CIDv1). - Header("X-Forwarded-Host", u.Host), - Expect(). - Status(301). - Headers( - Header("Location").Equals("{{scheme}}://{{cid}}.ipfs.{{host}}/", u.Scheme, CIDv1, u.Host), - ), - )) - - with(testGatewayWithManyProtocols(t, - "request for http://fake.domain.com/ipfs/{CID} with X-Forwarded-Host: example.com and X-Forwarded-Proto: https match the example.com gateway, redirect with https", - "", - Request(). - URL("{{scheme}}://{{domain}}/ipfs/{{cid}}", u.Scheme, "fake.domain.com", CIDv1). - Header("X-Forwarded-Host", u.Host). - Header("X-Forwarded-Proto", "https"), - Expect(). - Status(301). - Headers( - Header("Location").Equals("https://{{cid}}.ipfs.{{host}}/", CIDv1, u.Host), - ), - )) + }, + { + Name: "request for http://fake.domain.com/ipfs/{CID} with X-Forwarded-Host: example.com and X-Forwarded-Proto: https match the example.com gateway, redirect with https", + Request: Request().URL("{{scheme}}://{{domain}}/ipfs/{{cid}}", u.Scheme, "fake.domain.com", CIDv1). + Header("X-Forwarded-Host", u.Host). + Header("X-Forwarded-Proto", "https"), + Response: Expect(). + Status(301). + Headers( + Header("Location").Equals("https://{{cid}}.ipfs.{{host}}/", CIDv1, u.Host), + ), + }, + }...) } - if specs.SubdomainGateway.IsEnabled() { - Run(t, tests) - } else { - t.Skip("subdomain gateway disabled") - } + RunIfSpecsAreEnabled(t, helpers.UnwrapSubdomainTests(t, tests), specs.SubdomainGateway) } -func testGatewayWithManyProtocols(t *testing.T, label string, hint string, reqURL interface{}, expected ExpectBuilder) SugarTests { - t.Helper() +func TestGatewaySubdomainAndIPNS(t *testing.T) { + tests := SugarTests{} + + rsaFixture := ipns.MustOpenIPNSRecordWithKey("t0114/QmVujd5Vb7moysJj8itnGufN7MEtPRCNHkKpNuA4onsRa3.ipns-record") + ed25519Fixture := ipns.MustOpenIPNSRecordWithKey("t0114/12D3KooWLQzUv2FHWGVPXTXSZpdHs7oHbXub2G5WC8Tx4NQhyd2d.ipns-record") - baseURL := "" - baseReq := Request() + car := car.MustOpenUnixfsCar("t0114/fixtures.car") + helloCID := "bafkreicysg23kiwv34eg2d7qweipxwosdo2py4ldv42nbauguluen5v6am" + payload := string(car.MustGetRawData(helloCID)) - switch req := reqURL.(type) { - case string: - baseURL = reqURL.(string) - case RequestBuilder: - baseReq = req - baseURL = req.GetURL() - default: - t.Fatalf("invalid type for reqURL: %T", reqURL) + // We're going to run the same test against multiple gateways (localhost, and a subdomain gateway) + gatewayURLs := []string{ + SubdomainGatewayURL, + SubdomainLocalhostGatewayURL, } - u, err := url.Parse(baseURL) - if err != nil { - t.Fatal(err) + ipnsRecords := []*ipns.IpnsRecord{ + rsaFixture, + ed25519Fixture, } - // Because you might be testing an IPFS node in CI, or on your local machine, the test are designed - // to test the subdomain behavior (querying http://{CID}.my-subdomain-gateway.io/) even if the node is - // actually living on http://127.0.0.1:8080 or somewhere else. - // - // The test knows two addresses: - // - GatewayURL: the URL we connect to, it might be "dweb.link", "127.0.0.1:8080", etc. - // - SubdomainGatewayURL: the URL we test for subdomain requests, it might be "dweb.link", "localhost", "example.com", etc. - - // host is the hostname of the gateway we are testing, it might be `localhost` or `example.com` - host := u.Host - - // raw url is the url but we replace the host with our local url, it might be `http://127.0.0.1/ipfs/something` - u.Host = GatewayHost - rawURL := u.String() - - return SugarTests{ - { - Name: fmt.Sprintf("%s (direct HTTP)", label), - Hint: fmt.Sprintf("%s\n%s", hint, "direct HTTP request (hostname in URL, raw IP in Host header)"), - Request: baseReq. - URL(rawURL). - Headers( - Header("Host", host), - ), - Response: expected, - }, - { - Name: fmt.Sprintf("%s (HTTP proxy)", label), - Hint: fmt.Sprintf("%s\n%s", hint, "HTTP proxy (hostname is passed via URL)"), - Request: baseReq. - URL(baseURL). - Proxy(GatewayURL), - Response: expected, - }, - { - Name: fmt.Sprintf("%s (HTTP proxy tunneling via CONNECT)", label), - Hint: fmt.Sprintf("%s\n%s", hint, `HTTP proxy - In HTTP/1.x, the pseudo-method CONNECT, - can be used to convert an HTTP connection into a tunnel to a remote host - https://tools.ietf.org/html/rfc7231#section-4.3.6 - `), - Request: baseReq. - URL(baseURL). - Proxy(GatewayURL). - WithProxyTunnel(). - Headers( - Header("Host", host), - ), - Response: expected, - }, + + for _, gatewayURL := range gatewayURLs { + u, err := url.Parse(gatewayURL) + if err != nil { + t.Fatal(err) + } + + for _, record := range ipnsRecords { + tests = append(tests, SugarTests{ + // # /ipns/ + // test_localhost_gateway_response_should_contain \ + // "request for localhost/ipns/{CIDv0} redirects to CIDv1 with libp2p-key multicodec in subdomain" \ + // "http://localhost:$GWAY_PORT/ipns/$RSA_IPNS_IDv0" \ + // "Location: http://${RSA_IPNS_IDv1}.ipns.localhost:$GWAY_PORT/" + // test_localhost_gateway_response_should_contain \ + // "request for localhost/ipns/{CIDv0} redirects to CIDv1 with libp2p-key multicodec in subdomain" \ + // "http://localhost:$GWAY_PORT/ipns/$ED25519_IPNS_IDv0" \ + // "Location: http://${ED25519_IPNS_IDv1}.ipns.localhost:$GWAY_PORT/" + { + Name: "request for /ipns/{CIDv0} redirects to CIDv1 with libp2p-key multicodec in subdomain", + Request: Request(). + URL("{{url}}/ipns/{{cid}}", gatewayURL, record.IdV0()), + Response: Expect(). + Status(301). + Headers( + Header("Location"). + Equals("{{scheme}}://{{cid}}.ipns.{{host}}/", u.Scheme, record.IdV1(), u.Host), + ), + }, + // # *.ipns.localhost + // # .ipns.localhost + // test_localhost_gateway_response_should_contain \ + // "request for {CIDv1-libp2p-key}.ipns.localhost returns expected payload" \ + // "http://${RSA_IPNS_IDv1}.ipns.localhost:$GWAY_PORT" \ + // "$CID_VAL" + // test_localhost_gateway_response_should_contain \ + // "request for {CIDv1-libp2p-key}.ipns.localhost returns expected payload" \ + // "http://${ED25519_IPNS_IDv1}.ipns.localhost:$GWAY_PORT" \ + // "$CID_VAL" + { + Name: "request for {CIDv1-libp2p-key}.ipns.{gateway} returns expected payload", + Request: Request(). + URL("{{scheme}}://{{cid}}.ipns.{{host}}/", u.Scheme, record.IdV1(), u.Host), + Response: Expect(). + Status(200). + BodyWithHint("Request for {{cid}}.ipns.{{host}} returns expected payload", payload), + }, + // test_localhost_gateway_response_should_contain \ + // "localhost request for {CIDv1-dag-pb}.ipns.localhost redirects to CID with libp2p-key multicodec" \ + // "http://${RSA_IPNS_IDv1_DAGPB}.ipns.localhost:$GWAY_PORT" \ + // "Location: http://${RSA_IPNS_IDv1}.ipns.localhost:$GWAY_PORT/" + // test_localhost_gateway_response_should_contain \ + // "localhost request for {CIDv1-dag-pb}.ipns.localhost redirects to CID with libp2p-key multicodec" \ + // "http://${ED25519_IPNS_IDv1_DAGPB}.ipns.localhost:$GWAY_PORT" \ + // "Location: http://${ED25519_IPNS_IDv1}.ipns.localhost:$GWAY_PORT/" + { + Name: "request for {CIDv1-dag-pb}.ipns.{gateway} redirects to CID with libp2p-key multicodec", + Request: Request(). + URL("{{scheme}}://{{cid}}.ipns.{{host}}/", u.Scheme, record.ToCID(multicodec.DagPb, multibase.Base36), u.Host), + Response: Expect(). + Status(301). + Headers( + Header("Location"). + Equals("{{scheme}}://{{cid}}.ipns.{{host}}/", u.Scheme, record.IdV1(), u.Host), + ), + }, + // # example.com/ipns/ + // test_hostname_gateway_response_should_contain \ + // "request for example.com/ipns/{CIDv0} redirects to CIDv1 with libp2p-key multicodec in subdomain" \ + // "example.com" \ + // "http://127.0.0.1:$GWAY_PORT/ipns/$RSA_IPNS_IDv0" \ + // "Location: http://${RSA_IPNS_IDv1}.ipns.example.com/" + // test_hostname_gateway_response_should_contain \ + // "request for example.com/ipns/{CIDv0} redirects to CIDv1 with libp2p-key multicodec in subdomain" \ + // "example.com" \ + // "http://127.0.0.1:$GWAY_PORT/ipns/$ED25519_IPNS_IDv0" \ + // "Location: http://${ED25519_IPNS_IDv1}.ipns.example.com/" + // NOTE: Done above, thanks to the loop + // + // # *.ipns.example.com + // # ============================================================================ + + // # .ipns.example.com + + // test_hostname_gateway_response_should_contain \ + // "request for {CIDv1-libp2p-key}.ipns.example.com returns expected payload" \ + // "${RSA_IPNS_IDv1}.ipns.example.com" \ + // "http://127.0.0.1:$GWAY_PORT" \ + // "$CID_VAL" + + // test_hostname_gateway_response_should_contain \ + // "request for {CIDv1-libp2p-key}.ipns.example.com returns expected payload" \ + // "${ED25519_IPNS_IDv1}.ipns.example.com" \ + // "http://127.0.0.1:$GWAY_PORT" \ + // "$CID_VAL" + + // test_hostname_gateway_response_should_contain \ + // "hostname request for {CIDv1-dag-pb}.ipns.localhost redirects to CID with libp2p-key multicodec" \ + // "${RSA_IPNS_IDv1_DAGPB}.ipns.example.com" \ + // "http://127.0.0.1:$GWAY_PORT" \ + // "Location: http://${RSA_IPNS_IDv1}.ipns.example.com/" + + // test_hostname_gateway_response_should_contain \ + // "hostname request for {CIDv1-dag-pb}.ipns.localhost redirects to CID with libp2p-key multicodec" \ + // "${ED25519_IPNS_IDv1_DAGPB}.ipns.example.com" \ + // "http://127.0.0.1:$GWAY_PORT" \ + // "Location: http://${ED25519_IPNS_IDv1}.ipns.example.com/" + // # disable /ipns for the hostname by not whitelisting it + // ipfs config --json Gateway.PublicGateways '{ + // "example.com": { + // "UseSubdomains": true, + // "Paths": ["/ipfs"] + // } + // }' || exit 1 + // # restart daemon to apply config changes + // test_kill_ipfs_daemon + // test_launch_ipfs_daemon_without_network + + // TODO: what to do with these? + // # refuse requests to Paths that were not explicitly whitelisted for the hostname + // test_hostname_gateway_response_should_contain \ + // "request for *.ipns.example.com returns HTTP 404 Not Found when /ipns is not on Paths whitelist" \ + // "${RSA_IPNS_IDv1}.ipns.example.com" \ + // "http://127.0.0.1:$GWAY_PORT" \ + // "404 Not Found" + + // test_hostname_gateway_response_should_contain \ + // "request for *.ipns.example.com returns HTTP 404 Not Found when /ipns is not on Paths whitelist" \ + // "${ED25519_IPNS_IDv1}.ipns.example.com" \ + // "http://127.0.0.1:$GWAY_PORT" \ + // "404 Not Found" + + // # refuse requests to Paths that were not explicitly whitelisted for the hostname + // test_hostname_gateway_response_should_contain \ + // "request for example.com/ipns/ returns HTTP 404 Not Found when /ipns is not on Paths whitelist" \ + // "example.com" \ + // "http://127.0.0.1:$GWAY_PORT/ipns/$RSA_IPNS_IDv1" \ + // "404 Not Found" + + // test_hostname_gateway_response_should_contain \ + // "request for example.com/ipns/ returns HTTP 404 Not Found when /ipns is not on Paths whitelist" \ + // "example.com" \ + // "http://127.0.0.1:$GWAY_PORT/ipns/$ED25519_IPNS_IDv1" \ + // "404 Not Found" + }...) + } + + tests = append(tests, SugarTests{ + // ## Test subdomain handling of CIDs that do not fit in a single DNS Label (>63chars) + // ## https://github.com/ipfs/go-ipfs/issues/7318 + // ## ============================================================================ + // # local: *.localhost + // test_localhost_gateway_response_should_contain \ + // "request for a ED25519 libp2p-key at localhost/ipns/{b58mh} returns Location HTTP header for DNS-safe subdomain redirect in browsers" \ + // "http://localhost:$GWAY_PORT/ipns/$IPNS_ED25519_B58MH" \ + // "Location: http://${IPNS_ED25519_B36CID}.ipns.localhost:$GWAY_PORT/" + // # public subdomain gateway: *.example.com + // test_hostname_gateway_response_should_contain \ + // "request for a ED25519 libp2p-key at example.com/ipns/{b58mh} returns Location HTTP header for DNS-safe subdomain redirect in browsers" \ + // "example.com" \ + // "http://127.0.0.1:$GWAY_PORT/ipns/$IPNS_ED25519_B58MH" \ + // "Location: http://${IPNS_ED25519_B36CID}.ipns.example.com" + { + Name: "request for a ED25519 libp2p-key at example.com/ipns/{b58mh} returns Location HTTP header for DNS-safe subdomain redirect in browsers", + Request: Request(). + URL("{{url}}/ipns/{{cid}}", gatewayURL, ed25519Fixture.B58MH()), + Response: Expect(). + Headers( + Header("Location"). + Equals("{{scheme}}://{{cid}}.ipns.{{host}}/", u.Scheme, ed25519Fixture.ToCID(multicodec.Libp2pKey, multibase.Base36), u.Host), + ), + }, + }...) + + } + + RunIfSpecsAreEnabled(t, helpers.UnwrapSubdomainTests(t, tests), specs.SubdomainGateway, specs.IPNSResolver) +} + +func TestGatewaySubdomainAndDnsLink(t *testing.T) { + tests := SugarTests{} + + // We're going to run the same test against multiple gateways (localhost, and a subdomain gateway) + gatewayURLs := []string{ + SubdomainGatewayURL, + SubdomainLocalhostGatewayURL, + } + + dnsLinks := dnslink.MustOpenDNSLink("t0114/dnslink.yml") + wikipedia := dnsLinks.MustGet("wikipedia") + dnsLinkTest := dnsLinks.MustGet("test") + + for _, gatewayURL := range gatewayURLs { + u, err := url.Parse(gatewayURL) + if err != nil { + t.Fatal(err) + } + + tests = append(tests, SugarTests{ + // # /ipns/ + // test_localhost_gateway_response_should_contain \ + // "request for localhost/ipns/{fqdn} redirects to DNSLink in subdomain" \ + // "http://localhost:$GWAY_PORT/ipns/en.wikipedia-on-ipfs.org/wiki" \ + // "Location: http://en.wikipedia-on-ipfs.org.ipns.localhost:$GWAY_PORT/wiki" + { + Name: "request for /ipns/{fqdn} redirects to DNSLink in subdomain", + Request: Request(). + URL("{{url}}/ipns/{{fqdn}}/wiki/", gatewayURL, wikipedia), + Response: Expect(). + Headers( + Header("Location"). + Equals("{{scheme}}://{{fqdn}}.ipns.{{host}}/wiki/", u.Scheme, dnslink.InlineDNS(wikipedia), u.Host), + ), + }, + // # .ipns.localhost + // # DNSLink test requires a daemon in online mode with precached /ipns/ mapping + // test_kill_ipfs_daemon + // DNSLINK_FQDN="dnslink-test.example.com" + // export IPFS_NS_MAP="$DNSLINK_FQDN:/ipfs/$CIDv1" + // test_launch_ipfs_daemon + // test_localhost_gateway_response_should_contain \ + // "request for {dnslink}.ipns.localhost returns expected payload" \ + // "http://$DNSLINK_FQDN.ipns.localhost:$GWAY_PORT" \ + // "$CID_VAL" + { + Name: "request for {dnslink}.ipns.{gateway} returns expected payload", + Request: Request(). + URL("{{scheme}}://{{fqdn}}.ipns.{{host}}", u.Scheme, dnsLinkTest, u.Host), + Response: Expect(). + Body("hello\n"), + }, + // ## ============================================================================ + // ## Test DNSLink inlining on HTTP gateways + // ## ============================================================================ + // # set explicit subdomain gateway config for the hostname + // ipfs config --json Gateway.PublicGateways '{ + // "localhost": { + // "UseSubdomains": true, + // "InlineDNSLink": true, + // "Paths": ["/ipfs", "/ipns", "/api"] + // }, + // "example.com": { + // "UseSubdomains": true, + // "InlineDNSLink": true, + // "Paths": ["/ipfs", "/ipns", "/api"] + // } + // }' || exit 1 + // # restart daemon to apply config changes + // test_kill_ipfs_daemon + // test_launch_ipfs_daemon_without_network + + // test_localhost_gateway_response_should_contain \ + // "request for localhost/ipns/{fqdn} redirects to DNSLink in subdomain with DNS inlining" \ + // "http://localhost:$GWAY_PORT/ipns/en.wikipedia-on-ipfs.org/wiki" \ + // "Location: http://en-wikipedia--on--ipfs-org.ipns.localhost:$GWAY_PORT/wiki" + + // test_hostname_gateway_response_should_contain \ + // "request for example.com/ipns/{fqdn} redirects to DNSLink in subdomain with DNS inlining" \ + // "example.com" \ + // "http://127.0.0.1:$GWAY_PORT/ipns/en.wikipedia-on-ipfs.org/wiki" \ + // "Location: http://en-wikipedia--on--ipfs-org.ipns.example.com/wiki" + + // # example.com/ipns/ + + // test_hostname_gateway_response_should_contain \ + // "request for example.com/ipns/{fqdn} redirects to DNSLink in subdomain" \ + // "example.com" \ + // "http://127.0.0.1:$GWAY_PORT/ipns/en.wikipedia-on-ipfs.org/wiki" \ + // "Location: http://en.wikipedia-on-ipfs.org.ipns.example.com/wiki" + + // # DNSLink on Public gateway with a single-level wildcard TLS cert + // # "Option C" from https://github.com/ipfs/in-web-browsers/issues/169 + // test_expect_success \ + // "request for example.com/ipns/{fqdn} with X-Forwarded-Proto redirects to TLS-safe label in subdomain" " + // curl -H \"Host: example.com\" -H \"X-Forwarded-Proto: https\" -sD - \"http://127.0.0.1:$GWAY_PORT/ipns/en.wikipedia-on-ipfs.org/wiki\" > response && + // test_should_contain \"Location: https://en-wikipedia--on--ipfs-org.ipns.example.com/wiki\" response + // " + { + Name: "request for example.com/ipns/{fqdn} with X-Forwarded-Proto redirects to TLS-safe label in subdomain", + Hint: ` + DNSLink on Public gateway with a single-level wildcard TLS cert + "Option C" from https://github.com/ipfs/in-web-browsers/issues/169 + `, + Request: Request(). + Header("X-Forwarded-Proto", "https"). + URL("{{url}}/ipns/{{wikipedia}}/wiki/", gatewayURL, wikipedia), + Response: Expect(). + Headers( + Header("Location"). + Equals("https://{{inlined}}.ipns.{{host}}/wiki/", dnslink.InlineDNS(wikipedia), u.Host), + ), + }, + // # Support ipns:// in https://developer.mozilla.org/en-US/docs/Web/API/Navigator/registerProtocolHandler + // test_hostname_gateway_response_should_contain \ + // "request for example.com/ipns/?uri=ipns%3A%2F%2F.. produces redirect to /ipns/.. content path" \ + // "example.com" \ + // "http://127.0.0.1:$GWAY_PORT/ipns/?uri=ipns%3A%2F%2Fen.wikipedia-on-ipfs.org" \ + // "Location: /ipns/en.wikipedia-on-ipfs.org" + { + Name: `request for example.com/ipns/?uri=ipns%3A%2F%2F.. produces redirect to /ipns/.. content path`, + Hint: "Support ipns:// in https://developer.mozilla.org/en-US/docs/Web/API/Navigator/registerProtocolHandler", + Request: Request(). + URL(`{{url}}/ipns/?uri=ipns%3A%2F%2F{{dnslink}}`, gatewayURL, wikipedia), + Response: Expect(). + Headers( + Header("Location").Equals("/ipns/{{wikipedia}}", wikipedia), + ), + }, + // # DNSLink: .ipns.example.com + // # (not really useful outside of localhost, as setting TLS for more than one + // # level of wildcard is a pain, but we support it if someone really wants it) + // # ============================================================================ + + // # DNSLink test requires a daemon in online mode with precached /ipns/ mapping + // test_kill_ipfs_daemon + // DNSLINK_FQDN="dnslink-subdomain-gw-test.example.org" + // export IPFS_NS_MAP="$DNSLINK_FQDN:/ipfs/$CIDv1" + // test_launch_ipfs_daemon + + // test_hostname_gateway_response_should_contain \ + // "request for {dnslink}.ipns.example.com returns expected payload" \ + // "$DNSLINK_FQDN.ipns.example.com" \ + // "http://127.0.0.1:$GWAY_PORT" \ + // "$CID_VAL" + // Note: this test was merged with the test for wikipedia in the end. + + // # DNSLink on Public gateway with a single-level wildcard TLS cert + // # "Option C" from https://github.com/ipfs/in-web-browsers/issues/169 + // test_expect_success \ + // "request for {single-label-dnslink}.ipns.example.com with X-Forwarded-Proto returns expected payload" " + // curl -H \"Host: dnslink--subdomain--gw--test-example-org.ipns.example.com\" -H \"X-Forwarded-Proto: https\" -sD - \"http://127.0.0.1:$GWAY_PORT\" > response && + // test_should_contain \"$CID_VAL\" response + // " + // Note: this test was merged with the test for wikipedia in the end. + + // ## ============================================================================ + // ## Test DNSLink requests with a custom PublicGateway (hostname config) + // ## (DNSLink site at http://dnslink-test.example.com) + // ## ============================================================================ + // # disable wildcard DNSLink gateway + // # and enable it on specific NSLink hostname + // ipfs config --json Gateway.NoDNSLink true && \ + // ipfs config --json Gateway.PublicGateways '{ + // "dnslink-enabled-on-fqdn.example.org": { + // "NoDNSLink": false, + // "UseSubdomains": false, + // "Paths": ["/ipfs"] + // }, + // "only-dnslink-enabled-on-fqdn.example.org": { + // "NoDNSLink": false, + // "UseSubdomains": false, + // "Paths": [] + // }, + // "dnslink-disabled-on-fqdn.example.com": { + // "NoDNSLink": true, + // "UseSubdomains": false, + // "Paths": [] + // } + // }' || exit 1 + + // # DNSLink test requires a daemon in online mode with precached /ipns/ mapping + // DNSLINK_FQDN="dnslink-enabled-on-fqdn.example.org" + // ONLY_DNSLINK_FQDN="only-dnslink-enabled-on-fqdn.example.org" + // NO_DNSLINK_FQDN="dnslink-disabled-on-fqdn.example.com" + // export IPFS_NS_MAP="$DNSLINK_FQDN:/ipfs/$CIDv1,$ONLY_DNSLINK_FQDN:/ipfs/$DIR_CID" + + // # DNSLink enabled + + // test_hostname_gateway_response_should_contain \ + // "request for http://{dnslink-fqdn}/ PublicGateway returns expected payload" \ + // "$DNSLINK_FQDN" \ + // "http://127.0.0.1:$GWAY_PORT/" \ + // "$CID_VAL" + + // test_hostname_gateway_response_should_contain \ + // "request for {dnslink-fqdn}/ipfs/{cid} returns expected payload when /ipfs is on Paths whitelist" \ + // "$DNSLINK_FQDN" \ + // "http://127.0.0.1:$GWAY_PORT/ipfs/$CIDv1" \ + // "$CID_VAL" + + // # Test for a fun edge case: DNSLink-only gateway without /ipfs/ namespace + // # mounted, and with subdirectory named "ipfs" ¯\_(ツ)_/¯ + // test_hostname_gateway_response_should_contain \ + // "request for {dnslink-fqdn}/ipfs/file.txt returns data from content root when /ipfs in not on Paths whitelist" \ + // "$ONLY_DNSLINK_FQDN" \ + // "http://127.0.0.1:$GWAY_PORT/ipfs/file.txt" \ + // "I am a txt file" + + // test_hostname_gateway_response_should_contain \ + // "request for {dnslink-fqdn}/ipns/{peerid} returns 404 when path is not whitelisted" \ + // "$DNSLINK_FQDN" \ + // "http://127.0.0.1:$GWAY_PORT/ipns/$RSA_IPNS_IDv0" \ + // "404 Not Found" + + // test_hostname_gateway_response_should_contain \ + // "request for {dnslink-fqdn}/ipns/{peerid} returns 404 when path is not whitelisted" \ + // "$DNSLINK_FQDN" \ + // "http://127.0.0.1:$GWAY_PORT/ipns/$ED25519_IPNS_IDv0" \ + // "404 Not Found" + + // # DNSLink disabled + + // test_hostname_gateway_response_should_contain \ + // "request for http://{dnslink-fqdn}/ returns 404 when NoDNSLink=true" \ + // "$NO_DNSLINK_FQDN" \ + // "http://127.0.0.1:$GWAY_PORT/" \ + // "404 Not Found" + + // test_hostname_gateway_response_should_contain \ + // "request for {dnslink-fqdn}/ipfs/{cid} returns 404 when path is not whitelisted" \ + // "$NO_DNSLINK_FQDN" \ + // "http://127.0.0.1:$GWAY_PORT/ipfs/$CIDv0" \ + // "404 Not Found" + + // ## ============================================================================ + // ## Test wildcard DNSLink (any hostname, with default config) + // ## ============================================================================ + + // test_kill_ipfs_daemon + + // # enable wildcard DNSLink gateway (any value in Host header) + // # and remove custom PublicGateways + // ipfs config --json Gateway.NoDNSLink false && \ + // ipfs config --json Gateway.PublicGateways '{}' || exit 1 + + // # DNSLink test requires a daemon in online mode with precached /ipns/ mapping + // DNSLINK_FQDN="wildcard-dnslink-not-in-config.example.com" + // export IPFS_NS_MAP="$DNSLINK_FQDN:/ipfs/$CIDv1" + + // # restart daemon to apply config changes + // test_launch_ipfs_daemon + + // # make sure test setup is valid (fail if CoreAPI is unable to resolve) + // test_expect_success "spoofed DNSLink record resolves in cli" " + // ipfs resolve /ipns/$DNSLINK_FQDN > result && + // test_should_contain \"$CIDv1\" result && + // ipfs cat /ipns/$DNSLINK_FQDN > result && + // test_should_contain \"$CID_VAL\" result + // " + + // # gateway test + // + // test_hostname_gateway_response_should_contain \ + // "request for http://{dnslink-fqdn}/ (wildcard) returns expected payload" \ + // "$DNSLINK_FQDN" \ + // "http://127.0.0.1:$GWAY_PORT/" \ + // "$CID_VAL" + }...) } + + RunIfSpecsAreEnabled(t, helpers.UnwrapSubdomainTests(t, tests), specs.SubdomainGateway, specs.DNSLinkResolver) } diff --git a/tooling/dnslink/dnslink.go b/tooling/dnslink/dnslink.go index 2a8778ab9..481eaa872 100644 --- a/tooling/dnslink/dnslink.go +++ b/tooling/dnslink/dnslink.go @@ -4,27 +4,36 @@ import ( "fmt" "os" "path" + "strings" "github.com/ipfs/gateway-conformance/tooling/fixtures" "gopkg.in/yaml.v3" ) -type DNSLinks struct { +type ConfigFixture struct { DNSLinks map[string]DNSLink `yaml:"dnslinks"` } type DNSLink struct { Subdomain string `yaml:"subdomain"` + Domain string `yaml:"domain"` Path string `yaml:"path"` } -func OpenDNSLink(absPath string) (*DNSLinks, error) { +func InlineDNS(s string) string { + // See spec at https://github.com/ipfs/specs/blob/main/src/http-gateways/subdomain-gateway.md#host-request-header + // Every - is replaced with -- + // Every . is replaced with - + return strings.ReplaceAll(strings.ReplaceAll(s, "-", "--"), ".", "-") +} + +func OpenDNSLink(absPath string) (*ConfigFixture, error) { data, err := os.ReadFile(absPath) if err != nil { return nil, err } - var dnsLinks DNSLinks + var dnsLinks ConfigFixture err = yaml.Unmarshal(data, &dnsLinks) if err != nil { return nil, err @@ -33,7 +42,7 @@ func OpenDNSLink(absPath string) (*DNSLinks, error) { return &dnsLinks, nil } -func MustOpenDNSLink(file string) *DNSLinks { +func MustOpenDNSLink(file string) *ConfigFixture { fixturePath := path.Join(fixtures.Dir(), file) dnsLinks, err := OpenDNSLink(fixturePath) if err != nil { @@ -43,10 +52,24 @@ func MustOpenDNSLink(file string) *DNSLinks { return dnsLinks } -func (d *DNSLinks) MustGet(id string) string { +func (d *ConfigFixture) MustGet(id string) string { dnsLink, ok := d.DNSLinks[id] if !ok { panic(fmt.Errorf("dnslink %s not found", id)) } + if dnsLink.Domain != "" && dnsLink.Subdomain != "" { + panic(fmt.Errorf("dnslink %s has both domain and subdomain", id)) + } + if dnsLink.Domain == "" && dnsLink.Subdomain == "" { + panic(fmt.Errorf("dnslink %s has neither domain nor subdomain", id)) + } + if dnsLink.Path == "" { + panic(fmt.Errorf("dnslink %s has no path", id)) + } + + if dnsLink.Domain != "" { + return dnsLink.Domain + } + return dnsLink.Subdomain } diff --git a/tooling/dnslink/merge.go b/tooling/dnslink/merge.go index e1fb70b6c..446af9b19 100644 --- a/tooling/dnslink/merge.go +++ b/tooling/dnslink/merge.go @@ -6,8 +6,16 @@ import ( "os" ) -func Aggregate(inputPaths []string) (map[string]string, error) { - aggMap := make(map[string]string) +type DNSLinksAggregate struct { + Domains map[string]string `json:"domains"` + Subdomains map[string]string `json:"subdomains"` +} + +func Aggregate(inputPaths []string) (*DNSLinksAggregate, error) { + agg := DNSLinksAggregate{ + Domains: make(map[string]string), + Subdomains: make(map[string]string), + } for _, file := range inputPaths { dnsLinks, err := OpenDNSLink(file) @@ -16,15 +24,31 @@ func Aggregate(inputPaths []string) (map[string]string, error) { } for _, link := range dnsLinks.DNSLinks { - if _, ok := aggMap[link.Subdomain]; ok { - return nil, fmt.Errorf("collision detected for subdomain %s", link.Subdomain) + if link.Domain != "" && link.Subdomain != "" { + return nil, fmt.Errorf("dnslink %s has both domain and subdomain", link.Subdomain) + } + + if link.Domain != "" { + if _, ok := agg.Domains[link.Domain]; ok { + return nil, fmt.Errorf("collision detected for domain %s", link.Domain) + } + + agg.Domains[link.Domain] = link.Path + continue } - aggMap[link.Subdomain] = link.Path + if link.Subdomain != "" { + if _, ok := agg.Subdomains[link.Subdomain]; ok { + return nil, fmt.Errorf("collision detected for subdomain %s", link.Subdomain) + } + + agg.Subdomains[link.Subdomain] = link.Path + continue + } } } - return aggMap, nil + return &agg, nil } func Merge(inputPaths []string, outputPath string) error { diff --git a/tooling/ipns/_fixtures/12D3KooWLQzUv2FHWGVPXTXSZpdHs7oHbXub2G5WC8Tx4NQhyd2d.ipns-record b/tooling/ipns/_fixtures/12D3KooWLQzUv2FHWGVPXTXSZpdHs7oHbXub2G5WC8Tx4NQhyd2d.ipns-record new file mode 100644 index 000000000..39b2f41a4 Binary files /dev/null and b/tooling/ipns/_fixtures/12D3KooWLQzUv2FHWGVPXTXSZpdHs7oHbXub2G5WC8Tx4NQhyd2d.ipns-record differ diff --git a/tooling/ipns/_fixtures/QmVujd5Vb7moysJj8itnGufN7MEtPRCNHkKpNuA4onsRa3.ipns-record b/tooling/ipns/_fixtures/QmVujd5Vb7moysJj8itnGufN7MEtPRCNHkKpNuA4onsRa3.ipns-record new file mode 100644 index 000000000..b37d9b75b Binary files /dev/null and b/tooling/ipns/_fixtures/QmVujd5Vb7moysJj8itnGufN7MEtPRCNHkKpNuA4onsRa3.ipns-record differ diff --git a/tooling/ipns/ipns_test.go b/tooling/ipns/ipns_test.go index c1b33f73a..f84265d8b 100644 --- a/tooling/ipns/ipns_test.go +++ b/tooling/ipns/ipns_test.go @@ -4,6 +4,8 @@ import ( "testing" "time" + mbase "github.com/multiformats/go-multibase" + "github.com/multiformats/go-multicodec" "github.com/stretchr/testify/assert" ) @@ -62,3 +64,30 @@ func TestLoadTestRecord(t *testing.T) { err = ipns.Valid() assert.NoError(t, err) } + +func TestIPNSFixtureVersionsConversion(t *testing.T) { + path := "./_fixtures/12D3KooWLQzUv2FHWGVPXTXSZpdHs7oHbXub2G5WC8Tx4NQhyd2d.ipns-record" + record, err := OpenIPNSRecordWithKey(path) + + assert.Nil(t, err) + + // 12D3KooWLQzUv2FHWGVPXTXSZpdHs7oHbXub2G5WC8Tx4NQhyd2d is a ED25519 key, which is using the identity hash. + assert.Equal(t, "12D3KooWLQzUv2FHWGVPXTXSZpdHs7oHbXub2G5WC8Tx4NQhyd2d", record.Key()) + assert.Equal(t, "12D3KooWLQzUv2FHWGVPXTXSZpdHs7oHbXub2G5WC8Tx4NQhyd2d", record.IdV0()) + assert.Equal(t, "k51qzi5uqu5dk3v4rmjber23h16xnr23bsggmqqil9z2gduiis5se8dht36dam", record.IdV1()) + assert.Equal(t, "k50rm9yjlt0jey4fqg6wafvqprktgbkpgkqdg27tpqje6iimzxewnhvtin9hhq", record.ToCID(multicodec.DagPb, mbase.Base36)) + assert.Equal(t, "12D3KooWLQzUv2FHWGVPXTXSZpdHs7oHbXub2G5WC8Tx4NQhyd2d", record.B58MH()) + assert.Equal(t, "k51qzi5uqu5dk3v4rmjber23h16xnr23bsggmqqil9z2gduiis5se8dht36dam", record.ToCID(multicodec.Libp2pKey, mbase.Base36)) + + path = "./_fixtures/QmVujd5Vb7moysJj8itnGufN7MEtPRCNHkKpNuA4onsRa3.ipns-record" + record, err = OpenIPNSRecordWithKey(path) + + assert.Nil(t, err) + + // QmVujd5Vb7moysJj8itnGufN7MEtPRCNHkKpNuA4onsRa3 is a RSA key, which is using sha256 hash. + assert.Equal(t, "QmVujd5Vb7moysJj8itnGufN7MEtPRCNHkKpNuA4onsRa3", record.Key()) + assert.Equal(t, "QmVujd5Vb7moysJj8itnGufN7MEtPRCNHkKpNuA4onsRa3", record.IdV0()) + assert.Equal(t, "k2k4r8m7xvggw5pxxk3abrkwyer625hg01hfyggrai7lk1m63fuihi7w", record.IdV1()) + assert.Equal(t, "k2jmtxu61bnhrtj301lw7zizknztocdbeqhxgv76l2q9t36fn9jbzipo", record.ToCID(multicodec.DagPb, mbase.Base36)) + assert.Equal(t, "QmVujd5Vb7moysJj8itnGufN7MEtPRCNHkKpNuA4onsRa3", record.B58MH()) +} diff --git a/tooling/ipns/record.go b/tooling/ipns/record.go index 4132711ab..31ed5f059 100644 --- a/tooling/ipns/record.go +++ b/tooling/ipns/record.go @@ -1,16 +1,21 @@ package ipns import ( + "strings" "time" "github.com/ipfs/boxo/ipns" ipns_pb "github.com/ipfs/boxo/ipns/pb" + "github.com/ipfs/go-cid" "github.com/libp2p/go-libp2p/core/peer" + mbase "github.com/multiformats/go-multibase" + "github.com/multiformats/go-multicodec" ) type IpnsRecord struct { pb *ipns_pb.IpnsEntry key string + id peer.ID validity time.Time } @@ -25,7 +30,12 @@ func UnmarshalIpnsRecord(data []byte, pubKey string) (*IpnsRecord, error) { return nil, err } - return &IpnsRecord{pb: pb, key: pubKey, validity: validity}, nil + id, err := peer.Decode(pubKey) + if err != nil { + return nil, err + } + + return &IpnsRecord{pb: pb, key: pubKey, id: id, validity: validity}, nil } func (i *IpnsRecord) Value() string { @@ -41,10 +51,39 @@ func (i *IpnsRecord) Validity() time.Time { } func (i *IpnsRecord) Valid() error { - id, err := peer.Decode(i.key) + return ipns.ValidateWithPeerID(i.id, i.pb) +} + +func (i *IpnsRecord) idV1(codec multicodec.Code, base mbase.Encoding) (string, error) { + c := peer.ToCid(i.id) + c = cid.NewCidV1(uint64(codec), c.Hash()) + s, err := c.StringOfBase(base) if err != nil { - return err + return "", err } + return s, nil +} + +func (i *IpnsRecord) ToCID(codec multicodec.Code, base mbase.Encoding) string { + s, err := i.idV1(codec, base) + if err != nil { + panic(err) + } + return s +} + +func (i *IpnsRecord) IdV0() string { + if strings.HasPrefix(i.key, "Qm") || strings.HasPrefix(i.key, "1") { + return i.key + } + + panic("not a v0 id") +} + +func (i *IpnsRecord) IdV1() string { + return i.ToCID(cid.Libp2pKey, mbase.Base36) +} - return ipns.ValidateWithPeerID(id, i.pb) +func (i *IpnsRecord) B58MH() string { + return i.id.String() }