Compare commits
133 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
513e79832a | ||
|
bd538abd37 | ||
|
c286b3b1d0 | ||
|
f7fad2a5ae | ||
|
98d198d419 | ||
|
9d769aeee7 | ||
|
dcf03fc078 | ||
|
5e72753e91 | ||
|
caeb1a4acb | ||
|
f2f943c0d8 | ||
|
3c61a39864 | ||
|
4565481643 | ||
|
c827a28dd8 | ||
|
aa90356f0a | ||
|
b6d0a04b21 | ||
|
6c63b66ce4 | ||
|
b9966487f6 | ||
|
69eabb248a | ||
|
8e67d28c4f | ||
|
91b54bef29 | ||
|
bf9a08e1fd | ||
|
b9e9f14209 | ||
|
df2228b6d5 | ||
|
091e6c8ed9 | ||
|
2a730b2439 | ||
|
8f2699407d | ||
|
1ae50735a1 | ||
|
392c6ae452 | ||
|
88a217fbe6 | ||
|
dc41a4caf4 | ||
|
519259f459 | ||
|
f72bbfd85f | ||
|
876a53d9a2 | ||
|
e06900d5e5 | ||
|
00e8a41c89 | ||
|
8207586a48 | ||
|
bcaceda711 | ||
|
5411c96ef3 | ||
|
baf4e7e326 | ||
|
fd24b4a2bc | ||
|
9076bc3f75 | ||
|
48a49f69a7 | ||
|
6dedd55eb3 | ||
|
4c6164ef05 | ||
|
cc32bab31f | ||
|
913f762eb0 | ||
|
38fb28f84f | ||
|
35b35c5d67 | ||
|
02bd942b04 | ||
|
659932521c | ||
|
bb8eb32ee2 | ||
|
f2ba7eac64 | ||
|
57076a47d3 | ||
|
6f12f2a8e4 | ||
|
b2ca888050 | ||
|
2dbc66d052 | ||
|
1724d9fb2e | ||
|
4267d54a63 | ||
|
a2c5376d9a | ||
|
1e4dfe2ae8 | ||
|
f5d0dc7447 | ||
|
a5504acb0e | ||
|
f5e613bfdb | ||
|
cf9e6d9dc6 | ||
|
ac5b19123d | ||
|
4404287958 | ||
|
e73c79da77 | ||
|
adfc96ab94 | ||
|
73fa2da646 | ||
|
6af6523a0f | ||
|
70c7065f76 | ||
|
aa0638903a | ||
|
196482da07 | ||
|
5aae7c882f | ||
|
67a190f68a | ||
|
a7bb3448a4 | ||
|
2f6b280fce | ||
|
5fe51d8621 | ||
|
a0534f1fde | ||
|
26a199053b | ||
|
0374e95d23 | ||
|
e85f21ed2e | ||
|
a0e0d2d335 | ||
|
de439f9bec | ||
|
11fa729686 | ||
|
77e39b2213 | ||
|
e6198e4ddd | ||
|
de4706bf58 | ||
|
76c867cfca | ||
|
b6c4c63fb4 | ||
|
ccada3e6df | ||
|
bb6f28fe57 | ||
|
b3830e979c | ||
|
2b49039252 | ||
|
b28204a468 | ||
|
97d4ea9d6b | ||
|
fdd04610e5 | ||
|
5b2e91a37a | ||
|
38426c26db | ||
|
f35c4d0f66 | ||
|
0bc38b668f | ||
|
5ca5020cfa | ||
|
796f24262e | ||
|
690879440a | ||
|
5b81a8b8bc | ||
|
35e08d2252 | ||
|
ac93a5661c | ||
|
bdc2d0c259 | ||
|
fb5726bd20 | ||
|
76e5d8e77c | ||
|
2e970dbcda | ||
|
51c79f512d | ||
|
38938e884d | ||
|
57dce3b0c5 | ||
|
026a04e57e | ||
|
b6d7f5a6ee | ||
|
726d8321e8 | ||
|
989d00832f | ||
|
26dd1591f6 | ||
|
a48ba8ee49 | ||
|
fedfa8def4 | ||
|
6c85b8a166 | ||
|
544b3f7321 | ||
|
f29ebc57d3 | ||
|
5b6e3748b4 | ||
|
6d520c2a40 | ||
|
73da80adc1 | ||
|
455f65216c | ||
|
772c17e214 | ||
|
418dbb7315 | ||
|
a6da3eb5f0 | ||
|
f2bb6aa36f | ||
|
e800d2110e |
9
.ecrc
Normal file
9
.ecrc
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Exclude": [
|
||||||
|
".git",
|
||||||
|
"go.mod", "go.sum",
|
||||||
|
"vendor",
|
||||||
|
"LICENSE",
|
||||||
|
"_test.go"
|
||||||
|
]
|
||||||
|
}
|
17
.editorconfig
Normal file
17
.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
tab_width = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.go]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
indent_size = 1
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,3 +3,5 @@
|
|||||||
key-database.pogreb/
|
key-database.pogreb/
|
||||||
acme-account.json
|
acme-account.json
|
||||||
build/
|
build/
|
||||||
|
vendor/
|
||||||
|
pages
|
||||||
|
20
.golangci.yml
Normal file
20
.golangci.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
linters-settings:
|
||||||
|
gocritic:
|
||||||
|
enabled-tags:
|
||||||
|
- diagnostic
|
||||||
|
- experimental
|
||||||
|
- opinionated
|
||||||
|
- performance
|
||||||
|
- style
|
||||||
|
disabled-checks:
|
||||||
|
- importShadow
|
||||||
|
- ifElseChain
|
||||||
|
- hugeParam
|
||||||
|
|
||||||
|
linters:
|
||||||
|
enable:
|
||||||
|
- unconvert
|
||||||
|
- gocritic
|
||||||
|
|
||||||
|
run:
|
||||||
|
timeout: 5m
|
111
.woodpecker.yml
Normal file
111
.woodpecker.yml
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
pipeline:
|
||||||
|
# use vendor to cache dependencies
|
||||||
|
vendor:
|
||||||
|
image: golang:1.18
|
||||||
|
commands:
|
||||||
|
- go mod vendor
|
||||||
|
|
||||||
|
lint:
|
||||||
|
image: golangci/golangci-lint:latest
|
||||||
|
group: compliant
|
||||||
|
pull: true
|
||||||
|
commands:
|
||||||
|
- go version
|
||||||
|
- go install mvdan.cc/gofumpt@latest
|
||||||
|
- "[ $(gofumpt -extra -l . | wc -l) != 0 ] && { echo 'code not formated'; exit 1; }"
|
||||||
|
- golangci-lint run --timeout 5m --build-tags integration
|
||||||
|
|
||||||
|
editor-config:
|
||||||
|
group: compliant
|
||||||
|
image: mstruebing/editorconfig-checker
|
||||||
|
|
||||||
|
build:
|
||||||
|
group: compliant
|
||||||
|
image: codeberg.org/6543/docker-images/golang_just
|
||||||
|
commands:
|
||||||
|
- go version
|
||||||
|
- just build
|
||||||
|
when:
|
||||||
|
event: [ "pull_request", "push" ]
|
||||||
|
|
||||||
|
docker-dryrun:
|
||||||
|
group: compliant
|
||||||
|
image: plugins/kaniko
|
||||||
|
settings:
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
no_push: true
|
||||||
|
tags: latest
|
||||||
|
when:
|
||||||
|
event: [ "pull_request", "push" ]
|
||||||
|
path: Dockerfile
|
||||||
|
|
||||||
|
build-tag:
|
||||||
|
group: compliant
|
||||||
|
image: codeberg.org/6543/docker-images/golang_just
|
||||||
|
commands:
|
||||||
|
- go version
|
||||||
|
- just build-tag ${CI_COMMIT_TAG##v}
|
||||||
|
when:
|
||||||
|
event: [ "tag" ]
|
||||||
|
|
||||||
|
test:
|
||||||
|
group: test
|
||||||
|
image: codeberg.org/6543/docker-images/golang_just
|
||||||
|
commands:
|
||||||
|
- just test
|
||||||
|
|
||||||
|
integration-tests:
|
||||||
|
group: test
|
||||||
|
image: codeberg.org/6543/docker-images/golang_just
|
||||||
|
commands:
|
||||||
|
- just integration
|
||||||
|
environment:
|
||||||
|
- ACME_API=https://acme.mock.directory
|
||||||
|
- PAGES_DOMAIN=localhost.mock.directory
|
||||||
|
- RAW_DOMAIN=raw.localhost.mock.directory
|
||||||
|
- PORT=4430
|
||||||
|
|
||||||
|
release:
|
||||||
|
image: plugins/gitea-release
|
||||||
|
settings:
|
||||||
|
base_url: https://codeberg.org
|
||||||
|
file_exists: overwrite
|
||||||
|
files: build/codeberg-pages-server
|
||||||
|
api_key:
|
||||||
|
from_secret: bot_token
|
||||||
|
environment:
|
||||||
|
- DRONE_REPO_OWNER=${CI_REPO_OWNER}
|
||||||
|
- DRONE_REPO_NAME=${CI_REPO_NAME}
|
||||||
|
- DRONE_BUILD_EVENT=${CI_BUILD_EVENT}
|
||||||
|
- DRONE_COMMIT_REF=${CI_COMMIT_REF}
|
||||||
|
when:
|
||||||
|
event: [ "tag" ]
|
||||||
|
|
||||||
|
docker-next:
|
||||||
|
image: plugins/kaniko
|
||||||
|
settings:
|
||||||
|
registry: codeberg.org
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
repo: codeberg.org/codeberg/pages-server
|
||||||
|
tags: next
|
||||||
|
username:
|
||||||
|
from_secret: bot_user
|
||||||
|
password:
|
||||||
|
from_secret: bot_token
|
||||||
|
when:
|
||||||
|
event: [ "push" ]
|
||||||
|
branch: ${CI_REPO_DEFAULT_BRANCH}
|
||||||
|
|
||||||
|
docker-tag:
|
||||||
|
image: plugins/kaniko
|
||||||
|
settings:
|
||||||
|
registry: codeberg.org
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
repo: codeberg.org/codeberg/pages-server
|
||||||
|
tags: [ latest, "${CI_COMMIT_TAG}" ]
|
||||||
|
username:
|
||||||
|
from_secret: bot_user
|
||||||
|
password:
|
||||||
|
from_secret: bot_token
|
||||||
|
when:
|
||||||
|
event: [ "tag" ]
|
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM golang:alpine as build
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
RUN apk add ca-certificates
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build .
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=build /workspace/pages /pages
|
||||||
|
COPY --from=build \
|
||||||
|
/etc/ssl/certs/ca-certificates.crt \
|
||||||
|
/etc/ssl/certs/ca-certificates.crt
|
||||||
|
|
||||||
|
ENTRYPOINT ["/pages"]
|
38
Justfile
38
Justfile
@@ -6,7 +6,45 @@ dev:
|
|||||||
export PAGES_DOMAIN=localhost.mock.directory
|
export PAGES_DOMAIN=localhost.mock.directory
|
||||||
export RAW_DOMAIN=raw.localhost.mock.directory
|
export RAW_DOMAIN=raw.localhost.mock.directory
|
||||||
export PORT=4430
|
export PORT=4430
|
||||||
|
export LOG_LEVEL=trace
|
||||||
go run .
|
go run .
|
||||||
|
|
||||||
build:
|
build:
|
||||||
CGO_ENABLED=0 go build -ldflags '-s -w' -v -o build/codeberg-pages-server ./
|
CGO_ENABLED=0 go build -ldflags '-s -w' -v -o build/codeberg-pages-server ./
|
||||||
|
|
||||||
|
build-tag VERSION:
|
||||||
|
CGO_ENABLED=0 go build -ldflags '-s -w -X "codeberg.org/codeberg/pages/server/version.Version={{VERSION}}"' -v -o build/codeberg-pages-server ./
|
||||||
|
|
||||||
|
lint: tool-golangci tool-gofumpt
|
||||||
|
[ $(gofumpt -extra -l . | wc -l) != 0 ] && { echo 'code not formated'; exit 1; }; \
|
||||||
|
golangci-lint run --timeout 5m --build-tags integration
|
||||||
|
# TODO: run editorconfig-checker
|
||||||
|
|
||||||
|
fmt: tool-gofumpt
|
||||||
|
gofumpt -w --extra .
|
||||||
|
|
||||||
|
clean:
|
||||||
|
go clean ./...
|
||||||
|
rm -rf build/
|
||||||
|
|
||||||
|
tool-golangci:
|
||||||
|
@hash golangci-lint> /dev/null 2>&1; if [ $? -ne 0 ]; then \
|
||||||
|
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
tool-gofumpt:
|
||||||
|
@hash gofumpt> /dev/null 2>&1; if [ $? -ne 0 ]; then \
|
||||||
|
go install mvdan.cc/gofumpt@latest; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test -race codeberg.org/codeberg/pages/server/... codeberg.org/codeberg/pages/html/
|
||||||
|
|
||||||
|
test-run TEST:
|
||||||
|
go test -race -run "^{{TEST}}$" codeberg.org/codeberg/pages/server/... codeberg.org/codeberg/pages/html/
|
||||||
|
|
||||||
|
integration:
|
||||||
|
go test -race -tags integration codeberg.org/codeberg/pages/integration/...
|
||||||
|
|
||||||
|
integration-run TEST:
|
||||||
|
go test -race -tags integration -run "^{{TEST}}$" codeberg.org/codeberg/pages/integration/...
|
||||||
|
98
README.md
98
README.md
@@ -1,11 +1,64 @@
|
|||||||
## Environment
|
# Codeberg Pages
|
||||||
|
|
||||||
|
Gitea lacks the ability to host static pages from Git.
|
||||||
|
The Codeberg Pages Server addresses this lack by implementing a standalone service
|
||||||
|
that connects to Gitea via API.
|
||||||
|
It is suitable to be deployed by other Gitea instances, too, to offer static pages hosting to their users.
|
||||||
|
|
||||||
|
**End user documentation** can mainly be found at the [Wiki](https://codeberg.org/Codeberg/pages-server/wiki/Overview)
|
||||||
|
and the [Codeberg Documentation](https://docs.codeberg.org/codeberg-pages/).
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
This is the new Codeberg Pages server, a solution for serving static pages from Gitea repositories.
|
||||||
|
Mapping custom domains is not static anymore, but can be done with DNS:
|
||||||
|
|
||||||
|
1) add a `.domains` text file to your repository, containing the allowed domains, separated by new lines. The
|
||||||
|
first line will be the canonical domain/URL; all other occurrences will be redirected to it.
|
||||||
|
|
||||||
|
2) add a CNAME entry to your domain, pointing to `[[{branch}.]{repo}.]{owner}.codeberg.page` (repo defaults to
|
||||||
|
"pages", "branch" defaults to the default branch if "repo" is "pages", or to "pages" if "repo" is something else.
|
||||||
|
If the branch name contains slash characters, you need to replace "/" in the branch name to "~"):
|
||||||
|
`www.example.org. IN CNAME main.pages.example.codeberg.page.`
|
||||||
|
|
||||||
|
3) if a CNAME is set for "www.example.org", you can redirect there from the naked domain by adding an ALIAS record
|
||||||
|
for "example.org" (if your provider allows ALIAS or similar records, otherwise use A/AAAA), together with a TXT
|
||||||
|
record that points to your repo (just like the CNAME record):
|
||||||
|
`example.org IN ALIAS codeberg.page.`
|
||||||
|
`example.org IN TXT main.pages.example.codeberg.page.`
|
||||||
|
|
||||||
|
Certificates are generated, updated and cleaned up automatically via Let's Encrypt through a TLS challenge.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
**Warning: Some Caveats Apply**
|
||||||
|
> Currently, the deployment requires you to have some knowledge of system administration as well as understanding and building code,
|
||||||
|
> so you can eventually edit non-configurable and codeberg-specific settings.
|
||||||
|
> In the future, we'll try to reduce these and make hosting Codeberg Pages as easy as setting up Gitea.
|
||||||
|
> If you consider using Pages in practice, please consider contacting us first,
|
||||||
|
> we'll then try to share some basic steps and document the current usage for admins
|
||||||
|
> (might be changing in the current state).
|
||||||
|
|
||||||
|
Deploying the software itself is very easy. You can grab a current release binary or build yourself,
|
||||||
|
configure the environment as described below, and you are done.
|
||||||
|
|
||||||
|
The hard part is about adding **custom domain support** if you intend to use it.
|
||||||
|
SSL certificates (request + renewal) is automatically handled by the Pages Server,
|
||||||
|
but if you want to run it on a shared IP address (and not a standalone),
|
||||||
|
you'll need to configure your reverse proxy not to terminate the TLS connections,
|
||||||
|
but forward the requests on the IP level to the Pages Server.
|
||||||
|
|
||||||
|
You can check out a proof of concept in the `haproxy-sni` folder,
|
||||||
|
and especially have a look at [this section of the haproxy.cfg](https://codeberg.org/Codeberg/pages-server/src/branch/main/haproxy-sni/haproxy.cfg#L38).
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
|
||||||
- `HOST` & `PORT` (default: `[::]` & `443`): listen address.
|
- `HOST` & `PORT` (default: `[::]` & `443`): listen address.
|
||||||
- `PAGES_DOMAIN` (default: `codeberg.page`): main domain for pages.
|
- `PAGES_DOMAIN` (default: `codeberg.page`): main domain for pages.
|
||||||
- `RAW_DOMAIN` (default: `raw.codeberg.org`): domain for raw resources.
|
- `RAW_DOMAIN` (default: `raw.codeberg.page`): domain for raw resources.
|
||||||
- `GITEA_ROOT` (default: `https://codeberg.org`): root of the upstream Gitea instance.
|
- `GITEA_ROOT` (default: `https://codeberg.org`): root of the upstream Gitea instance.
|
||||||
- `REDIRECT_BROKEN_DNS` (default: https://docs.codeberg.org/pages/custom-domains/): info page for setting up DNS, shown for invalid DNS setups.
|
- `GITEA_API_TOKEN` (default: empty): API token for the Gitea instance to access non-public (e.g. limited) repos.
|
||||||
- `REDIRECT_RAW_INFO` (default: https://docs.codeberg.org/pages/raw-content/): info page for raw resources, shown if no resource is provided.
|
- `RAW_INFO_PAGE` (default: https://docs.codeberg.org/pages/raw-content/): info page for raw resources, shown if no resource is provided.
|
||||||
- `ACME_API` (default: https://acme-v02.api.letsencrypt.org/directory): set this to https://acme.mock.director to use invalid certificates without any verification (great for debugging).
|
- `ACME_API` (default: https://acme-v02.api.letsencrypt.org/directory): set this to https://acme.mock.director to use invalid certificates without any verification (great for debugging).
|
||||||
ZeroSSL might be better in the future as it doesn't have rate limits and doesn't clash with the official Codeberg certificates (which are using Let's Encrypt), but I couldn't get it to work yet.
|
ZeroSSL might be better in the future as it doesn't have rate limits and doesn't clash with the official Codeberg certificates (which are using Let's Encrypt), but I couldn't get it to work yet.
|
||||||
- `ACME_EMAIL` (default: `noreply@example.email`): Set this to "true" to accept the Terms of Service of your ACME provider.
|
- `ACME_EMAIL` (default: `noreply@example.email`): Set this to "true" to accept the Terms of Service of your ACME provider.
|
||||||
@@ -15,3 +68,40 @@
|
|||||||
- `ENABLE_HTTP_SERVER` (default: false): Set this to true to enable the HTTP-01 challenge and redirect all other HTTP requests to HTTPS. Currently only works with port 80.
|
- `ENABLE_HTTP_SERVER` (default: false): Set this to true to enable the HTTP-01 challenge and redirect all other HTTP requests to HTTPS. Currently only works with port 80.
|
||||||
- `DNS_PROVIDER` (default: use self-signed certificate): Code of the ACME DNS provider for the main domain wildcard.
|
- `DNS_PROVIDER` (default: use self-signed certificate): Code of the ACME DNS provider for the main domain wildcard.
|
||||||
See https://go-acme.github.io/lego/dns/ for available values & additional environment variables.
|
See https://go-acme.github.io/lego/dns/ for available values & additional environment variables.
|
||||||
|
- `LOG_LEVEL` (default: warn): Set this to specify the level of logging.
|
||||||
|
|
||||||
|
|
||||||
|
## Contributing to the development
|
||||||
|
|
||||||
|
The Codeberg team is very open to your contribution.
|
||||||
|
Since we are working nicely in a team, it might be hard at times to get started
|
||||||
|
(still check out the issues, we always aim to have some things to get you started).
|
||||||
|
|
||||||
|
If you have any questions, want to work on a feature or could imagine collaborating with us for some time,
|
||||||
|
feel free to ping us in an issue or in a general Matrix chatgroup.
|
||||||
|
|
||||||
|
You can also contact the maintainers of this project:
|
||||||
|
|
||||||
|
- [momar](https://codeberg.org/momar) [(Matrix)](https://matrix.to/#/@moritz:wuks.space)
|
||||||
|
- [6543](https://codeberg.org/6543) [(Matrix)](https://matrix.to/#/@marddl:obermui.de)
|
||||||
|
|
||||||
|
### First steps
|
||||||
|
|
||||||
|
The code of this repository is split in several modules.
|
||||||
|
While heavy refactoring work is currently undergo, you can easily understand the basic structure:
|
||||||
|
The `cmd` folder holds the data necessary for interacting with the service via the cli.
|
||||||
|
If you are considering to deploy the service yourself, make sure to check it out.
|
||||||
|
The heart of the software lives in the `server` folder and is split in several modules.
|
||||||
|
After scanning the code, you should quickly be able to understand their function and start hacking on them.
|
||||||
|
|
||||||
|
Again: Feel free to get in touch with us for any questions that might arise.
|
||||||
|
Thank you very much.
|
||||||
|
|
||||||
|
|
||||||
|
### Test Server
|
||||||
|
|
||||||
|
run `just dev`
|
||||||
|
now this pages should work:
|
||||||
|
- https://magiclike.localhost.mock.directory:4430/
|
||||||
|
- https://momar.localhost.mock.directory:4430/ci-testing/
|
||||||
|
- https://momar.localhost.mock.directory:4430/pag/@master/
|
||||||
|
510
certificates.go
510
certificates.go
@@ -1,510 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto"
|
|
||||||
"crypto/ecdsa"
|
|
||||||
"crypto/elliptic"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/gob"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"github.com/OrlovEvgeny/go-mcache"
|
|
||||||
"github.com/akrylysov/pogreb/fs"
|
|
||||||
"github.com/go-acme/lego/v4/certificate"
|
|
||||||
"github.com/go-acme/lego/v4/challenge"
|
|
||||||
"github.com/go-acme/lego/v4/challenge/resolver"
|
|
||||||
"github.com/go-acme/lego/v4/challenge/tlsalpn01"
|
|
||||||
"github.com/go-acme/lego/v4/providers/dns"
|
|
||||||
"io/ioutil"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/akrylysov/pogreb"
|
|
||||||
"github.com/reugn/equalizer"
|
|
||||||
|
|
||||||
"github.com/go-acme/lego/v4/certcrypto"
|
|
||||||
"github.com/go-acme/lego/v4/lego"
|
|
||||||
"github.com/go-acme/lego/v4/registration"
|
|
||||||
)
|
|
||||||
|
|
||||||
// tlsConfig contains the configuration for generating, serving and cleaning up Let's Encrypt certificates.
|
|
||||||
var tlsConfig = &tls.Config{
|
|
||||||
// check DNS name & get certificate from Let's Encrypt
|
|
||||||
GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
||||||
sni := strings.ToLower(strings.TrimSpace(info.ServerName))
|
|
||||||
sniBytes := []byte(sni)
|
|
||||||
if len(sni) < 1 {
|
|
||||||
return nil, errors.New("missing sni")
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.SupportedProtos != nil {
|
|
||||||
for _, proto := range info.SupportedProtos {
|
|
||||||
if proto == tlsalpn01.ACMETLS1Protocol {
|
|
||||||
challenge, ok := challengeCache.Get(sni)
|
|
||||||
if !ok {
|
|
||||||
return nil, errors.New("no challenge for this domain")
|
|
||||||
}
|
|
||||||
cert, err := tlsalpn01.ChallengeCert(sni, challenge.(string))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return cert, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
targetOwner := ""
|
|
||||||
if bytes.HasSuffix(sniBytes, MainDomainSuffix) || bytes.Equal(sniBytes, MainDomainSuffix[1:]) {
|
|
||||||
// deliver default certificate for the main domain (*.codeberg.page)
|
|
||||||
sniBytes = MainDomainSuffix
|
|
||||||
sni = string(sniBytes)
|
|
||||||
} else {
|
|
||||||
var targetRepo, targetBranch string
|
|
||||||
targetOwner, targetRepo, targetBranch = getTargetFromDNS(sni)
|
|
||||||
if targetOwner == "" {
|
|
||||||
// DNS not set up, return main certificate to redirect to the docs
|
|
||||||
sniBytes = MainDomainSuffix
|
|
||||||
sni = string(sniBytes)
|
|
||||||
} else {
|
|
||||||
_, _ = targetRepo, targetBranch
|
|
||||||
_, valid := checkCanonicalDomain(targetOwner, targetRepo, targetBranch, sni)
|
|
||||||
if !valid {
|
|
||||||
sniBytes = MainDomainSuffix
|
|
||||||
sni = string(sniBytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if tlsCertificate, ok := keyCache.Get(sni); ok {
|
|
||||||
// we can use an existing certificate object
|
|
||||||
return tlsCertificate.(*tls.Certificate), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var tlsCertificate tls.Certificate
|
|
||||||
var err error
|
|
||||||
var ok bool
|
|
||||||
if tlsCertificate, ok = retrieveCertFromDB(sniBytes); !ok {
|
|
||||||
// request a new certificate
|
|
||||||
if bytes.Equal(sniBytes, MainDomainSuffix) {
|
|
||||||
return nil, errors.New("won't request certificate for main domain, something really bad has happened")
|
|
||||||
}
|
|
||||||
|
|
||||||
tlsCertificate, err = obtainCert(acmeClient, []string{sni}, nil, targetOwner)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = keyCache.Set(sni, &tlsCertificate, 15 * time.Minute)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return &tlsCertificate, nil
|
|
||||||
},
|
|
||||||
PreferServerCipherSuites: true,
|
|
||||||
NextProtos: []string{
|
|
||||||
"http/1.1",
|
|
||||||
tlsalpn01.ACMETLS1Protocol,
|
|
||||||
},
|
|
||||||
|
|
||||||
// generated 2021-07-13, Mozilla Guideline v5.6, Go 1.14.4, intermediate configuration
|
|
||||||
// https://ssl-config.mozilla.org/#server=go&version=1.14.4&config=intermediate&guideline=5.6
|
|
||||||
MinVersion: tls.VersionTLS12,
|
|
||||||
CipherSuites: []uint16{
|
|
||||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
|
||||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
|
||||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
|
||||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
|
||||||
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
|
|
||||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var keyCache = mcache.New()
|
|
||||||
var keyDatabase *pogreb.DB
|
|
||||||
|
|
||||||
func CheckUserLimit(user string) (error) {
|
|
||||||
userLimit, ok := acmeClientCertificateLimitPerUser[user]
|
|
||||||
if !ok {
|
|
||||||
// Each Codeberg user can only add 10 new domains per day.
|
|
||||||
userLimit = equalizer.NewTokenBucket(10, time.Hour * 24)
|
|
||||||
acmeClientCertificateLimitPerUser[user] = userLimit
|
|
||||||
}
|
|
||||||
if !userLimit.Ask() {
|
|
||||||
return errors.New("rate limit exceeded: 10 certificates per user per 24 hours")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var myAcmeAccount AcmeAccount
|
|
||||||
var myAcmeConfig *lego.Config
|
|
||||||
|
|
||||||
type AcmeAccount struct {
|
|
||||||
Email string
|
|
||||||
Registration *registration.Resource
|
|
||||||
Key crypto.PrivateKey `json:"-"`
|
|
||||||
KeyPEM string `json:"Key"`
|
|
||||||
}
|
|
||||||
func (u *AcmeAccount) GetEmail() string {
|
|
||||||
return u.Email
|
|
||||||
}
|
|
||||||
func (u AcmeAccount) GetRegistration() *registration.Resource {
|
|
||||||
return u.Registration
|
|
||||||
}
|
|
||||||
func (u *AcmeAccount) GetPrivateKey() crypto.PrivateKey {
|
|
||||||
return u.Key
|
|
||||||
}
|
|
||||||
|
|
||||||
func newAcmeClient(configureChallenge func(*resolver.SolverManager) error) *lego.Client {
|
|
||||||
acmeClient, err := lego.NewClient(myAcmeConfig)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
err = configureChallenge(acmeClient.Challenge)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return acmeClient
|
|
||||||
}
|
|
||||||
|
|
||||||
var acmeClient, mainDomainAcmeClient *lego.Client
|
|
||||||
var acmeClientCertificateLimitPerUser = map[string]*equalizer.TokenBucket{}
|
|
||||||
|
|
||||||
// rate limit is 300 / 3 hours, we want 200 / 2 hours but to refill more often, so that's 25 new domains every 15 minutes
|
|
||||||
// TODO: when this is used a lot, we probably have to think of a somewhat better solution?
|
|
||||||
var acmeClientOrderLimit = equalizer.NewTokenBucket(25, 15 * time.Minute)
|
|
||||||
|
|
||||||
// rate limit is 20 / second, we want 10 / second
|
|
||||||
var acmeClientRequestLimit = equalizer.NewTokenBucket(10, 1 * time.Second)
|
|
||||||
|
|
||||||
var challengeCache = mcache.New()
|
|
||||||
type AcmeTLSChallengeProvider struct{}
|
|
||||||
var _ challenge.Provider = AcmeTLSChallengeProvider{}
|
|
||||||
func (a AcmeTLSChallengeProvider) Present(domain, _, keyAuth string) error {
|
|
||||||
return challengeCache.Set(domain, keyAuth, 1*time.Hour)
|
|
||||||
}
|
|
||||||
func (a AcmeTLSChallengeProvider) CleanUp(domain, _, _ string) error {
|
|
||||||
challengeCache.Remove(domain)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
type AcmeHTTPChallengeProvider struct{}
|
|
||||||
var _ challenge.Provider = AcmeHTTPChallengeProvider{}
|
|
||||||
func (a AcmeHTTPChallengeProvider) Present(domain, token, keyAuth string) error {
|
|
||||||
return challengeCache.Set(domain + "/" + token, keyAuth, 1*time.Hour)
|
|
||||||
}
|
|
||||||
func (a AcmeHTTPChallengeProvider) CleanUp(domain, token, _ string) error {
|
|
||||||
challengeCache.Remove(domain + "/" + token)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func retrieveCertFromDB(sni []byte) (tls.Certificate, bool) {
|
|
||||||
// parse certificate from database
|
|
||||||
resBytes, err := keyDatabase.Get(sni)
|
|
||||||
if err != nil {
|
|
||||||
// key database is not working
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if resBytes == nil {
|
|
||||||
return tls.Certificate{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
resGob := bytes.NewBuffer(resBytes)
|
|
||||||
resDec := gob.NewDecoder(resGob)
|
|
||||||
res := &certificate.Resource{}
|
|
||||||
err = resDec.Decode(res)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tlsCertificate, err := tls.X509KeyPair(res.Certificate, res.PrivateKey)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !bytes.Equal(sni, MainDomainSuffix) {
|
|
||||||
tlsCertificate.Leaf, err = x509.ParseCertificate(tlsCertificate.Certificate[0])
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// renew certificates 7 days before they expire
|
|
||||||
if !tlsCertificate.Leaf.NotAfter.After(time.Now().Add(-7 * 24 * time.Hour)) {
|
|
||||||
go (func() {
|
|
||||||
tlsCertificate, err = obtainCert(acmeClient, []string{string(sni)}, res, "")
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Couldn't renew certificate for %s: %s", sni, err)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tlsCertificate, true
|
|
||||||
}
|
|
||||||
|
|
||||||
var obtainLocks = sync.Map{}
|
|
||||||
func obtainCert(acmeClient *lego.Client, domains []string, renew *certificate.Resource, user string) (tls.Certificate, error) {
|
|
||||||
name := strings.TrimPrefix(domains[0], "*")
|
|
||||||
if os.Getenv("DNS_PROVIDER") == "" && len(domains[0]) > 0 && domains[0][0] == '*' {
|
|
||||||
domains = domains[1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// lock to avoid simultaneous requests
|
|
||||||
_, working := obtainLocks.LoadOrStore(name, struct{}{})
|
|
||||||
if working {
|
|
||||||
for working {
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
_, working = obtainLocks.Load(name)
|
|
||||||
}
|
|
||||||
cert, ok := retrieveCertFromDB([]byte(name))
|
|
||||||
if !ok {
|
|
||||||
return tls.Certificate{}, errors.New("certificate failed in synchronous request")
|
|
||||||
}
|
|
||||||
return cert, nil
|
|
||||||
}
|
|
||||||
defer obtainLocks.Delete(name)
|
|
||||||
|
|
||||||
// request actual cert
|
|
||||||
var res *certificate.Resource
|
|
||||||
var err error
|
|
||||||
if renew != nil {
|
|
||||||
if os.Getenv("ACME_USE_RATE_LIMITS") != "false" {
|
|
||||||
acmeClientRequestLimit.Take()
|
|
||||||
}
|
|
||||||
log.Printf("Renewing certificate for %v", domains)
|
|
||||||
res, err = acmeClient.Certificate.Renew(*renew, true, false, "")
|
|
||||||
} else {
|
|
||||||
if user != "" {
|
|
||||||
if err := CheckUserLimit(user); err != nil {
|
|
||||||
return tls.Certificate{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if os.Getenv("ACME_USE_RATE_LIMITS") != "false" {
|
|
||||||
acmeClientOrderLimit.Take()
|
|
||||||
acmeClientRequestLimit.Take()
|
|
||||||
}
|
|
||||||
log.Printf("Requesting new certificate for %v", domains)
|
|
||||||
res, err = acmeClient.Certificate.Obtain(certificate.ObtainRequest{
|
|
||||||
Domains: domains,
|
|
||||||
Bundle: true,
|
|
||||||
MustStaple: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Couldn't obtain certificate for %v: %s", domains, err)
|
|
||||||
return tls.Certificate{}, err
|
|
||||||
}
|
|
||||||
log.Printf("Obtained certificate for %v", domains)
|
|
||||||
|
|
||||||
var resGob bytes.Buffer
|
|
||||||
resEnc := gob.NewEncoder(&resGob)
|
|
||||||
err = resEnc.Encode(res)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
err = keyDatabase.Put([]byte(name), resGob.Bytes())
|
|
||||||
if err != nil {
|
|
||||||
_ = keyDatabase.Delete([]byte(name + "/key"))
|
|
||||||
obtainLocks.Delete(name)
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tlsCertificate, err := tls.X509KeyPair(res.Certificate, res.PrivateKey)
|
|
||||||
if err != nil {
|
|
||||||
return tls.Certificate{}, err
|
|
||||||
}
|
|
||||||
return tlsCertificate, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupCertificates() {
|
|
||||||
var err error
|
|
||||||
keyDatabase, err = pogreb.Open("key-database.pogreb", &pogreb.Options{
|
|
||||||
BackgroundSyncInterval: 30 * time.Second,
|
|
||||||
BackgroundCompactionInterval: 6 * time.Hour,
|
|
||||||
FileSystem: fs.OSMMap,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if os.Getenv("ACME_ACCEPT_TERMS") != "true" || (os.Getenv("DNS_PROVIDER") == "" && os.Getenv("ACME_API") != "https://acme.mock.directory") {
|
|
||||||
panic(errors.New("you must set ACME_ACCEPT_TERMS and DNS_PROVIDER, unless ACME_API is set to https://acme.mock.directory"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if account, err := ioutil.ReadFile("acme-account.json"); err == nil {
|
|
||||||
err = json.Unmarshal(account, &myAcmeAccount)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
myAcmeAccount.Key, err = certcrypto.ParsePEMPrivateKey([]byte(myAcmeAccount.KeyPEM))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
myAcmeConfig = lego.NewConfig(&myAcmeAccount)
|
|
||||||
myAcmeConfig.CADirURL = envOr("ACME_API", "https://acme-v02.api.letsencrypt.org/directory")
|
|
||||||
myAcmeConfig.Certificate.KeyType = certcrypto.RSA2048
|
|
||||||
newAcmeClient(func(manager *resolver.SolverManager) error { return nil })
|
|
||||||
} else if os.IsNotExist(err) {
|
|
||||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
myAcmeAccount = AcmeAccount{
|
|
||||||
Email: envOr("ACME_EMAIL", "noreply@example.email"),
|
|
||||||
Key: privateKey,
|
|
||||||
KeyPEM: string(certcrypto.PEMEncode(privateKey)),
|
|
||||||
}
|
|
||||||
myAcmeConfig = lego.NewConfig(&myAcmeAccount)
|
|
||||||
myAcmeConfig.CADirURL = envOr("ACME_API", "https://acme-v02.api.letsencrypt.org/directory")
|
|
||||||
myAcmeConfig.Certificate.KeyType = certcrypto.RSA2048
|
|
||||||
tempClient := newAcmeClient(func(manager *resolver.SolverManager) error { return nil })
|
|
||||||
|
|
||||||
// accept terms & log in to EAB
|
|
||||||
if os.Getenv("ACME_EAB_KID") == "" || os.Getenv("ACME_EAB_HMAC") == "" {
|
|
||||||
reg, err := tempClient.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: os.Getenv("ACME_ACCEPT_TERMS") == "true"})
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
myAcmeAccount.Registration = reg
|
|
||||||
} else {
|
|
||||||
reg, err := tempClient.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{
|
|
||||||
TermsOfServiceAgreed: os.Getenv("ACME_ACCEPT_TERMS") == "true",
|
|
||||||
Kid: os.Getenv("ACME_EAB_KID"),
|
|
||||||
HmacEncoded: os.Getenv("ACME_EAB_HMAC"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
myAcmeAccount.Registration = reg
|
|
||||||
}
|
|
||||||
|
|
||||||
acmeAccountJson, err := json.Marshal(myAcmeAccount)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
err = ioutil.WriteFile("acme-account.json", acmeAccountJson, 0600)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
acmeClient = newAcmeClient(func(challenge *resolver.SolverManager) error {
|
|
||||||
err = challenge.SetTLSALPN01Provider(AcmeTLSChallengeProvider{})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if os.Getenv("ENABLE_HTTP_SERVER") == "true" {
|
|
||||||
return challenge.SetHTTP01Provider(AcmeHTTPChallengeProvider{})
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
mainDomainAcmeClient = newAcmeClient(func(challenge *resolver.SolverManager) error {
|
|
||||||
if os.Getenv("DNS_PROVIDER") == "" {
|
|
||||||
// using mock server, don't use wildcard certs
|
|
||||||
return challenge.SetTLSALPN01Provider(AcmeTLSChallengeProvider{})
|
|
||||||
}
|
|
||||||
provider, err := dns.NewDNSChallengeProviderByName(os.Getenv("DNS_PROVIDER"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return challenge.SetDNS01Provider(provider)
|
|
||||||
})
|
|
||||||
|
|
||||||
resBytes, err := keyDatabase.Get(MainDomainSuffix)
|
|
||||||
if err != nil {
|
|
||||||
// key database is not working
|
|
||||||
panic(err)
|
|
||||||
} else if resBytes == nil {
|
|
||||||
_, err = obtainCert(mainDomainAcmeClient, []string{"*" + string(MainDomainSuffix), string(MainDomainSuffix[1:])}, nil, "")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Couldn't renew certificate for *%s: %s", MainDomainSuffix, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
go (func() {
|
|
||||||
for {
|
|
||||||
err := keyDatabase.Sync()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Syncinc key database failed: %s", err)
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Minute)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
go (func() {
|
|
||||||
for {
|
|
||||||
// clean up expired certs
|
|
||||||
now := time.Now()
|
|
||||||
expiredCertCount := 0
|
|
||||||
keyDatabaseIterator := keyDatabase.Items()
|
|
||||||
key, resBytes, err := keyDatabaseIterator.Next()
|
|
||||||
for err == nil {
|
|
||||||
if !bytes.Equal(key, MainDomainSuffix) {
|
|
||||||
resGob := bytes.NewBuffer(resBytes)
|
|
||||||
resDec := gob.NewDecoder(resGob)
|
|
||||||
res := &certificate.Resource{}
|
|
||||||
err = resDec.Decode(res)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tlsCertificates, err := certcrypto.ParsePEMBundle(res.Certificate)
|
|
||||||
if err != nil || !tlsCertificates[0].NotAfter.After(now) {
|
|
||||||
err := keyDatabase.Delete(key)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Deleting expired certificate for %s failed: %s", string(key), err)
|
|
||||||
} else {
|
|
||||||
expiredCertCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
key, resBytes, err = keyDatabaseIterator.Next()
|
|
||||||
}
|
|
||||||
log.Printf("Removed %d expired certificates from the database", expiredCertCount)
|
|
||||||
|
|
||||||
// compact the database
|
|
||||||
result, err := keyDatabase.Compact()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Compacting key database failed: %s", err)
|
|
||||||
} else {
|
|
||||||
log.Printf("Compacted key database (%+v)", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// update main cert
|
|
||||||
resBytes, err = keyDatabase.Get(MainDomainSuffix)
|
|
||||||
if err != nil {
|
|
||||||
// key database is not working
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resGob := bytes.NewBuffer(resBytes)
|
|
||||||
resDec := gob.NewDecoder(resGob)
|
|
||||||
res := &certificate.Resource{}
|
|
||||||
err = resDec.Decode(res)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tlsCertificates, err := certcrypto.ParsePEMBundle(res.Certificate)
|
|
||||||
|
|
||||||
// renew main certificate 30 days before it expires
|
|
||||||
if !tlsCertificates[0].NotAfter.After(time.Now().Add(-30 * 24 * time.Hour)) {
|
|
||||||
go (func() {
|
|
||||||
_, err = obtainCert(mainDomainAcmeClient, []string{"*" + string(MainDomainSuffix), string(MainDomainSuffix[1:])}, res, "")
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Couldn't renew certificate for *%s: %s", MainDomainSuffix, err)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(12 * time.Hour)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}
|
|
72
cmd/certs.go
Normal file
72
cmd/certs.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/akrylysov/pogreb"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/server/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Certs = &cli.Command{
|
||||||
|
Name: "certs",
|
||||||
|
Usage: "manage certs manually",
|
||||||
|
Subcommands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "list",
|
||||||
|
Usage: "list all certificates in the database",
|
||||||
|
Action: listCerts,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "remove",
|
||||||
|
Usage: "remove a certificate from the database",
|
||||||
|
Action: removeCert,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func listCerts(ctx *cli.Context) error {
|
||||||
|
// TODO: make "key-database.pogreb" set via flag
|
||||||
|
keyDatabase, err := database.New("key-database.pogreb")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not create database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items := keyDatabase.Items()
|
||||||
|
for domain, _, err := items.Next(); err != pogreb.ErrIterationDone; domain, _, err = items.Next() {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if domain[0] == '.' {
|
||||||
|
fmt.Printf("*")
|
||||||
|
}
|
||||||
|
fmt.Printf("%s\n", domain)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeCert(ctx *cli.Context) error {
|
||||||
|
if ctx.Args().Len() < 1 {
|
||||||
|
return fmt.Errorf("'certs remove' requires at least one domain as an argument")
|
||||||
|
}
|
||||||
|
|
||||||
|
domains := ctx.Args().Slice()
|
||||||
|
|
||||||
|
// TODO: make "key-database.pogreb" set via flag
|
||||||
|
keyDatabase, err := database.New("key-database.pogreb")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not create database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, domain := range domains {
|
||||||
|
fmt.Printf("Removing domain %s from the database...\n", domain)
|
||||||
|
if err := keyDatabase.Delete(domain); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := keyDatabase.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
123
cmd/flags.go
Normal file
123
cmd/flags.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ServeFlags = []cli.Flag{
|
||||||
|
// MainDomainSuffix specifies the main domain (starting with a dot) for which subdomains shall be served as static
|
||||||
|
// pages, or used for comparison in CNAME lookups. Static pages can be accessed through
|
||||||
|
// https://{owner}.{MainDomain}[/{repo}], with repo defaulting to "pages".
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "pages-domain",
|
||||||
|
Usage: "specifies the main domain (starting with a dot) for which subdomains shall be served as static pages",
|
||||||
|
EnvVars: []string{"PAGES_DOMAIN"},
|
||||||
|
Value: "codeberg.page",
|
||||||
|
},
|
||||||
|
// GiteaRoot specifies the root URL of the Gitea instance, without a trailing slash.
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "gitea-root",
|
||||||
|
Usage: "specifies the root URL of the Gitea instance, without a trailing slash.",
|
||||||
|
EnvVars: []string{"GITEA_ROOT"},
|
||||||
|
Value: "https://codeberg.org",
|
||||||
|
},
|
||||||
|
// GiteaApiToken specifies an api token for the Gitea instance
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "gitea-api-token",
|
||||||
|
Usage: "specifies an api token for the Gitea instance",
|
||||||
|
EnvVars: []string{"GITEA_API_TOKEN"},
|
||||||
|
Value: "",
|
||||||
|
},
|
||||||
|
// RawDomain specifies the domain from which raw repository content shall be served in the following format:
|
||||||
|
// https://{RawDomain}/{owner}/{repo}[/{branch|tag|commit}/{version}]/{filepath...}
|
||||||
|
// (set to []byte(nil) to disable raw content hosting)
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "raw-domain",
|
||||||
|
Usage: "specifies the domain from which raw repository content shall be served, not set disable raw content hosting",
|
||||||
|
EnvVars: []string{"RAW_DOMAIN"},
|
||||||
|
Value: "raw.codeberg.page",
|
||||||
|
},
|
||||||
|
// RawInfoPage will be shown (with a redirect) when trying to access RawDomain directly (or without owner/repo/path).
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "raw-info-page",
|
||||||
|
Usage: "will be shown (with a redirect) when trying to access $RAW_DOMAIN directly (or without owner/repo/path)",
|
||||||
|
EnvVars: []string{"RAW_INFO_PAGE"},
|
||||||
|
Value: "https://docs.codeberg.org/codeberg-pages/raw-content/",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Server
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "host",
|
||||||
|
Usage: "specifies host of listening address",
|
||||||
|
EnvVars: []string{"HOST"},
|
||||||
|
Value: "[::]",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "port",
|
||||||
|
Usage: "specifies port of listening address",
|
||||||
|
EnvVars: []string{"PORT"},
|
||||||
|
Value: "443",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "enable-http-server",
|
||||||
|
// TODO: desc
|
||||||
|
EnvVars: []string{"ENABLE_HTTP_SERVER"},
|
||||||
|
},
|
||||||
|
// Server Options
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "enable-lfs-support",
|
||||||
|
Usage: "enable lfs support, require gitea v1.17.0 as backend",
|
||||||
|
EnvVars: []string{"ENABLE_LFS_SUPPORT"},
|
||||||
|
Value: true,
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "enable-symlink-support",
|
||||||
|
Usage: "follow symlinks if enabled, require gitea v1.18.0 as backend",
|
||||||
|
EnvVars: []string{"ENABLE_SYMLINK_SUPPORT"},
|
||||||
|
Value: true,
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "log-level",
|
||||||
|
Value: "warn",
|
||||||
|
Usage: "specify at which log level should be logged. Possible options: info, warn, error, fatal",
|
||||||
|
EnvVars: []string{"LOG_LEVEL"},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ACME
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "acme-api-endpoint",
|
||||||
|
EnvVars: []string{"ACME_API"},
|
||||||
|
Value: "https://acme-v02.api.letsencrypt.org/directory",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "acme-email",
|
||||||
|
EnvVars: []string{"ACME_EMAIL"},
|
||||||
|
Value: "noreply@example.email",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "acme-use-rate-limits",
|
||||||
|
// TODO: Usage
|
||||||
|
EnvVars: []string{"ACME_USE_RATE_LIMITS"},
|
||||||
|
Value: true,
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "acme-accept-terms",
|
||||||
|
// TODO: Usage
|
||||||
|
EnvVars: []string{"ACME_ACCEPT_TERMS"},
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "acme-eab-kid",
|
||||||
|
// TODO: Usage
|
||||||
|
EnvVars: []string{"ACME_EAB_KID"},
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "acme-eab-hmac",
|
||||||
|
// TODO: Usage
|
||||||
|
EnvVars: []string{"ACME_EAB_HMAC"},
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "dns-provider",
|
||||||
|
// TODO: Usage
|
||||||
|
EnvVars: []string{"DNS_PROVIDER"},
|
||||||
|
},
|
||||||
|
}
|
152
cmd/main.go
Normal file
152
cmd/main.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/server"
|
||||||
|
"codeberg.org/codeberg/pages/server/cache"
|
||||||
|
"codeberg.org/codeberg/pages/server/certificates"
|
||||||
|
"codeberg.org/codeberg/pages/server/database"
|
||||||
|
"codeberg.org/codeberg/pages/server/gitea"
|
||||||
|
"codeberg.org/codeberg/pages/server/handler"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AllowedCorsDomains lists the domains for which Cross-Origin Resource Sharing is allowed.
|
||||||
|
// TODO: make it a flag
|
||||||
|
var AllowedCorsDomains = []string{
|
||||||
|
"fonts.codeberg.org",
|
||||||
|
"design.codeberg.org",
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlacklistedPaths specifies forbidden path prefixes for all Codeberg Pages.
|
||||||
|
// TODO: Make it a flag too
|
||||||
|
var BlacklistedPaths = []string{
|
||||||
|
"/.well-known/acme-challenge/",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve sets up and starts the web server.
|
||||||
|
func Serve(ctx *cli.Context) error {
|
||||||
|
// Initalize the logger.
|
||||||
|
logLevel, err := zerolog.ParseLevel(ctx.String("log-level"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr}).With().Timestamp().Logger().Level(logLevel)
|
||||||
|
|
||||||
|
giteaRoot := strings.TrimSuffix(ctx.String("gitea-root"), "/")
|
||||||
|
giteaAPIToken := ctx.String("gitea-api-token")
|
||||||
|
rawDomain := ctx.String("raw-domain")
|
||||||
|
mainDomainSuffix := ctx.String("pages-domain")
|
||||||
|
rawInfoPage := ctx.String("raw-info-page")
|
||||||
|
listeningAddress := fmt.Sprintf("%s:%s", ctx.String("host"), ctx.String("port"))
|
||||||
|
enableHTTPServer := ctx.Bool("enable-http-server")
|
||||||
|
|
||||||
|
acmeAPI := ctx.String("acme-api-endpoint")
|
||||||
|
acmeMail := ctx.String("acme-email")
|
||||||
|
acmeUseRateLimits := ctx.Bool("acme-use-rate-limits")
|
||||||
|
acmeAcceptTerms := ctx.Bool("acme-accept-terms")
|
||||||
|
acmeEabKID := ctx.String("acme-eab-kid")
|
||||||
|
acmeEabHmac := ctx.String("acme-eab-hmac")
|
||||||
|
dnsProvider := ctx.String("dns-provider")
|
||||||
|
if (!acmeAcceptTerms || dnsProvider == "") && acmeAPI != "https://acme.mock.directory" {
|
||||||
|
return errors.New("you must set $ACME_ACCEPT_TERMS and $DNS_PROVIDER, unless $ACME_API is set to https://acme.mock.directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedCorsDomains := AllowedCorsDomains
|
||||||
|
if rawDomain != "" {
|
||||||
|
allowedCorsDomains = append(allowedCorsDomains, rawDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure MainDomain has a trailing dot, and GiteaRoot has no trailing slash
|
||||||
|
if !strings.HasPrefix(mainDomainSuffix, ".") {
|
||||||
|
mainDomainSuffix = "." + mainDomainSuffix
|
||||||
|
}
|
||||||
|
|
||||||
|
keyCache := cache.NewKeyValueCache()
|
||||||
|
challengeCache := cache.NewKeyValueCache()
|
||||||
|
// canonicalDomainCache stores canonical domains
|
||||||
|
canonicalDomainCache := cache.NewKeyValueCache()
|
||||||
|
// dnsLookupCache stores DNS lookups for custom domains
|
||||||
|
dnsLookupCache := cache.NewKeyValueCache()
|
||||||
|
// clientResponseCache stores responses from the Gitea server
|
||||||
|
clientResponseCache := cache.NewKeyValueCache()
|
||||||
|
|
||||||
|
giteaClient, err := gitea.NewClient(giteaRoot, giteaAPIToken, clientResponseCache, ctx.Bool("enable-symlink-support"), ctx.Bool("enable-lfs-support"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not create new gitea client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create handler based on settings
|
||||||
|
httpsHandler := handler.Handler(mainDomainSuffix, rawDomain,
|
||||||
|
giteaClient,
|
||||||
|
rawInfoPage,
|
||||||
|
BlacklistedPaths, allowedCorsDomains,
|
||||||
|
dnsLookupCache, canonicalDomainCache)
|
||||||
|
|
||||||
|
httpHandler := server.SetupHTTPACMEChallengeServer(challengeCache)
|
||||||
|
|
||||||
|
// Setup listener and TLS
|
||||||
|
log.Info().Msgf("Listening on https://%s", listeningAddress)
|
||||||
|
listener, err := net.Listen("tcp", listeningAddress)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("couldn't create listener: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: make "key-database.pogreb" set via flag
|
||||||
|
certDB, err := database.New("key-database.pogreb")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not create database: %v", err)
|
||||||
|
}
|
||||||
|
defer certDB.Close() //nolint:errcheck // database has no close ... sync behave like it
|
||||||
|
|
||||||
|
listener = tls.NewListener(listener, certificates.TLSConfig(mainDomainSuffix,
|
||||||
|
giteaClient,
|
||||||
|
dnsProvider,
|
||||||
|
acmeUseRateLimits,
|
||||||
|
keyCache, challengeCache, dnsLookupCache, canonicalDomainCache,
|
||||||
|
certDB))
|
||||||
|
|
||||||
|
acmeConfig, err := certificates.SetupAcmeConfig(acmeAPI, acmeMail, acmeEabHmac, acmeEabKID, acmeAcceptTerms)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := certificates.SetupCertificates(mainDomainSuffix, dnsProvider, acmeConfig, acmeUseRateLimits, enableHTTPServer, challengeCache, certDB); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
interval := 12 * time.Hour
|
||||||
|
certMaintainCtx, cancelCertMaintain := context.WithCancel(context.Background())
|
||||||
|
defer cancelCertMaintain()
|
||||||
|
go certificates.MaintainCertDB(certMaintainCtx, interval, mainDomainSuffix, dnsProvider, acmeUseRateLimits, certDB)
|
||||||
|
|
||||||
|
if enableHTTPServer {
|
||||||
|
go func() {
|
||||||
|
log.Info().Msg("Start HTTP server listening on :80")
|
||||||
|
err := http.ListenAndServe("[::]:80", httpHandler)
|
||||||
|
if err != nil {
|
||||||
|
log.Panic().Err(err).Msg("Couldn't start HTTP fastServer")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the web fastServer
|
||||||
|
log.Info().Msgf("Start listening on %s", listener.Addr())
|
||||||
|
if err := http.Serve(listener, httpsHandler); err != nil {
|
||||||
|
log.Panic().Err(err).Msg("Couldn't start fastServer")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
@@ -1,68 +0,0 @@
|
|||||||
package debug_stepper
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var Enabled = strings.HasSuffix(os.Args[0], ".test") || os.Getenv("DEBUG") == "1"
|
|
||||||
|
|
||||||
var Logger = func(s string, i ...interface{}) {
|
|
||||||
fmt.Printf(s, i...)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Stepper struct {
|
|
||||||
Name string
|
|
||||||
Start time.Time
|
|
||||||
LastStep time.Time
|
|
||||||
Completion time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func Start(name string) *Stepper {
|
|
||||||
if !Enabled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
t := time.Now()
|
|
||||||
Logger("%s: started at %s\n", name, t.Format(time.RFC3339))
|
|
||||||
return &Stepper{
|
|
||||||
Name: name,
|
|
||||||
Start: t,
|
|
||||||
LastStep: t,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Stepper) Debug(text string) {
|
|
||||||
if !Enabled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t := time.Now()
|
|
||||||
Logger("%s: %s (at %s, %s since last step, %s since start)\n", s.Name, text, t.Format(time.RFC3339), t.Sub(s.LastStep).String(), t.Sub(s.Start).String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Stepper) Step(description string) {
|
|
||||||
if !Enabled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if s.Completion != (time.Time{}) {
|
|
||||||
Logger("%s: already completed all tasks.\n")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t := time.Now()
|
|
||||||
Logger("%s: completed %s at %s (%s)\n", s.Name, description, t.Format(time.RFC3339), t.Sub(s.LastStep).String())
|
|
||||||
s.LastStep = t
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Stepper) Complete() {
|
|
||||||
if !Enabled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if s.Completion != (time.Time{}) {
|
|
||||||
Logger("%s: already completed all tasks.\n")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t := time.Now()
|
|
||||||
Logger("%s: completed all tasks at %s (%s since last step; total time: %s)\n", s.Name, t.Format(time.RFC3339), t.Sub(s.LastStep).String(), t.Sub(s.Start).String())
|
|
||||||
s.Completion = t
|
|
||||||
}
|
|
112
domains.go
112
domains.go
@@ -1,112 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/OrlovEvgeny/go-mcache"
|
|
||||||
"github.com/valyala/fasthttp"
|
|
||||||
"net"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DnsLookupCacheTimeout specifies the timeout for the DNS lookup cache.
|
|
||||||
var DnsLookupCacheTimeout = 15*time.Minute
|
|
||||||
// dnsLookupCache stores DNS lookups for custom domains
|
|
||||||
var dnsLookupCache = mcache.New()
|
|
||||||
|
|
||||||
// getTargetFromDNS searches for CNAME or TXT entries on the request domain ending with MainDomainSuffix.
|
|
||||||
// If everything is fine, it returns the target data.
|
|
||||||
func getTargetFromDNS(domain string) (targetOwner, targetRepo, targetBranch string) {
|
|
||||||
// Get CNAME or TXT
|
|
||||||
var cname string
|
|
||||||
var err error
|
|
||||||
if cachedName, ok := dnsLookupCache.Get(domain); ok {
|
|
||||||
cname = cachedName.(string)
|
|
||||||
} else {
|
|
||||||
cname, err = net.LookupCNAME(domain)
|
|
||||||
cname = strings.TrimSuffix(cname, ".")
|
|
||||||
if err != nil || !strings.HasSuffix(cname, string(MainDomainSuffix)) {
|
|
||||||
cname = ""
|
|
||||||
// TODO: check if the A record matches!
|
|
||||||
names, err := net.LookupTXT(domain)
|
|
||||||
if err == nil {
|
|
||||||
for _, name := range names {
|
|
||||||
name = strings.TrimSuffix(name, ".")
|
|
||||||
if strings.HasSuffix(name, string(MainDomainSuffix)) {
|
|
||||||
cname = name
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = dnsLookupCache.Set(domain, cname, DnsLookupCacheTimeout)
|
|
||||||
}
|
|
||||||
if cname == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cnameParts := strings.Split(strings.TrimSuffix(cname, string(MainDomainSuffix)), ".")
|
|
||||||
targetOwner = cnameParts[len(cnameParts)-1]
|
|
||||||
if len(cnameParts) > 1 {
|
|
||||||
targetRepo = cnameParts[len(cnameParts)-2]
|
|
||||||
}
|
|
||||||
if len(cnameParts) > 2 {
|
|
||||||
targetBranch = cnameParts[len(cnameParts)-3]
|
|
||||||
}
|
|
||||||
if targetRepo == "" {
|
|
||||||
targetRepo = "pages"
|
|
||||||
}
|
|
||||||
if targetBranch == "" && targetRepo != "pages" {
|
|
||||||
targetBranch = "pages"
|
|
||||||
}
|
|
||||||
// if targetBranch is still empty, the caller must find the default branch
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// CanonicalDomainCacheTimeout specifies the timeout for the canonical domain cache.
|
|
||||||
var CanonicalDomainCacheTimeout = 15*time.Minute
|
|
||||||
// canonicalDomainCache stores canonical domains
|
|
||||||
var canonicalDomainCache = mcache.New()
|
|
||||||
|
|
||||||
// checkCanonicalDomain returns the canonical domain specified in the repo (using the file `.canonical-domain`).
|
|
||||||
func checkCanonicalDomain(targetOwner, targetRepo, targetBranch, actualDomain string) (canonicalDomain string, valid bool) {
|
|
||||||
domains := []string{}
|
|
||||||
if cachedValue, ok := canonicalDomainCache.Get(targetOwner + "/" + targetRepo + "/" + targetBranch); ok {
|
|
||||||
domains = cachedValue.([]string)
|
|
||||||
for _, domain := range domains {
|
|
||||||
if domain == actualDomain {
|
|
||||||
valid = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
req := fasthttp.AcquireRequest()
|
|
||||||
req.SetRequestURI(string(GiteaRoot) + "/api/v1/repos/" + targetOwner + "/" + targetRepo + "/raw/" + targetBranch + "/.domains")
|
|
||||||
res := fasthttp.AcquireResponse()
|
|
||||||
|
|
||||||
err := upstreamClient.Do(req, res)
|
|
||||||
if err == nil && res.StatusCode() == fasthttp.StatusOK {
|
|
||||||
for _, domain := range strings.Split(string(res.Body()), "\n") {
|
|
||||||
domain = strings.ToLower(domain)
|
|
||||||
domain = strings.TrimSpace(domain)
|
|
||||||
domain = strings.TrimPrefix(domain, "http://")
|
|
||||||
domain = strings.TrimPrefix(domain, "https://")
|
|
||||||
if len(domain) > 0 && !strings.HasPrefix(domain, "#") && !strings.ContainsAny(domain, "\t /") && strings.ContainsRune(domain, '.') {
|
|
||||||
domains = append(domains, domain)
|
|
||||||
}
|
|
||||||
if domain == actualDomain {
|
|
||||||
valid = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
domains = append(domains, targetOwner + string(MainDomainSuffix))
|
|
||||||
if domains[len(domains) - 1] == actualDomain {
|
|
||||||
valid = true
|
|
||||||
}
|
|
||||||
if targetRepo != "" && targetRepo != "pages" {
|
|
||||||
domains[len(domains) - 1] += "/" + targetRepo
|
|
||||||
}
|
|
||||||
_ = canonicalDomainCache.Set(targetOwner + "/" + targetRepo + "/" + targetBranch, domains, CanonicalDomainCacheTimeout)
|
|
||||||
}
|
|
||||||
canonicalDomain = domains[0]
|
|
||||||
return
|
|
||||||
}
|
|
121
go.mod
121
go.mod
@@ -1,12 +1,127 @@
|
|||||||
module codeberg.org/codeberg/pages
|
module codeberg.org/codeberg/pages
|
||||||
|
|
||||||
go 1.16
|
go 1.19
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
code.gitea.io/sdk/gitea v0.15.1-0.20220729105105-cc14c63cccfa
|
||||||
github.com/OrlovEvgeny/go-mcache v0.0.0-20200121124330-1a8195b34f3a
|
github.com/OrlovEvgeny/go-mcache v0.0.0-20200121124330-1a8195b34f3a
|
||||||
github.com/akrylysov/pogreb v0.10.1
|
github.com/akrylysov/pogreb v0.10.1
|
||||||
github.com/go-acme/lego/v4 v4.5.3
|
github.com/go-acme/lego/v4 v4.5.3
|
||||||
|
github.com/joho/godotenv v1.4.0
|
||||||
github.com/reugn/equalizer v0.0.0-20210216135016-a959c509d7ad
|
github.com/reugn/equalizer v0.0.0-20210216135016-a959c509d7ad
|
||||||
github.com/valyala/fasthttp v1.31.0
|
github.com/rs/zerolog v1.27.0
|
||||||
github.com/valyala/fastjson v1.6.3
|
github.com/stretchr/testify v1.7.0
|
||||||
|
github.com/urfave/cli/v2 v2.3.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
cloud.google.com/go v0.54.0 // indirect
|
||||||
|
github.com/Azure/azure-sdk-for-go v32.4.0+incompatible // indirect
|
||||||
|
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
|
||||||
|
github.com/Azure/go-autorest/autorest v0.11.19 // indirect
|
||||||
|
github.com/Azure/go-autorest/autorest/adal v0.9.13 // indirect
|
||||||
|
github.com/Azure/go-autorest/autorest/azure/auth v0.5.8 // indirect
|
||||||
|
github.com/Azure/go-autorest/autorest/azure/cli v0.4.2 // indirect
|
||||||
|
github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
|
||||||
|
github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect
|
||||||
|
github.com/Azure/go-autorest/autorest/validation v0.3.1 // indirect
|
||||||
|
github.com/Azure/go-autorest/logger v0.2.1 // indirect
|
||||||
|
github.com/Azure/go-autorest/tracing v0.6.0 // indirect
|
||||||
|
github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect
|
||||||
|
github.com/akamai/AkamaiOPEN-edgegrid-golang v1.1.1 // indirect
|
||||||
|
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1183 // indirect
|
||||||
|
github.com/aws/aws-sdk-go v1.39.0 // indirect
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
|
github.com/cenkalti/backoff/v4 v4.1.1 // indirect
|
||||||
|
github.com/cloudflare/cloudflare-go v0.20.0 // indirect
|
||||||
|
github.com/cpu/goacmedns v0.1.1 // indirect
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||||
|
github.com/deepmap/oapi-codegen v1.6.1 // indirect
|
||||||
|
github.com/dimchansky/utfbom v1.1.1 // indirect
|
||||||
|
github.com/dnsimple/dnsimple-go v0.70.1 // indirect
|
||||||
|
github.com/exoscale/egoscale v0.67.0 // indirect
|
||||||
|
github.com/fatih/structs v1.1.0 // indirect
|
||||||
|
github.com/form3tech-oss/jwt-go v3.2.2+incompatible // indirect
|
||||||
|
github.com/go-errors/errors v1.0.1 // indirect
|
||||||
|
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||||
|
github.com/go-resty/resty/v2 v2.1.1-0.20191201195748-d7b97669fe48 // indirect
|
||||||
|
github.com/gofrs/uuid v3.2.0+incompatible // indirect
|
||||||
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
|
||||||
|
github.com/golang/protobuf v1.5.2 // indirect
|
||||||
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
|
github.com/google/uuid v1.1.1 // indirect
|
||||||
|
github.com/googleapis/gax-go/v2 v2.0.5 // indirect
|
||||||
|
github.com/gophercloud/gophercloud v0.16.0 // indirect
|
||||||
|
github.com/gophercloud/utils v0.0.0-20210216074907-f6de111f2eae // indirect
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.1 // indirect
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.7.0 // indirect
|
||||||
|
github.com/hashicorp/go-version v1.6.0 // indirect
|
||||||
|
github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect
|
||||||
|
github.com/infobloxopen/infoblox-go-client v1.1.1 // indirect
|
||||||
|
github.com/jarcoal/httpmock v1.0.6 // indirect
|
||||||
|
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.7 // indirect
|
||||||
|
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect
|
||||||
|
github.com/kolo/xmlrpc v0.0.0-20200310150728-e0350524596b // indirect
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
|
||||||
|
github.com/labbsr0x/bindman-dns-webhook v1.0.2 // indirect
|
||||||
|
github.com/labbsr0x/goh v1.0.1 // indirect
|
||||||
|
github.com/linode/linodego v0.31.1 // indirect
|
||||||
|
github.com/liquidweb/go-lwApi v0.0.5 // indirect
|
||||||
|
github.com/liquidweb/liquidweb-cli v0.6.9 // indirect
|
||||||
|
github.com/liquidweb/liquidweb-go v1.6.3 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||||
|
github.com/miekg/dns v1.1.43 // indirect
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.4.1 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.1 // indirect
|
||||||
|
github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect
|
||||||
|
github.com/nrdcg/auroradns v1.0.1 // indirect
|
||||||
|
github.com/nrdcg/desec v0.6.0 // indirect
|
||||||
|
github.com/nrdcg/dnspod-go v0.4.0 // indirect
|
||||||
|
github.com/nrdcg/freemyip v0.2.0 // indirect
|
||||||
|
github.com/nrdcg/goinwx v0.8.1 // indirect
|
||||||
|
github.com/nrdcg/namesilo v0.2.1 // indirect
|
||||||
|
github.com/nrdcg/porkbun v0.1.1 // indirect
|
||||||
|
github.com/oracle/oci-go-sdk v24.3.0+incompatible // indirect
|
||||||
|
github.com/ovh/go-ovh v1.1.0 // indirect
|
||||||
|
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/pquerna/otp v1.3.0 // indirect
|
||||||
|
github.com/russross/blackfriday/v2 v2.0.1 // indirect
|
||||||
|
github.com/sacloud/libsacloud v1.36.2 // indirect
|
||||||
|
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.7.0.20210127161313-bd30bebeac4f // indirect
|
||||||
|
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||||
|
github.com/sirupsen/logrus v1.4.2 // indirect
|
||||||
|
github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect
|
||||||
|
github.com/softlayer/softlayer-go v1.0.3 // indirect
|
||||||
|
github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect
|
||||||
|
github.com/spf13/cast v1.3.1 // indirect
|
||||||
|
github.com/stretchr/objx v0.3.0 // indirect
|
||||||
|
github.com/transip/gotransip/v6 v6.6.1 // indirect
|
||||||
|
github.com/vinyldns/go-vinyldns v0.0.0-20200917153823-148a5f6b8f14 // indirect
|
||||||
|
github.com/vultr/govultr/v2 v2.7.1 // indirect
|
||||||
|
go.opencensus.io v0.22.3 // indirect
|
||||||
|
go.uber.org/ratelimit v0.0.0-20180316092928-c15da0234277 // indirect
|
||||||
|
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect
|
||||||
|
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d // indirect
|
||||||
|
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
|
||||||
|
golang.org/x/text v0.3.6 // indirect
|
||||||
|
golang.org/x/time v0.0.0-20210611083556-38a9dc6acbc6 // indirect
|
||||||
|
google.golang.org/api v0.20.0 // indirect
|
||||||
|
google.golang.org/appengine v1.6.5 // indirect
|
||||||
|
google.golang.org/genproto v0.0.0-20200305110556-506484158171 // indirect
|
||||||
|
google.golang.org/grpc v1.27.1 // indirect
|
||||||
|
google.golang.org/protobuf v1.26.0 // indirect
|
||||||
|
gopkg.in/ini.v1 v1.62.0 // indirect
|
||||||
|
gopkg.in/ns1/ns1-go.v2 v2.6.2 // indirect
|
||||||
|
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||||
)
|
)
|
||||||
|
50
go.sum
50
go.sum
@@ -22,6 +22,8 @@ cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIA
|
|||||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||||
|
code.gitea.io/sdk/gitea v0.15.1-0.20220729105105-cc14c63cccfa h1:OVwgYrY6vr6gWZvgnmevFhtL0GVA4HKaFOhD+joPoNk=
|
||||||
|
code.gitea.io/sdk/gitea v0.15.1-0.20220729105105-cc14c63cccfa/go.mod h1:aRmrQC3CAHdJAU1LQt0C9zqzqI8tUB/5oQtNE746aYE=
|
||||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||||
github.com/Azure/azure-sdk-for-go v32.4.0+incompatible h1:1JP8SKfroEakYiQU2ZyPDosh8w2Tg9UopKt88VyQPt4=
|
github.com/Azure/azure-sdk-for-go v32.4.0+incompatible h1:1JP8SKfroEakYiQU2ZyPDosh8w2Tg9UopKt88VyQPt4=
|
||||||
github.com/Azure/azure-sdk-for-go v32.4.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
|
github.com/Azure/azure-sdk-for-go v32.4.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
|
||||||
@@ -66,8 +68,6 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy
|
|||||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1183 h1:dkj8/dxOQ4L1XpwCzRLqukvUBbxuNdz3FeyvHFnRjmo=
|
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1183 h1:dkj8/dxOQ4L1XpwCzRLqukvUBbxuNdz3FeyvHFnRjmo=
|
||||||
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1183/go.mod h1:pUKYbK5JQ+1Dfxk80P0qxGqe5dkxDoabbZS7zOcouyA=
|
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1183/go.mod h1:pUKYbK5JQ+1Dfxk80P0qxGqe5dkxDoabbZS7zOcouyA=
|
||||||
github.com/andybalholm/brotli v1.0.2 h1:JKnhI/XQ75uFBTiuzXpzFrUriDPiZjlOSzh6wXogP0E=
|
|
||||||
github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
|
||||||
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||||
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
|
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
|
||||||
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||||
@@ -95,15 +95,19 @@ github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkE
|
|||||||
github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||||
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
||||||
github.com/cpu/goacmedns v0.1.1 h1:DM3H2NiN2oam7QljgGY5ygy4yDXhK5Z4JUnqaugs2C4=
|
github.com/cpu/goacmedns v0.1.1 h1:DM3H2NiN2oam7QljgGY5ygy4yDXhK5Z4JUnqaugs2C4=
|
||||||
github.com/cpu/goacmedns v0.1.1/go.mod h1:MuaouqEhPAHxsbqjgnck5zeghuwBP1dLnPoobeGqugQ=
|
github.com/cpu/goacmedns v0.1.1/go.mod h1:MuaouqEhPAHxsbqjgnck5zeghuwBP1dLnPoobeGqugQ=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||||
github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4=
|
github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||||
|
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||||
github.com/deepmap/oapi-codegen v1.6.1 h1:2BvsmRb6pogGNtr8Ann+esAbSKFXx2CZN18VpAMecnw=
|
github.com/deepmap/oapi-codegen v1.6.1 h1:2BvsmRb6pogGNtr8Ann+esAbSKFXx2CZN18VpAMecnw=
|
||||||
github.com/deepmap/oapi-codegen v1.6.1/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M=
|
github.com/deepmap/oapi-codegen v1.6.1/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M=
|
||||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||||
@@ -134,6 +138,8 @@ github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJ
|
|||||||
github.com/go-cmd/cmd v1.0.5/go.mod h1:y8q8qlK5wQibcw63djSl/ntiHUHXHGdCkPk0j4QeW4s=
|
github.com/go-cmd/cmd v1.0.5/go.mod h1:y8q8qlK5wQibcw63djSl/ntiHUHXHGdCkPk0j4QeW4s=
|
||||||
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
|
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
|
||||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||||
|
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||||
|
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
@@ -148,6 +154,7 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me
|
|||||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||||
github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b h1:/vQ+oYKu+JoyaMPDsv5FzwuL2wwWBgBbtj/YLCi4LuA=
|
github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b h1:/vQ+oYKu+JoyaMPDsv5FzwuL2wwWBgBbtj/YLCi4LuA=
|
||||||
github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw=
|
github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
|
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
|
||||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
@@ -179,7 +186,6 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
|
|||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
||||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
|
||||||
github.com/golangci/lint-1 v0.0.0-20181222135242-d2cdd8c08219/go.mod h1:/X8TswGSh1pIozq4ZwCfxS0WA5JGXguxk94ar/4c87Y=
|
github.com/golangci/lint-1 v0.0.0-20181222135242-d2cdd8c08219/go.mod h1:/X8TswGSh1pIozq4ZwCfxS0WA5JGXguxk94ar/4c87Y=
|
||||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
@@ -240,6 +246,9 @@ github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdv
|
|||||||
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/hashicorp/go-version v1.5.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||||
|
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
|
||||||
|
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||||
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
|
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
|
||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
@@ -263,6 +272,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y
|
|||||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||||
|
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
|
||||||
|
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
|
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
|
||||||
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||||
@@ -277,8 +288,6 @@ github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcM
|
|||||||
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=
|
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=
|
||||||
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.13.4 h1:0zhec2I8zGnjWcKyLl6i3gPqKANCCn5e9xmviEEeX6s=
|
|
||||||
github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
|
|
||||||
github.com/kolo/xmlrpc v0.0.0-20200310150728-e0350524596b h1:DzHy0GlWeF0KAglaTMY7Q+khIFoG8toHP+wLFBVBQJc=
|
github.com/kolo/xmlrpc v0.0.0-20200310150728-e0350524596b h1:DzHy0GlWeF0KAglaTMY7Q+khIFoG8toHP+wLFBVBQJc=
|
||||||
github.com/kolo/xmlrpc v0.0.0-20200310150728-e0350524596b/go.mod h1:o03bZfuBwAXHetKXuInt4S7omeXUu62/A845kiycsSQ=
|
github.com/kolo/xmlrpc v0.0.0-20200310150728-e0350524596b/go.mod h1:o03bZfuBwAXHetKXuInt4S7omeXUu62/A845kiycsSQ=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
@@ -316,12 +325,15 @@ github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc
|
|||||||
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||||
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||||
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||||
|
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
|
||||||
|
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||||
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
|
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
|
||||||
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
|
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
|
||||||
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
|
|
||||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||||
|
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||||
|
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||||
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||||
github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||||
@@ -422,6 +434,10 @@ github.com/reugn/equalizer v0.0.0-20210216135016-a959c509d7ad h1:WtSUHi5zthjudjI
|
|||||||
github.com/reugn/equalizer v0.0.0-20210216135016-a959c509d7ad/go.mod h1:h0+DiDRe2Y+6iHTjIq/9HzUq7NII/Nffp0HkFrsAKq4=
|
github.com/reugn/equalizer v0.0.0-20210216135016-a959c509d7ad/go.mod h1:h0+DiDRe2Y+6iHTjIq/9HzUq7NII/Nffp0HkFrsAKq4=
|
||||||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
|
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||||
|
github.com/rs/zerolog v1.27.0 h1:1T7qCieN22GVc8S4Q2yuexzBb1EqjbgjSH9RohbMjKs=
|
||||||
|
github.com/rs/zerolog v1.27.0/go.mod h1:7frBqO0oezxmnO7GF86FY++uy8I0Tk/If5ni1G9Qc0U=
|
||||||
|
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
|
||||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||||
github.com/sacloud/libsacloud v1.36.2 h1:aosI7clbQ9IU0Hj+3rpk3SKJop5nLPpLThnWCivPqjI=
|
github.com/sacloud/libsacloud v1.36.2 h1:aosI7clbQ9IU0Hj+3rpk3SKJop5nLPpLThnWCivPqjI=
|
||||||
@@ -429,6 +445,7 @@ github.com/sacloud/libsacloud v1.36.2/go.mod h1:P7YAOVmnIn3DKHqCZcUKYUXmSwGBm3yS
|
|||||||
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.7.0.20210127161313-bd30bebeac4f h1:WSnaD0/cvbKJgSTYbjAPf4RJXVvNNDAwVm+W8wEmnGE=
|
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.7.0.20210127161313-bd30bebeac4f h1:WSnaD0/cvbKJgSTYbjAPf4RJXVvNNDAwVm+W8wEmnGE=
|
||||||
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.7.0.20210127161313-bd30bebeac4f/go.mod h1:CJJ5VAbozOl0yEw7nHB9+7BXTJbIn6h7W+f6Gau5IP8=
|
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.7.0.20210127161313-bd30bebeac4f/go.mod h1:CJJ5VAbozOl0yEw7nHB9+7BXTJbIn6h7W+f6Gau5IP8=
|
||||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||||
|
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||||
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
||||||
@@ -479,16 +496,11 @@ github.com/transip/gotransip/v6 v6.6.1/go.mod h1:pQZ36hWWRahCUXkFWlx9Hs711gLd8J4
|
|||||||
github.com/uber-go/atomic v1.3.2 h1:Azu9lPBWRNKzYXSIwRfgRuDuS0YKsK4NFhiQv98gkxo=
|
github.com/uber-go/atomic v1.3.2 h1:Azu9lPBWRNKzYXSIwRfgRuDuS0YKsK4NFhiQv98gkxo=
|
||||||
github.com/uber-go/atomic v1.3.2/go.mod h1:/Ct5t2lcmbJ4OSe/waGBoaVvVqtO0bmtfVNex1PFV8g=
|
github.com/uber-go/atomic v1.3.2/go.mod h1:/Ct5t2lcmbJ4OSe/waGBoaVvVqtO0bmtfVNex1PFV8g=
|
||||||
github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||||
|
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
|
||||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasthttp v1.31.0 h1:lrauRLII19afgCs2fnWRJ4M5IkV0lo2FqA61uGkNBfE=
|
|
||||||
github.com/valyala/fasthttp v1.31.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus=
|
|
||||||
github.com/valyala/fastjson v1.6.3 h1:tAKFnnwmeMGPbwJ7IwxcTPCNr3uIzoIj3/Fh90ra4xc=
|
|
||||||
github.com/valyala/fastjson v1.6.3/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
|
||||||
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
|
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
|
||||||
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
|
||||||
github.com/vinyldns/go-vinyldns v0.0.0-20200917153823-148a5f6b8f14 h1:TFXGGMHmml4rs29PdPisC/aaCzOxUu1Vsh9on/IpUfE=
|
github.com/vinyldns/go-vinyldns v0.0.0-20200917153823-148a5f6b8f14 h1:TFXGGMHmml4rs29PdPisC/aaCzOxUu1Vsh9on/IpUfE=
|
||||||
github.com/vinyldns/go-vinyldns v0.0.0-20200917153823-148a5f6b8f14/go.mod h1:RWc47jtnVuQv6+lY3c768WtXCas/Xi+U5UFc5xULmYg=
|
github.com/vinyldns/go-vinyldns v0.0.0-20200917153823-148a5f6b8f14/go.mod h1:RWc47jtnVuQv6+lY3c768WtXCas/Xi+U5UFc5xULmYg=
|
||||||
github.com/vultr/govultr/v2 v2.7.1 h1:uF9ERet++Gb+7Cqs3p1P6b6yebeaZqVd7t5P2uZCaJU=
|
github.com/vultr/govultr/v2 v2.7.1 h1:uF9ERet++Gb+7Cqs3p1P6b6yebeaZqVd7t5P2uZCaJU=
|
||||||
@@ -525,8 +537,10 @@ golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPh
|
|||||||
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||||
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e h1:gsTQYXdTw2Gq7RBsWvlQ91b+aEQ6bXFUngBGuR8sPpI=
|
|
||||||
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c=
|
||||||
|
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
@@ -589,8 +603,9 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v
|
|||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||||
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q=
|
|
||||||
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
@@ -652,10 +667,13 @@ golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
|
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg=
|
||||||
|
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
512
handler.go
512
handler.go
@@ -1,512 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
debug_stepper "codeberg.org/codeberg/pages/debug-stepper"
|
|
||||||
"fmt"
|
|
||||||
"github.com/OrlovEvgeny/go-mcache"
|
|
||||||
"github.com/valyala/fasthttp"
|
|
||||||
"github.com/valyala/fastjson"
|
|
||||||
"io"
|
|
||||||
"mime"
|
|
||||||
"path"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handler handles a single HTTP request to the web server.
|
|
||||||
func handler(ctx *fasthttp.RequestCtx) {
|
|
||||||
s := debug_stepper.Start("handler")
|
|
||||||
defer s.Complete()
|
|
||||||
|
|
||||||
ctx.Response.Header.Set("Server", "Codeberg Pages")
|
|
||||||
|
|
||||||
// Force new default from specification (since November 2020) - see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy#strict-origin-when-cross-origin
|
|
||||||
ctx.Response.Header.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
||||||
|
|
||||||
// Enable caching, but require revalidation to reduce confusion
|
|
||||||
ctx.Response.Header.Set("Cache-Control", "must-revalidate")
|
|
||||||
|
|
||||||
trimmedHost := TrimHostPort(ctx.Request.Host())
|
|
||||||
|
|
||||||
// Add HSTS for RawDomain and MainDomainSuffix
|
|
||||||
if hsts := GetHSTSHeader(trimmedHost); hsts != "" {
|
|
||||||
ctx.Response.Header.Set("Strict-Transport-Security", hsts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block all methods not required for static pages
|
|
||||||
if !ctx.IsGet() && !ctx.IsHead() && !ctx.IsOptions() {
|
|
||||||
ctx.Response.Header.Set("Allow", "GET, HEAD, OPTIONS")
|
|
||||||
ctx.Error("Method not allowed", fasthttp.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block blacklisted paths (like ACME challenges)
|
|
||||||
for _, blacklistedPath := range BlacklistedPaths {
|
|
||||||
if bytes.HasPrefix(ctx.Path(), blacklistedPath) {
|
|
||||||
returnErrorPage(ctx, fasthttp.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow CORS for specified domains
|
|
||||||
if ctx.IsOptions() {
|
|
||||||
allowCors := false
|
|
||||||
for _, allowedCorsDomain := range AllowedCorsDomains {
|
|
||||||
if bytes.Equal(trimmedHost, allowedCorsDomain) {
|
|
||||||
allowCors = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if allowCors {
|
|
||||||
ctx.Response.Header.Set("Access-Control-Allow-Origin", "*")
|
|
||||||
ctx.Response.Header.Set("Access-Control-Allow-Methods", "GET, HEAD")
|
|
||||||
}
|
|
||||||
ctx.Response.Header.Set("Allow", "GET, HEAD, OPTIONS")
|
|
||||||
ctx.Response.Header.SetStatusCode(fasthttp.StatusNoContent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare request information to Gitea
|
|
||||||
var targetOwner, targetRepo, targetBranch, targetPath string
|
|
||||||
var targetOptions = &upstreamOptions{
|
|
||||||
ForbiddenMimeTypes: map[string]struct{}{},
|
|
||||||
TryIndexPages: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// tryBranch checks if a branch exists and populates the target variables. If canonicalLink is non-empty, it will
|
|
||||||
// also disallow search indexing and add a Link header to the canonical URL.
|
|
||||||
var tryBranch = func(repo string, branch string, path []string, canonicalLink string) bool {
|
|
||||||
if repo == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the branch exists, otherwise treat it as a file path
|
|
||||||
branchTimestampResult := getBranchTimestamp(targetOwner, repo, branch)
|
|
||||||
if branchTimestampResult == nil {
|
|
||||||
// branch doesn't exist
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Branch exists, use it
|
|
||||||
targetRepo = repo
|
|
||||||
targetPath = strings.Trim(strings.Join(path, "/"), "/")
|
|
||||||
targetBranch = branchTimestampResult.branch
|
|
||||||
|
|
||||||
targetOptions.BranchTimestamp = branchTimestampResult.timestamp
|
|
||||||
|
|
||||||
if canonicalLink != "" {
|
|
||||||
// Hide from search machines & add canonical link
|
|
||||||
ctx.Response.Header.Set("X-Robots-Tag", "noarchive, noindex")
|
|
||||||
ctx.Response.Header.Set("Link",
|
|
||||||
strings.NewReplacer("%b", targetBranch, "%p", targetPath).Replace(canonicalLink)+
|
|
||||||
"; rel=\"canonical\"",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// tryUpstream forwards the target request to the Gitea API, and shows an error page on failure.
|
|
||||||
var tryUpstream = func() {
|
|
||||||
// check if a canonical domain exists on a request on MainDomain
|
|
||||||
if bytes.HasSuffix(trimmedHost, MainDomainSuffix) {
|
|
||||||
canonicalDomain, _ := checkCanonicalDomain(targetOwner, targetRepo, targetBranch, "")
|
|
||||||
if !strings.HasSuffix(strings.SplitN(canonicalDomain, "/", 2)[0], string(MainDomainSuffix)) {
|
|
||||||
canonicalPath := string(ctx.RequestURI())
|
|
||||||
if targetRepo != "pages" {
|
|
||||||
canonicalPath = "/" + strings.SplitN(canonicalPath, "/", 3)[2]
|
|
||||||
}
|
|
||||||
ctx.Redirect("https://" + canonicalDomain + canonicalPath, fasthttp.StatusTemporaryRedirect)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to request the file from the Gitea API
|
|
||||||
if !upstream(ctx, targetOwner, targetRepo, targetBranch, targetPath, targetOptions) {
|
|
||||||
returnErrorPage(ctx, ctx.Response.StatusCode())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.Step("preparations")
|
|
||||||
|
|
||||||
if RawDomain != nil && bytes.Equal(trimmedHost, RawDomain) {
|
|
||||||
// Serve raw content from RawDomain
|
|
||||||
s.Debug("raw domain")
|
|
||||||
|
|
||||||
targetOptions.TryIndexPages = false
|
|
||||||
targetOptions.ForbiddenMimeTypes["text/html"] = struct{}{}
|
|
||||||
targetOptions.DefaultMimeType = "text/plain; charset=utf-8"
|
|
||||||
|
|
||||||
pathElements := strings.Split(string(bytes.Trim(ctx.Request.URI().Path(), "/")), "/")
|
|
||||||
if len(pathElements) < 2 {
|
|
||||||
// https://{RawDomain}/{owner}/{repo}[/@{branch}]/{path} is required
|
|
||||||
ctx.Redirect(RawInfoPage, fasthttp.StatusTemporaryRedirect)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
targetOwner = pathElements[0]
|
|
||||||
targetRepo = pathElements[1]
|
|
||||||
|
|
||||||
// raw.codeberg.org/example/myrepo/@main/index.html
|
|
||||||
if len(pathElements) > 2 && strings.HasPrefix(pathElements[2], "@") {
|
|
||||||
s.Step("raw domain preparations, now trying with specified branch")
|
|
||||||
if tryBranch(targetRepo, pathElements[2][1:], pathElements[3:],
|
|
||||||
string(GiteaRoot)+"/"+targetOwner+"/"+targetRepo+"/src/branch/%b/%p",
|
|
||||||
) {
|
|
||||||
s.Step("tryBranch, now trying upstream")
|
|
||||||
tryUpstream()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.Debug("missing branch")
|
|
||||||
returnErrorPage(ctx, fasthttp.StatusFailedDependency)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
s.Step("raw domain preparations, now trying with default branch")
|
|
||||||
tryBranch(targetRepo, "", pathElements[2:],
|
|
||||||
string(GiteaRoot)+"/"+targetOwner+"/"+targetRepo+"/src/branch/%b/%p",
|
|
||||||
)
|
|
||||||
s.Step("tryBranch, now trying upstream")
|
|
||||||
tryUpstream()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if bytes.HasSuffix(trimmedHost, MainDomainSuffix) {
|
|
||||||
// Serve pages from subdomains of MainDomainSuffix
|
|
||||||
s.Debug("main domain suffix")
|
|
||||||
|
|
||||||
pathElements := strings.Split(string(bytes.Trim(ctx.Request.URI().Path(), "/")), "/")
|
|
||||||
targetOwner = string(bytes.TrimSuffix(trimmedHost, MainDomainSuffix))
|
|
||||||
targetRepo = pathElements[0]
|
|
||||||
targetPath = strings.Trim(strings.Join(pathElements[1:], "/"), "/")
|
|
||||||
|
|
||||||
// Check if the first directory is a repo with the second directory as a branch
|
|
||||||
// example.codeberg.page/myrepo/@main/index.html
|
|
||||||
if len(pathElements) > 1 && strings.HasPrefix(pathElements[1], "@") {
|
|
||||||
if targetRepo == "pages" {
|
|
||||||
// example.codeberg.org/pages/@... redirects to example.codeberg.org/@...
|
|
||||||
ctx.Redirect("/" + strings.Join(pathElements[1:], "/"), fasthttp.StatusTemporaryRedirect)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.Step("main domain preparations, now trying with specified repo & branch")
|
|
||||||
if tryBranch(pathElements[0], pathElements[1][1:], pathElements[2:],
|
|
||||||
"/"+pathElements[0]+"/%p",
|
|
||||||
) {
|
|
||||||
s.Step("tryBranch, now trying upstream")
|
|
||||||
tryUpstream()
|
|
||||||
} else {
|
|
||||||
returnErrorPage(ctx, fasthttp.StatusFailedDependency)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the first directory is a branch for the "pages" repo
|
|
||||||
// example.codeberg.page/@main/index.html
|
|
||||||
if strings.HasPrefix(pathElements[0], "@") {
|
|
||||||
s.Step("main domain preparations, now trying with specified branch")
|
|
||||||
if tryBranch("pages", pathElements[0][1:], pathElements[1:], "/%p") {
|
|
||||||
s.Step("tryBranch, now trying upstream")
|
|
||||||
tryUpstream()
|
|
||||||
} else {
|
|
||||||
returnErrorPage(ctx, fasthttp.StatusFailedDependency)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the first directory is a repo with a "pages" branch
|
|
||||||
// example.codeberg.page/myrepo/index.html
|
|
||||||
// example.codeberg.page/pages/... is not allowed here.
|
|
||||||
s.Step("main domain preparations, now trying with specified repo")
|
|
||||||
if pathElements[0] != "pages" && tryBranch(pathElements[0], "pages", pathElements[1:], "") {
|
|
||||||
s.Step("tryBranch, now trying upstream")
|
|
||||||
tryUpstream()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to use the "pages" repo on its default branch
|
|
||||||
// example.codeberg.page/index.html
|
|
||||||
s.Step("main domain preparations, now trying with default repo/branch")
|
|
||||||
if tryBranch("pages", "", pathElements, "") {
|
|
||||||
s.Step("tryBranch, now trying upstream")
|
|
||||||
tryUpstream()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Couldn't find a valid repo/branch
|
|
||||||
returnErrorPage(ctx, fasthttp.StatusFailedDependency)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
trimmedHostStr := string(trimmedHost)
|
|
||||||
|
|
||||||
// Serve pages from external domains
|
|
||||||
targetOwner, targetRepo, targetBranch = getTargetFromDNS(trimmedHostStr)
|
|
||||||
if targetOwner == "" {
|
|
||||||
ctx.Redirect(BrokenDNSPage, fasthttp.StatusTemporaryRedirect)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pathElements := strings.Split(string(bytes.Trim(ctx.Request.URI().Path(), "/")), "/")
|
|
||||||
canonicalLink := ""
|
|
||||||
if strings.HasPrefix(pathElements[0], "@") {
|
|
||||||
targetBranch = pathElements[0][1:]
|
|
||||||
pathElements = pathElements[1:]
|
|
||||||
canonicalLink = "/%p"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to use the given repo on the given branch or the default branch
|
|
||||||
s.Step("custom domain preparations, now trying with details from DNS")
|
|
||||||
if tryBranch(targetRepo, targetBranch, pathElements, canonicalLink) {
|
|
||||||
canonicalDomain, valid := checkCanonicalDomain(targetOwner, targetRepo, targetBranch, trimmedHostStr)
|
|
||||||
if !valid {
|
|
||||||
returnErrorPage(ctx, fasthttp.StatusMisdirectedRequest)
|
|
||||||
return
|
|
||||||
} else if canonicalDomain != trimmedHostStr {
|
|
||||||
// only redirect if the target is also a codeberg page!
|
|
||||||
targetOwner, _, _ = getTargetFromDNS(strings.SplitN(canonicalDomain, "/", 2)[0])
|
|
||||||
if targetOwner != "" {
|
|
||||||
ctx.Redirect("https://"+canonicalDomain+string(ctx.RequestURI()), fasthttp.StatusTemporaryRedirect)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
ctx.Redirect(BrokenDNSPage, fasthttp.StatusTemporaryRedirect)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.Step("tryBranch, now trying upstream")
|
|
||||||
tryUpstream()
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
returnErrorPage(ctx, fasthttp.StatusFailedDependency)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// returnErrorPage sets the response status code and writes NotFoundPage to the response body, with "%status" replaced
|
|
||||||
// with the provided status code.
|
|
||||||
func returnErrorPage(ctx *fasthttp.RequestCtx, code int) {
|
|
||||||
ctx.Response.SetStatusCode(code)
|
|
||||||
ctx.Response.Header.SetContentType("text/html; charset=utf-8")
|
|
||||||
message := fasthttp.StatusMessage(code)
|
|
||||||
if code == fasthttp.StatusMisdirectedRequest {
|
|
||||||
message += " - domain not specified in <code>.domains</code> file"
|
|
||||||
}
|
|
||||||
if code == fasthttp.StatusFailedDependency {
|
|
||||||
message += " - owner, repo or branch doesn't exist (if everything's set up correctly, wait up to 15 minutes for cache invalidation)"
|
|
||||||
}
|
|
||||||
ctx.Response.SetBody(bytes.ReplaceAll(NotFoundPage, []byte("%status"), []byte(strconv.Itoa(code)+" "+message)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// BranchExistanceCacheTimeout specifies the timeout for the default branch cache. It can be quite long.
|
|
||||||
var DefaultBranchCacheTimeout = 15*time.Minute
|
|
||||||
// BranchExistanceCacheTimeout specifies the timeout for the branch timestamp & existance cache. It should be shorter
|
|
||||||
// than FileCacheTimeout, as that gets invalidated if the branch timestamp has changed. That way, repo changes will be
|
|
||||||
// picked up faster, while still allowing the content to be cached longer if nothing changes.
|
|
||||||
var BranchExistanceCacheTimeout = 5*time.Minute
|
|
||||||
// branchTimestampCache stores branch timestamps for faster cache checking
|
|
||||||
var branchTimestampCache = mcache.New()
|
|
||||||
type branchTimestamp struct {
|
|
||||||
branch string
|
|
||||||
timestamp time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileCacheTimeout specifies the timeout for the file content cache - you might want to make this quite long, depending
|
|
||||||
// on your available memory.
|
|
||||||
var FileCacheTimeout = 5*time.Minute
|
|
||||||
// FileCacheSizeLimit limits the maximum file size that will be cached, and is set to 1 MB by default.
|
|
||||||
var FileCacheSizeLimit = 1024 * 1024
|
|
||||||
// fileResponseCache stores responses from the Gitea server
|
|
||||||
// TODO: make this an MRU cache with a size limit
|
|
||||||
var fileResponseCache = mcache.New()
|
|
||||||
type fileResponse struct {
|
|
||||||
exists bool
|
|
||||||
mimeType string
|
|
||||||
body []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
// getBranchTimestamp finds the default branch (if branch is "") and returns the last modification time of the branch
|
|
||||||
// (or nil if the branch doesn't exist)
|
|
||||||
func getBranchTimestamp(owner, repo, branch string) *branchTimestamp {
|
|
||||||
if result, ok := branchTimestampCache.Get(owner + "/" + repo + "/" + branch); ok {
|
|
||||||
if result == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return result.(*branchTimestamp)
|
|
||||||
}
|
|
||||||
result := &branchTimestamp{}
|
|
||||||
result.branch = branch
|
|
||||||
if branch == "" {
|
|
||||||
// Get default branch
|
|
||||||
var body = make([]byte, 0)
|
|
||||||
status, body, err := fasthttp.GetTimeout(body, string(GiteaRoot)+"/api/v1/repos/"+owner+"/"+repo, 5 * time.Second)
|
|
||||||
if err != nil || status != 200 {
|
|
||||||
_ = branchTimestampCache.Set(owner + "/" + repo + "/" + branch, nil, DefaultBranchCacheTimeout)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
result.branch = fastjson.GetString(body, "default_branch")
|
|
||||||
}
|
|
||||||
|
|
||||||
var body = make([]byte, 0)
|
|
||||||
status, body, err := fasthttp.GetTimeout(body, string(GiteaRoot)+"/api/v1/repos/"+owner+"/"+repo+"/branches/"+branch, 5 * time.Second)
|
|
||||||
if err != nil || status != 200 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result.timestamp, _ = time.Parse(time.RFC3339, fastjson.GetString(body, "commit", "timestamp"))
|
|
||||||
_ = branchTimestampCache.Set(owner + "/" + repo + "/" + branch, result, BranchExistanceCacheTimeout)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
var upstreamClient = fasthttp.Client{
|
|
||||||
ReadTimeout: 10 * time.Second,
|
|
||||||
MaxConnDuration: 60 * time.Second,
|
|
||||||
MaxConnWaitTimeout: 1000 * time.Millisecond,
|
|
||||||
MaxConnsPerHost: 128 * 16, // TODO: adjust bottlenecks for best performance with Gitea!
|
|
||||||
}
|
|
||||||
|
|
||||||
// upstream requests a file from the Gitea API at GiteaRoot and writes it to the request context.
|
|
||||||
func upstream(ctx *fasthttp.RequestCtx, targetOwner string, targetRepo string, targetBranch string, targetPath string, options *upstreamOptions) (final bool) {
|
|
||||||
s := debug_stepper.Start("upstream")
|
|
||||||
defer s.Complete()
|
|
||||||
|
|
||||||
if options.ForbiddenMimeTypes == nil {
|
|
||||||
options.ForbiddenMimeTypes = map[string]struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the branch exists and when it was modified
|
|
||||||
if options.BranchTimestamp == (time.Time{}) {
|
|
||||||
branch := getBranchTimestamp(targetOwner, targetRepo, targetBranch)
|
|
||||||
|
|
||||||
if branch == nil {
|
|
||||||
returnErrorPage(ctx, fasthttp.StatusFailedDependency)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
targetBranch = branch.branch
|
|
||||||
options.BranchTimestamp = branch.timestamp
|
|
||||||
}
|
|
||||||
|
|
||||||
if targetOwner == "" || targetRepo == "" || targetBranch == "" {
|
|
||||||
returnErrorPage(ctx, fasthttp.StatusBadRequest)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the browser has a cached version
|
|
||||||
if ifModifiedSince, err := time.Parse(time.RFC1123, string(ctx.Request.Header.Peek("If-Modified-Since"))); err == nil {
|
|
||||||
if !ifModifiedSince.Before(options.BranchTimestamp) {
|
|
||||||
ctx.Response.SetStatusCode(fasthttp.StatusNotModified)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.Step("preparations")
|
|
||||||
|
|
||||||
// Make a GET request to the upstream URL
|
|
||||||
uri := targetOwner + "/" + targetRepo + "/raw/" + targetBranch + "/" + targetPath
|
|
||||||
var req *fasthttp.Request
|
|
||||||
var res *fasthttp.Response
|
|
||||||
var cachedResponse fileResponse
|
|
||||||
var err error
|
|
||||||
if cachedValue, ok := fileResponseCache.Get(uri + "?timestamp=" + strconv.FormatInt(options.BranchTimestamp.Unix(), 10)); ok {
|
|
||||||
cachedResponse = cachedValue.(fileResponse)
|
|
||||||
} else {
|
|
||||||
req = fasthttp.AcquireRequest()
|
|
||||||
req.SetRequestURI(string(GiteaRoot) + "/api/v1/repos/" + uri)
|
|
||||||
res = fasthttp.AcquireResponse()
|
|
||||||
res.SetBodyStream(&strings.Reader{}, -1)
|
|
||||||
err = upstreamClient.Do(req, res)
|
|
||||||
}
|
|
||||||
s.Step("acquisition")
|
|
||||||
|
|
||||||
// Handle errors
|
|
||||||
if (res == nil && !cachedResponse.exists) || (res != nil && res.StatusCode() == fasthttp.StatusNotFound) {
|
|
||||||
if options.TryIndexPages {
|
|
||||||
// copy the options struct & try if an index page exists
|
|
||||||
optionsForIndexPages := *options
|
|
||||||
optionsForIndexPages.TryIndexPages = false
|
|
||||||
optionsForIndexPages.AppendTrailingSlash = true
|
|
||||||
for _, indexPage := range IndexPages {
|
|
||||||
if upstream(ctx, targetOwner, targetRepo, targetBranch, strings.TrimSuffix(targetPath, "/")+"/"+indexPage, &optionsForIndexPages) {
|
|
||||||
_ = fileResponseCache.Set(uri + "?timestamp=" + strconv.FormatInt(options.BranchTimestamp.Unix(), 10), fileResponse{
|
|
||||||
exists: false,
|
|
||||||
}, FileCacheTimeout)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.Response.SetStatusCode(fasthttp.StatusNotFound)
|
|
||||||
if res != nil {
|
|
||||||
// Update cache if the request is fresh
|
|
||||||
_ = fileResponseCache.Set(uri + "?timestamp=" + strconv.FormatInt(options.BranchTimestamp.Unix(), 10), fileResponse{
|
|
||||||
exists: false,
|
|
||||||
}, FileCacheTimeout)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if res != nil && (err != nil || res.StatusCode() != fasthttp.StatusOK) {
|
|
||||||
fmt.Printf("Couldn't fetch contents from \"%s\": %s (status code %d)\n", req.RequestURI(), err, res.StatusCode())
|
|
||||||
returnErrorPage(ctx, fasthttp.StatusInternalServerError)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append trailing slash if missing (for index files)
|
|
||||||
// options.AppendTrailingSlash is only true when looking for index pages
|
|
||||||
if options.AppendTrailingSlash && !bytes.HasSuffix(ctx.Request.URI().Path(), []byte{'/'}) {
|
|
||||||
ctx.Redirect(string(ctx.Request.URI().Path())+"/", fasthttp.StatusTemporaryRedirect)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
s.Step("error handling")
|
|
||||||
|
|
||||||
// Set the MIME type
|
|
||||||
mimeType := mime.TypeByExtension(path.Ext(targetPath))
|
|
||||||
mimeTypeSplit := strings.SplitN(mimeType, ";", 2)
|
|
||||||
if _, ok := options.ForbiddenMimeTypes[mimeTypeSplit[0]]; ok || mimeType == "" {
|
|
||||||
if options.DefaultMimeType != "" {
|
|
||||||
mimeType = options.DefaultMimeType
|
|
||||||
} else {
|
|
||||||
mimeType = "application/octet-stream"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.Response.Header.SetContentType(mimeType)
|
|
||||||
|
|
||||||
// Everything's okay so far
|
|
||||||
ctx.Response.SetStatusCode(fasthttp.StatusOK)
|
|
||||||
ctx.Response.Header.SetLastModified(options.BranchTimestamp)
|
|
||||||
|
|
||||||
s.Step("response preparations")
|
|
||||||
|
|
||||||
// Write the response body to the original request
|
|
||||||
var cacheBodyWriter bytes.Buffer
|
|
||||||
if res != nil {
|
|
||||||
if res.Header.ContentLength() > FileCacheSizeLimit {
|
|
||||||
err = res.BodyWriteTo(ctx.Response.BodyWriter())
|
|
||||||
} else {
|
|
||||||
err = res.BodyWriteTo(io.MultiWriter(ctx.Response.BodyWriter(), &cacheBodyWriter))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_, err = ctx.Write(cachedResponse.body)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Couldn't write body for \"%s\": %s\n", req.RequestURI(), err)
|
|
||||||
returnErrorPage(ctx, fasthttp.StatusInternalServerError)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
s.Step("response")
|
|
||||||
|
|
||||||
if res != nil {
|
|
||||||
cachedResponse.exists = true
|
|
||||||
cachedResponse.mimeType = mimeType
|
|
||||||
cachedResponse.body = cacheBodyWriter.Bytes()
|
|
||||||
_ = fileResponseCache.Set(uri + "?timestamp=" + strconv.FormatInt(options.BranchTimestamp.Unix(), 10), cachedResponse, FileCacheTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// upstreamOptions provides various options for the upstream request.
|
|
||||||
type upstreamOptions struct {
|
|
||||||
DefaultMimeType string
|
|
||||||
ForbiddenMimeTypes map[string]struct{}
|
|
||||||
TryIndexPages bool
|
|
||||||
AppendTrailingSlash bool
|
|
||||||
BranchTimestamp time.Time
|
|
||||||
}
|
|
@@ -1,54 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"github.com/valyala/fasthttp"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestHandlerPerformance(t *testing.T) {
|
|
||||||
ctx := &fasthttp.RequestCtx{
|
|
||||||
Request: *fasthttp.AcquireRequest(),
|
|
||||||
Response: *fasthttp.AcquireResponse(),
|
|
||||||
}
|
|
||||||
ctx.Request.SetRequestURI("http://mondstern.codeberg.page/")
|
|
||||||
fmt.Printf("Start: %v\n", time.Now())
|
|
||||||
start := time.Now()
|
|
||||||
handler(ctx)
|
|
||||||
end := time.Now()
|
|
||||||
fmt.Printf("Done: %v\n", time.Now())
|
|
||||||
if ctx.Response.StatusCode() != 200 || len(ctx.Response.Body()) < 2048 {
|
|
||||||
t.Errorf("request failed with status code %d and body length %d", ctx.Response.StatusCode(), len(ctx.Response.Body()))
|
|
||||||
} else {
|
|
||||||
t.Logf("request took %d milliseconds", end.Sub(start).Milliseconds())
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.Response.Reset()
|
|
||||||
ctx.Response.ResetBody()
|
|
||||||
fmt.Printf("Start: %v\n", time.Now())
|
|
||||||
start = time.Now()
|
|
||||||
handler(ctx)
|
|
||||||
end = time.Now()
|
|
||||||
fmt.Printf("Done: %v\n", time.Now())
|
|
||||||
if ctx.Response.StatusCode() != 200 || len(ctx.Response.Body()) < 2048 {
|
|
||||||
t.Errorf("request failed with status code %d and body length %d", ctx.Response.StatusCode(), len(ctx.Response.Body()))
|
|
||||||
} else {
|
|
||||||
t.Logf("request took %d milliseconds", end.Sub(start).Milliseconds())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
ctx.Response.Reset()
|
|
||||||
ctx.Response.ResetBody()
|
|
||||||
ctx.Request.SetRequestURI("http://example.momar.xyz/")
|
|
||||||
fmt.Printf("Start: %v\n", time.Now())
|
|
||||||
start = time.Now()
|
|
||||||
handler(ctx)
|
|
||||||
end = time.Now()
|
|
||||||
fmt.Printf("Done: %v\n", time.Now())
|
|
||||||
if ctx.Response.StatusCode() != 200 || len(ctx.Response.Body()) < 1 {
|
|
||||||
t.Errorf("request failed with status code %d and body length %d", ctx.Response.StatusCode(), len(ctx.Response.Body()))
|
|
||||||
} else {
|
|
||||||
t.Logf("request took %d milliseconds", end.Sub(start).Milliseconds())
|
|
||||||
}
|
|
||||||
}
|
|
@@ -5,4 +5,4 @@ MIIBCAKCAQEA//////////+t+FRYortKmq/cViAnPTzx2LnFg84tNpWp4TZBFGQz
|
|||||||
YdEIqUuyyOP7uWrat2DX9GgdT0Kj3jlN9K5W7edjcrsZCwenyO4KbXCeAvzhzffi
|
YdEIqUuyyOP7uWrat2DX9GgdT0Kj3jlN9K5W7edjcrsZCwenyO4KbXCeAvzhzffi
|
||||||
7MA0BM0oNC9hkXL+nOmFg/+OTxIy7vKBg8P+OxtMb61zO7X8vC7CIAXFjvGDfRaD
|
7MA0BM0oNC9hkXL+nOmFg/+OTxIy7vKBg8P+OxtMb61zO7X8vC7CIAXFjvGDfRaD
|
||||||
ssbzSibBsu/6iGtCOGEoXJf//////////wIBAg==
|
ssbzSibBsu/6iGtCOGEoXJf//////////wIBAg==
|
||||||
-----END DH PARAMETERS-----
|
-----END DH PARAMETERS-----
|
||||||
|
@@ -19,4 +19,4 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./pages-www:/srv:ro
|
- ./pages-www:/srv:ro
|
||||||
- ./pages.Caddyfile:/etc/caddy/Caddyfile:ro
|
- ./pages.Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
|
||||||
|
@@ -51,6 +51,7 @@ frontend https_sni_frontend
|
|||||||
###################################################
|
###################################################
|
||||||
acl use_http_backend req.ssl_sni -i "codeberg.org"
|
acl use_http_backend req.ssl_sni -i "codeberg.org"
|
||||||
acl use_http_backend req.ssl_sni -i "join.codeberg.org"
|
acl use_http_backend req.ssl_sni -i "join.codeberg.org"
|
||||||
|
# TODO: use this if no SNI exists
|
||||||
use_backend https_termination_backend if use_http_backend
|
use_backend https_termination_backend if use_http_backend
|
||||||
|
|
||||||
############################
|
############################
|
||||||
|
21
helpers.go
21
helpers.go
@@ -1,21 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import "bytes"
|
|
||||||
|
|
||||||
// GetHSTSHeader returns a HSTS header with includeSubdomains & preload for MainDomainSuffix and RawDomain, or an empty
|
|
||||||
// string for custom domains.
|
|
||||||
func GetHSTSHeader(host []byte) string {
|
|
||||||
if bytes.HasSuffix(host, MainDomainSuffix) || bytes.Equal(host, RawDomain) {
|
|
||||||
return "max-age=63072000; includeSubdomains; preload"
|
|
||||||
} else {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TrimHostPort(host []byte) []byte {
|
|
||||||
i := bytes.IndexByte(host, ':')
|
|
||||||
if i >= 0 {
|
|
||||||
return host[:i]
|
|
||||||
}
|
|
||||||
return host
|
|
||||||
}
|
|
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<title>%status</title>
|
<title>%status%</title>
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://design.codeberg.org/design-kit/codeberg.css" />
|
<link rel="stylesheet" href="https://design.codeberg.org/design-kit/codeberg.css" />
|
||||||
<link href="https://fonts.codeberg.org/dist/inter/Inter%20Web/inter.css" rel="stylesheet" />
|
<link href="https://fonts.codeberg.org/dist/inter/Inter%20Web/inter.css" rel="stylesheet" />
|
||||||
@@ -21,12 +21,13 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<i class="fa fa-bug text-primary" style="font-size: 96px;"></i>
|
<i class="fa fa-search text-primary" style="font-size: 96px;"></i>
|
||||||
<h1 class="mb-0 text-primary">
|
<h1 class="mb-0 text-primary">
|
||||||
You found a bug!
|
Page not found!
|
||||||
</h1>
|
</h1>
|
||||||
<h5 class="text-center" style="max-width: 25em;">
|
<h5 class="text-center" style="max-width: 25em;">
|
||||||
Sorry, this page doesn't exist or is inaccessible for other reasons (%status)
|
Sorry, but this page couldn't be found or is inaccessible (%status%).<br/>
|
||||||
|
We hope this isn't a problem on our end ;) - Make sure to check the <a href="https://docs.codeberg.org/codeberg-pages/troubleshooting/" target="_blank">troubleshooting section in the Docs</a>!
|
||||||
</h5>
|
</h5>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
<img src="https://design.codeberg.org/logo-kit/icon.svg" class="align-top">
|
<img src="https://design.codeberg.org/logo-kit/icon.svg" class="align-top">
|
50
html/error.go
Normal file
50
html/error.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package html
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/server/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReturnErrorPage sets the response status code and writes NotFoundPage to the response body,
|
||||||
|
// with "%status%" and %message% replaced with the provided statusCode and msg
|
||||||
|
func ReturnErrorPage(ctx *context.Context, msg string, statusCode int) {
|
||||||
|
ctx.RespWriter.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
ctx.RespWriter.WriteHeader(statusCode)
|
||||||
|
|
||||||
|
msg = generateResponse(msg, statusCode)
|
||||||
|
|
||||||
|
_, _ = ctx.RespWriter.Write([]byte(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: use template engine
|
||||||
|
func generateResponse(msg string, statusCode int) string {
|
||||||
|
if msg == "" {
|
||||||
|
msg = strings.ReplaceAll(NotFoundPage,
|
||||||
|
"%status%",
|
||||||
|
strconv.Itoa(statusCode)+" "+errorMessage(statusCode))
|
||||||
|
} else {
|
||||||
|
msg = strings.ReplaceAll(
|
||||||
|
strings.ReplaceAll(ErrorPage, "%message%", template.HTMLEscapeString(msg)),
|
||||||
|
"%status%",
|
||||||
|
http.StatusText(statusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorMessage(statusCode int) string {
|
||||||
|
message := http.StatusText(statusCode)
|
||||||
|
|
||||||
|
switch statusCode {
|
||||||
|
case http.StatusMisdirectedRequest:
|
||||||
|
message += " - domain not specified in <code>.domains</code> file"
|
||||||
|
case http.StatusFailedDependency:
|
||||||
|
message += " - target repo/branch doesn't exist or is private"
|
||||||
|
}
|
||||||
|
|
||||||
|
return message
|
||||||
|
}
|
38
html/error.html
Normal file
38
html/error.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html class="codeberg-design">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<title>%status%</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://design.codeberg.org/design-kit/codeberg.css" />
|
||||||
|
<link href="https://fonts.codeberg.org/dist/inter/Inter%20Web/inter.css" rel="stylesheet" />
|
||||||
|
<link href="https://fonts.codeberg.org/dist/fontawesome5/css/all.min.css" rel="stylesheet" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0; padding: 1rem; box-sizing: border-box;
|
||||||
|
width: 100%; min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<i class="fa fa-search text-primary" style="font-size: 96px;"></i>
|
||||||
|
<h1 class="mb-0 text-primary">
|
||||||
|
%status%!
|
||||||
|
</h1>
|
||||||
|
<h5 class="text-center" style="max-width: 25em;">
|
||||||
|
Sorry, but this page couldn't be served.<br/>
|
||||||
|
We got an <b>"%message%"</b><br/>
|
||||||
|
We hope this isn't a problem on our end ;) - Make sure to check the <a href="https://docs.codeberg.org/codeberg-pages/troubleshooting/" target="_blank">troubleshooting section in the Docs</a>!
|
||||||
|
</h5>
|
||||||
|
<small class="text-muted">
|
||||||
|
<img src="https://design.codeberg.org/logo-kit/icon.svg" class="align-top">
|
||||||
|
Static pages made easy - <a href="https://codeberg.page">Codeberg Pages</a>
|
||||||
|
</small>
|
||||||
|
</body>
|
||||||
|
</html>
|
38
html/error_test.go
Normal file
38
html/error_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package html
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidMessage(t *testing.T) {
|
||||||
|
testString := "requested blacklisted path"
|
||||||
|
statusCode := http.StatusForbidden
|
||||||
|
|
||||||
|
expected := strings.ReplaceAll(
|
||||||
|
strings.ReplaceAll(ErrorPage, "%message%", testString),
|
||||||
|
"%status%",
|
||||||
|
http.StatusText(statusCode))
|
||||||
|
actual := generateResponse(testString, statusCode)
|
||||||
|
|
||||||
|
if expected != actual {
|
||||||
|
t.Errorf("generated response did not match: expected: '%s', got: '%s'", expected, actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageWithHtml(t *testing.T) {
|
||||||
|
testString := `abc<img src=1 onerror=alert("xss");`
|
||||||
|
escapedString := "abc<img src=1 onerror=alert("xss");"
|
||||||
|
statusCode := http.StatusNotFound
|
||||||
|
|
||||||
|
expected := strings.ReplaceAll(
|
||||||
|
strings.ReplaceAll(ErrorPage, "%message%", escapedString),
|
||||||
|
"%status%",
|
||||||
|
http.StatusText(statusCode))
|
||||||
|
actual := generateResponse(testString, statusCode)
|
||||||
|
|
||||||
|
if expected != actual {
|
||||||
|
t.Errorf("generated response did not match: expected: '%s', got: '%s'", expected, actual)
|
||||||
|
}
|
||||||
|
}
|
9
html/html.go
Normal file
9
html/html.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package html
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
//go:embed 404.html
|
||||||
|
var NotFoundPage string
|
||||||
|
|
||||||
|
//go:embed error.html
|
||||||
|
var ErrorPage string
|
178
integration/get_test.go
Normal file
178
integration/get_test.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
//go:build integration
|
||||||
|
// +build integration
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetRedirect(t *testing.T) {
|
||||||
|
log.Println("=== TestGetRedirect ===")
|
||||||
|
// test custom domain redirect
|
||||||
|
resp, err := getTestHTTPSClient().Get("https://calciumdibromid.localhost.mock.directory:4430")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
if !assert.EqualValues(t, http.StatusTemporaryRedirect, resp.StatusCode) {
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
assert.EqualValues(t, "https://www.cabr2.de/", resp.Header.Get("Location"))
|
||||||
|
assert.EqualValues(t, `<a href="https://www.cabr2.de/">Temporary Redirect</a>.`, strings.TrimSpace(string(getBytes(resp.Body))))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetContent(t *testing.T) {
|
||||||
|
log.Println("=== TestGetContent ===")
|
||||||
|
// test get image
|
||||||
|
resp, err := getTestHTTPSClient().Get("https://cb_pages_tests.localhost.mock.directory:4430/images/827679288a.jpg")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
if !assert.EqualValues(t, http.StatusOK, resp.StatusCode) {
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
assert.EqualValues(t, "image/jpeg", resp.Header.Get("Content-Type"))
|
||||||
|
assert.EqualValues(t, "124635", resp.Header.Get("Content-Length"))
|
||||||
|
assert.EqualValues(t, 124635, getSize(resp.Body))
|
||||||
|
assert.Len(t, resp.Header.Get("ETag"), 42)
|
||||||
|
|
||||||
|
// specify branch
|
||||||
|
resp, err = getTestHTTPSClient().Get("https://cb_pages_tests.localhost.mock.directory:4430/pag/@master/")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
if !assert.NotNil(t, resp) {
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
assert.EqualValues(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.EqualValues(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
|
||||||
|
assert.True(t, getSize(resp.Body) > 1000)
|
||||||
|
assert.Len(t, resp.Header.Get("ETag"), 44)
|
||||||
|
|
||||||
|
// access branch name contains '/'
|
||||||
|
resp, err = getTestHTTPSClient().Get("https://cb_pages_tests.localhost.mock.directory:4430/blumia/@docs~main/")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
if !assert.EqualValues(t, http.StatusOK, resp.StatusCode) {
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
assert.EqualValues(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
|
||||||
|
assert.True(t, getSize(resp.Body) > 100)
|
||||||
|
assert.Len(t, resp.Header.Get("ETag"), 44)
|
||||||
|
|
||||||
|
// TODO: test get of non cachable content (content size > fileCacheSizeLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomDomain(t *testing.T) {
|
||||||
|
log.Println("=== TestCustomDomain ===")
|
||||||
|
resp, err := getTestHTTPSClient().Get("https://mock-pages.codeberg-test.org:4430/README.md")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
if !assert.NotNil(t, resp) {
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
assert.EqualValues(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.EqualValues(t, "text/markdown; charset=utf-8", resp.Header.Get("Content-Type"))
|
||||||
|
assert.EqualValues(t, "106", resp.Header.Get("Content-Length"))
|
||||||
|
assert.EqualValues(t, 106, getSize(resp.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomDomainRedirects(t *testing.T) {
|
||||||
|
log.Println("=== TestCustomDomainRedirects ===")
|
||||||
|
// test redirect from default pages domain to custom domain
|
||||||
|
resp, err := getTestHTTPSClient().Get("https://6543.localhost.mock.directory:4430/test_pages-server_custom-mock-domain/@main/README.md")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
if !assert.NotNil(t, resp) {
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
assert.EqualValues(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||||
|
assert.EqualValues(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
|
||||||
|
// TODO: custom port is not evaluated (witch does hurt tests & dev env only)
|
||||||
|
assert.EqualValues(t, "https://mock-pages.codeberg-test.org/@main/README.md", resp.Header.Get("Location"))
|
||||||
|
assert.EqualValues(t, `https:/codeberg.org/6543/test_pages-server_custom-mock-domain/src/branch/main/README.md; rel="canonical"; rel="canonical"`, resp.Header.Get("Link"))
|
||||||
|
|
||||||
|
// TODO: test redirect from an custom domain to the primary custom domain (www.example.com -> example.com)
|
||||||
|
// (cover bug https://codeberg.org/Codeberg/pages-server/issues/153)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetNotFound(t *testing.T) {
|
||||||
|
log.Println("=== TestGetNotFound ===")
|
||||||
|
// test custom not found pages
|
||||||
|
resp, err := getTestHTTPSClient().Get("https://cb_pages_tests.localhost.mock.directory:4430/pages-404-demo/blah")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
if !assert.NotNil(t, resp) {
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
assert.EqualValues(t, http.StatusNotFound, resp.StatusCode)
|
||||||
|
assert.EqualValues(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
|
||||||
|
assert.EqualValues(t, "37", resp.Header.Get("Content-Length"))
|
||||||
|
assert.EqualValues(t, 37, getSize(resp.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFollowSymlink(t *testing.T) {
|
||||||
|
log.Printf("=== TestFollowSymlink ===\n")
|
||||||
|
|
||||||
|
resp, err := getTestHTTPSClient().Get("https://cb_pages_tests.localhost.mock.directory:4430/tests_for_pages-server/@main/link")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
if !assert.NotNil(t, resp) {
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
assert.EqualValues(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.EqualValues(t, "application/octet-stream", resp.Header.Get("Content-Type"))
|
||||||
|
assert.EqualValues(t, "4", resp.Header.Get("Content-Length"))
|
||||||
|
body := getBytes(resp.Body)
|
||||||
|
assert.EqualValues(t, 4, len(body))
|
||||||
|
assert.EqualValues(t, "abc\n", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLFSSupport(t *testing.T) {
|
||||||
|
log.Printf("=== TestLFSSupport ===\n")
|
||||||
|
|
||||||
|
resp, err := getTestHTTPSClient().Get("https://cb_pages_tests.localhost.mock.directory:4430/tests_for_pages-server/@main/lfs.txt")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
if !assert.NotNil(t, resp) {
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
assert.EqualValues(t, http.StatusOK, resp.StatusCode)
|
||||||
|
body := strings.TrimSpace(string(getBytes(resp.Body)))
|
||||||
|
assert.EqualValues(t, 12, len(body))
|
||||||
|
assert.EqualValues(t, "actual value", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOptions(t *testing.T) {
|
||||||
|
log.Println("=== TestGetOptions ===")
|
||||||
|
req, _ := http.NewRequest(http.MethodOptions, "https://mock-pages.codeberg-test.org:4430/README.md", http.NoBody)
|
||||||
|
resp, err := getTestHTTPSClient().Do(req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
if !assert.NotNil(t, resp) {
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
assert.EqualValues(t, http.StatusNoContent, resp.StatusCode)
|
||||||
|
assert.EqualValues(t, "GET, HEAD, OPTIONS", resp.Header.Get("Allow"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTestHTTPSClient() *http.Client {
|
||||||
|
cookieJar, _ := cookiejar.New(nil)
|
||||||
|
return &http.Client{
|
||||||
|
Jar: cookieJar,
|
||||||
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBytes(stream io.Reader) []byte {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
_, _ = buf.ReadFrom(stream)
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSize(stream io.Reader) int {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
_, _ = buf.ReadFrom(stream)
|
||||||
|
return buf.Len()
|
||||||
|
}
|
62
integration/main_test.go
Normal file
62
integration/main_test.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
//go:build integration
|
||||||
|
// +build integration
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/cmd"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
log.Println("=== TestMain: START Server ===")
|
||||||
|
serverCtx, serverCancel := context.WithCancel(context.Background())
|
||||||
|
if err := startServer(serverCtx); err != nil {
|
||||||
|
log.Fatalf("could not start server: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
serverCancel()
|
||||||
|
log.Println("=== TestMain: Server STOPED ===")
|
||||||
|
}()
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
|
||||||
|
m.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startServer(ctx context.Context) error {
|
||||||
|
args := []string{
|
||||||
|
"--verbose",
|
||||||
|
"--acme-accept-terms", "true",
|
||||||
|
}
|
||||||
|
setEnvIfNotSet("ACME_API", "https://acme.mock.directory")
|
||||||
|
setEnvIfNotSet("PAGES_DOMAIN", "localhost.mock.directory")
|
||||||
|
setEnvIfNotSet("RAW_DOMAIN", "raw.localhost.mock.directory")
|
||||||
|
setEnvIfNotSet("PORT", "4430")
|
||||||
|
|
||||||
|
app := cli.NewApp()
|
||||||
|
app.Name = "pages-server"
|
||||||
|
app.Action = cmd.Serve
|
||||||
|
app.Flags = cmd.ServeFlags
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := app.RunContext(ctx, args); err != nil {
|
||||||
|
log.Fatalf("run server error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setEnvIfNotSet(key, value string) {
|
||||||
|
if _, set := os.LookupEnv(key); !set {
|
||||||
|
os.Setenv(key, value)
|
||||||
|
}
|
||||||
|
}
|
142
main.go
142
main.go
@@ -1,141 +1,31 @@
|
|||||||
// Package main is the new Codeberg Pages server, a solution for serving static pages from Gitea repositories.
|
|
||||||
//
|
|
||||||
// Mapping custom domains is not static anymore, but can be done with DNS:
|
|
||||||
//
|
|
||||||
// 1) add a "domains.txt" text file to your repository, containing the allowed domains, separated by new lines. The
|
|
||||||
// first line will be the canonical domain/URL; all other occurrences will be redirected to it.
|
|
||||||
//
|
|
||||||
// 2) add a CNAME entry to your domain, pointing to "[[{branch}.]{repo}.]{owner}.codeberg.page" (repo defaults to
|
|
||||||
// "pages", "branch" defaults to the default branch if "repo" is "pages", or to "pages" if "repo" is something else):
|
|
||||||
// www.example.org. IN CNAME main.pages.example.codeberg.page.
|
|
||||||
//
|
|
||||||
// 3) if a CNAME is set for "www.example.org", you can redirect there from the naked domain by adding an ALIAS record
|
|
||||||
// for "example.org" (if your provider allows ALIAS or similar records):
|
|
||||||
// example.org IN ALIAS codeberg.page.
|
|
||||||
//
|
|
||||||
// Certificates are generated, updated and cleaned up automatically via Let's Encrypt through a TLS challenge.
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto/tls"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"time"
|
|
||||||
|
|
||||||
_ "embed"
|
_ "github.com/joho/godotenv/autoload"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
|
||||||
"github.com/valyala/fasthttp"
|
"codeberg.org/codeberg/pages/cmd"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MainDomainSuffix specifies the main domain (starting with a dot) for which subdomains shall be served as static
|
// can be changed with -X on compile
|
||||||
// pages, or used for comparison in CNAME lookups. Static pages can be accessed through
|
var version = "dev"
|
||||||
// https://{owner}.{MainDomain}[/{repo}], with repo defaulting to "pages".
|
|
||||||
var MainDomainSuffix = []byte("." + envOr("PAGES_DOMAIN", "codeberg.page"))
|
|
||||||
|
|
||||||
// GiteaRoot specifies the root URL of the Gitea instance, without a trailing slash.
|
|
||||||
var GiteaRoot = []byte(envOr("GITEA_ROOT", "https://codeberg.org"))
|
|
||||||
|
|
||||||
//go:embed 404.html
|
|
||||||
var NotFoundPage []byte
|
|
||||||
|
|
||||||
// BrokenDNSPage will be shown (with a redirect) when trying to access a domain for which no DNS CNAME record exists.
|
|
||||||
var BrokenDNSPage = envOr("REDIRECT_BROKEN_DNS", "https://docs.codeberg.org/pages/custom-domains/")
|
|
||||||
|
|
||||||
// RawDomain specifies the domain from which raw repository content shall be served in the following format:
|
|
||||||
// https://{RawDomain}/{owner}/{repo}[/{branch|tag|commit}/{version}]/{filepath...}
|
|
||||||
// (set to []byte(nil) to disable raw content hosting)
|
|
||||||
var RawDomain = []byte(envOr("RAW_DOMAIN", "raw.codeberg.org"))
|
|
||||||
|
|
||||||
// RawInfoPage will be shown (with a redirect) when trying to access RawDomain directly (or without owner/repo/path).
|
|
||||||
var RawInfoPage = envOr("REDIRECT_RAW_INFO", "https://docs.codeberg.org/pages/raw-content/")
|
|
||||||
|
|
||||||
// AllowedCorsDomains lists the domains for which Cross-Origin Resource Sharing is allowed.
|
|
||||||
var AllowedCorsDomains = [][]byte{
|
|
||||||
RawDomain,
|
|
||||||
[]byte("fonts.codeberg.org"),
|
|
||||||
[]byte("design.codeberg.org"),
|
|
||||||
}
|
|
||||||
|
|
||||||
// BlacklistedPaths specifies forbidden path prefixes for all Codeberg Pages.
|
|
||||||
var BlacklistedPaths = [][]byte{
|
|
||||||
[]byte("/.well-known/acme-challenge/"),
|
|
||||||
}
|
|
||||||
|
|
||||||
// IndexPages lists pages that may be considered as index pages for directories.
|
|
||||||
var IndexPages = []string{
|
|
||||||
"index.html",
|
|
||||||
}
|
|
||||||
|
|
||||||
// main sets up and starts the web server.
|
|
||||||
func main() {
|
func main() {
|
||||||
// Make sure MainDomain has a trailing dot, and GiteaRoot has no trailing slash
|
app := cli.NewApp()
|
||||||
if !bytes.HasPrefix(MainDomainSuffix, []byte{'.'}) {
|
app.Name = "pages-server"
|
||||||
MainDomainSuffix = append([]byte{'.'}, MainDomainSuffix...)
|
app.Version = version
|
||||||
}
|
app.Usage = "pages server"
|
||||||
GiteaRoot = bytes.TrimSuffix(GiteaRoot, []byte{'/'})
|
app.Action = cmd.Serve
|
||||||
|
app.Flags = cmd.ServeFlags
|
||||||
// Use HOST and PORT environment variables to determine listening address
|
app.Commands = []*cli.Command{
|
||||||
address := fmt.Sprintf("%s:%s", envOr("HOST", "[::]"), envOr("PORT", "443"))
|
cmd.Certs,
|
||||||
log.Printf("Listening on https://%s", address)
|
|
||||||
|
|
||||||
// Enable compression by wrapping the handler() method with the compression function provided by FastHTTP
|
|
||||||
compressedHandler := fasthttp.CompressHandlerBrotliLevel(handler, fasthttp.CompressBrotliBestSpeed, fasthttp.CompressBestSpeed)
|
|
||||||
|
|
||||||
server := &fasthttp.Server{
|
|
||||||
Handler: compressedHandler,
|
|
||||||
DisablePreParseMultipartForm: false,
|
|
||||||
MaxRequestBodySize: 0,
|
|
||||||
NoDefaultServerHeader: true,
|
|
||||||
NoDefaultDate: true,
|
|
||||||
ReadTimeout: 30 * time.Second, // needs to be this high for ACME certificates with ZeroSSL & HTTP-01 challenge
|
|
||||||
Concurrency: 1024 * 32, // TODO: adjust bottlenecks for best performance with Gitea!
|
|
||||||
MaxConnsPerIP: 100,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup listener and TLS
|
if err := app.Run(os.Args); err != nil {
|
||||||
listener, err := net.Listen("tcp", address)
|
_, _ = fmt.Fprintln(os.Stderr, err)
|
||||||
if err != nil {
|
os.Exit(1)
|
||||||
log.Fatalf("Couldn't create listener: %s", err)
|
|
||||||
}
|
|
||||||
listener = tls.NewListener(listener, tlsConfig)
|
|
||||||
|
|
||||||
setupCertificates()
|
|
||||||
if os.Getenv("ENABLE_HTTP_SERVER") == "true" {
|
|
||||||
go (func() {
|
|
||||||
challengePath := []byte("/.well-known/acme-challenge/")
|
|
||||||
err := fasthttp.ListenAndServe("[::]:80", func(ctx *fasthttp.RequestCtx) {
|
|
||||||
if bytes.HasPrefix(ctx.Path(), challengePath) {
|
|
||||||
challenge, ok := challengeCache.Get(string(TrimHostPort(ctx.Host())) + "/" + string(bytes.TrimPrefix(ctx.Path(), challengePath)))
|
|
||||||
if !ok || challenge == nil {
|
|
||||||
ctx.SetStatusCode(http.StatusNotFound)
|
|
||||||
ctx.SetBodyString("no challenge for this token")
|
|
||||||
}
|
|
||||||
ctx.SetBodyString(challenge.(string))
|
|
||||||
} else {
|
|
||||||
ctx.Redirect("https://" + string(ctx.Host()) + string(ctx.RequestURI()), http.StatusMovedPermanently)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Couldn't start HTTP server: %s", err)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start the web server
|
|
||||||
err = server.Serve(listener)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Couldn't start server: %s", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// envOr reads an environment variable and returns a default value if it's empty.
|
|
||||||
func envOr(env string, or string) string {
|
|
||||||
if v := os.Getenv(env); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return or
|
|
||||||
}
|
|
||||||
|
9
server/cache/interface.go
vendored
Normal file
9
server/cache/interface.go
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type SetGetKey interface {
|
||||||
|
Set(key string, value interface{}, ttl time.Duration) error
|
||||||
|
Get(key string) (interface{}, bool)
|
||||||
|
Remove(key string)
|
||||||
|
}
|
7
server/cache/setup.go
vendored
Normal file
7
server/cache/setup.go
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import "github.com/OrlovEvgeny/go-mcache"
|
||||||
|
|
||||||
|
func NewKeyValueCache() SetGetKey {
|
||||||
|
return mcache.New()
|
||||||
|
}
|
29
server/certificates/acme_account.go
Normal file
29
server/certificates/acme_account.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package certificates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
|
||||||
|
"github.com/go-acme/lego/v4/registration"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AcmeAccount struct {
|
||||||
|
Email string
|
||||||
|
Registration *registration.Resource
|
||||||
|
Key crypto.PrivateKey `json:"-"`
|
||||||
|
KeyPEM string `json:"Key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure AcmeAccount match User interface
|
||||||
|
var _ registration.User = &AcmeAccount{}
|
||||||
|
|
||||||
|
func (u *AcmeAccount) GetEmail() string {
|
||||||
|
return u.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u AcmeAccount) GetRegistration() *registration.Resource {
|
||||||
|
return u.Registration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *AcmeAccount) GetPrivateKey() crypto.PrivateKey {
|
||||||
|
return u.Key
|
||||||
|
}
|
553
server/certificates/certificates.go
Normal file
553
server/certificates/certificates.go
Normal file
@@ -0,0 +1,553 @@
|
|||||||
|
package certificates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/gob"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-acme/lego/v4/certcrypto"
|
||||||
|
"github.com/go-acme/lego/v4/certificate"
|
||||||
|
"github.com/go-acme/lego/v4/challenge"
|
||||||
|
"github.com/go-acme/lego/v4/challenge/tlsalpn01"
|
||||||
|
"github.com/go-acme/lego/v4/lego"
|
||||||
|
"github.com/go-acme/lego/v4/providers/dns"
|
||||||
|
"github.com/go-acme/lego/v4/registration"
|
||||||
|
"github.com/reugn/equalizer"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/server/cache"
|
||||||
|
"codeberg.org/codeberg/pages/server/database"
|
||||||
|
dnsutils "codeberg.org/codeberg/pages/server/dns"
|
||||||
|
"codeberg.org/codeberg/pages/server/gitea"
|
||||||
|
"codeberg.org/codeberg/pages/server/upstream"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TLSConfig returns the configuration for generating, serving and cleaning up Let's Encrypt certificates.
|
||||||
|
func TLSConfig(mainDomainSuffix string,
|
||||||
|
giteaClient *gitea.Client,
|
||||||
|
dnsProvider string,
|
||||||
|
acmeUseRateLimits bool,
|
||||||
|
keyCache, challengeCache, dnsLookupCache, canonicalDomainCache cache.SetGetKey,
|
||||||
|
certDB database.CertDB,
|
||||||
|
) *tls.Config {
|
||||||
|
return &tls.Config{
|
||||||
|
// check DNS name & get certificate from Let's Encrypt
|
||||||
|
GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
|
sni := strings.ToLower(strings.TrimSpace(info.ServerName))
|
||||||
|
if len(sni) < 1 {
|
||||||
|
return nil, errors.New("missing sni")
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.SupportedProtos != nil {
|
||||||
|
for _, proto := range info.SupportedProtos {
|
||||||
|
if proto != tlsalpn01.ACMETLS1Protocol {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
challenge, ok := challengeCache.Get(sni)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("no challenge for this domain")
|
||||||
|
}
|
||||||
|
cert, err := tlsalpn01.ChallengeCert(sni, challenge.(string))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cert, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetOwner := ""
|
||||||
|
if strings.HasSuffix(sni, mainDomainSuffix) || strings.EqualFold(sni, mainDomainSuffix[1:]) {
|
||||||
|
// deliver default certificate for the main domain (*.codeberg.page)
|
||||||
|
sni = mainDomainSuffix
|
||||||
|
} else {
|
||||||
|
var targetRepo, targetBranch string
|
||||||
|
targetOwner, targetRepo, targetBranch = dnsutils.GetTargetFromDNS(sni, mainDomainSuffix, dnsLookupCache)
|
||||||
|
if targetOwner == "" {
|
||||||
|
// DNS not set up, return main certificate to redirect to the docs
|
||||||
|
sni = mainDomainSuffix
|
||||||
|
} else {
|
||||||
|
targetOpt := &upstream.Options{
|
||||||
|
TargetOwner: targetOwner,
|
||||||
|
TargetRepo: targetRepo,
|
||||||
|
TargetBranch: targetBranch,
|
||||||
|
}
|
||||||
|
_, valid := targetOpt.CheckCanonicalDomain(giteaClient, sni, mainDomainSuffix, canonicalDomainCache)
|
||||||
|
if !valid {
|
||||||
|
sni = mainDomainSuffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tlsCertificate, ok := keyCache.Get(sni); ok {
|
||||||
|
// we can use an existing certificate object
|
||||||
|
return tlsCertificate.(*tls.Certificate), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var tlsCertificate tls.Certificate
|
||||||
|
var err error
|
||||||
|
var ok bool
|
||||||
|
if tlsCertificate, ok = retrieveCertFromDB(sni, mainDomainSuffix, dnsProvider, acmeUseRateLimits, certDB); !ok {
|
||||||
|
// request a new certificate
|
||||||
|
if strings.EqualFold(sni, mainDomainSuffix) {
|
||||||
|
return nil, errors.New("won't request certificate for main domain, something really bad has happened")
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsCertificate, err = obtainCert(acmeClient, []string{sni}, nil, targetOwner, dnsProvider, mainDomainSuffix, acmeUseRateLimits, certDB)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := keyCache.Set(sni, &tlsCertificate, 15*time.Minute); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &tlsCertificate, nil
|
||||||
|
},
|
||||||
|
PreferServerCipherSuites: true,
|
||||||
|
NextProtos: []string{
|
||||||
|
"h2",
|
||||||
|
"http/1.1",
|
||||||
|
tlsalpn01.ACMETLS1Protocol,
|
||||||
|
},
|
||||||
|
|
||||||
|
// generated 2021-07-13, Mozilla Guideline v5.6, Go 1.14.4, intermediate configuration
|
||||||
|
// https://ssl-config.mozilla.org/#server=go&version=1.14.4&config=intermediate&guideline=5.6
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
CipherSuites: []uint16{
|
||||||
|
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||||
|
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||||
|
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||||
|
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||||
|
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
|
||||||
|
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkUserLimit(user string) error {
|
||||||
|
userLimit, ok := acmeClientCertificateLimitPerUser[user]
|
||||||
|
if !ok {
|
||||||
|
// Each Codeberg user can only add 10 new domains per day.
|
||||||
|
userLimit = equalizer.NewTokenBucket(10, time.Hour*24)
|
||||||
|
acmeClientCertificateLimitPerUser[user] = userLimit
|
||||||
|
}
|
||||||
|
if !userLimit.Ask() {
|
||||||
|
return errors.New("rate limit exceeded: 10 certificates per user per 24 hours")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
acmeClient, mainDomainAcmeClient *lego.Client
|
||||||
|
acmeClientCertificateLimitPerUser = map[string]*equalizer.TokenBucket{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// rate limit is 300 / 3 hours, we want 200 / 2 hours but to refill more often, so that's 25 new domains every 15 minutes
|
||||||
|
// TODO: when this is used a lot, we probably have to think of a somewhat better solution?
|
||||||
|
var acmeClientOrderLimit = equalizer.NewTokenBucket(25, 15*time.Minute)
|
||||||
|
|
||||||
|
// rate limit is 20 / second, we want 5 / second (especially as one cert takes at least two requests)
|
||||||
|
var acmeClientRequestLimit = equalizer.NewTokenBucket(5, 1*time.Second)
|
||||||
|
|
||||||
|
// rate limit is 5 / hour https://letsencrypt.org/docs/failed-validation-limit/
|
||||||
|
var acmeClientFailLimit = equalizer.NewTokenBucket(5, 1*time.Hour)
|
||||||
|
|
||||||
|
type AcmeTLSChallengeProvider struct {
|
||||||
|
challengeCache cache.SetGetKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure AcmeTLSChallengeProvider match Provider interface
|
||||||
|
var _ challenge.Provider = AcmeTLSChallengeProvider{}
|
||||||
|
|
||||||
|
func (a AcmeTLSChallengeProvider) Present(domain, _, keyAuth string) error {
|
||||||
|
return a.challengeCache.Set(domain, keyAuth, 1*time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a AcmeTLSChallengeProvider) CleanUp(domain, _, _ string) error {
|
||||||
|
a.challengeCache.Remove(domain)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type AcmeHTTPChallengeProvider struct {
|
||||||
|
challengeCache cache.SetGetKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure AcmeHTTPChallengeProvider match Provider interface
|
||||||
|
var _ challenge.Provider = AcmeHTTPChallengeProvider{}
|
||||||
|
|
||||||
|
func (a AcmeHTTPChallengeProvider) Present(domain, token, keyAuth string) error {
|
||||||
|
return a.challengeCache.Set(domain+"/"+token, keyAuth, 1*time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a AcmeHTTPChallengeProvider) CleanUp(domain, token, _ string) error {
|
||||||
|
a.challengeCache.Remove(domain + "/" + token)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func retrieveCertFromDB(sni, mainDomainSuffix, dnsProvider string, acmeUseRateLimits bool, certDB database.CertDB) (tls.Certificate, bool) {
|
||||||
|
// parse certificate from database
|
||||||
|
res, err := certDB.Get(sni)
|
||||||
|
if err != nil {
|
||||||
|
panic(err) // TODO: no panic
|
||||||
|
}
|
||||||
|
if res == nil {
|
||||||
|
return tls.Certificate{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsCertificate, err := tls.X509KeyPair(res.Certificate, res.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: document & put into own function
|
||||||
|
if !strings.EqualFold(sni, mainDomainSuffix) {
|
||||||
|
tlsCertificate.Leaf, err = x509.ParseCertificate(tlsCertificate.Certificate[0])
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renew certificates 7 days before they expire
|
||||||
|
if tlsCertificate.Leaf.NotAfter.Before(time.Now().Add(7 * 24 * time.Hour)) {
|
||||||
|
// TODO: add ValidUntil to custom res struct
|
||||||
|
if res.CSR != nil && len(res.CSR) > 0 {
|
||||||
|
// CSR stores the time when the renewal shall be tried again
|
||||||
|
nextTryUnix, err := strconv.ParseInt(string(res.CSR), 10, 64)
|
||||||
|
if err == nil && time.Now().Before(time.Unix(nextTryUnix, 0)) {
|
||||||
|
return tlsCertificate, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go (func() {
|
||||||
|
res.CSR = nil // acme client doesn't like CSR to be set
|
||||||
|
tlsCertificate, err = obtainCert(acmeClient, []string{sni}, res, "", dnsProvider, mainDomainSuffix, acmeUseRateLimits, certDB)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Msgf("Couldn't renew certificate for %s: %v", sni, err)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tlsCertificate, true
|
||||||
|
}
|
||||||
|
|
||||||
|
var obtainLocks = sync.Map{}
|
||||||
|
|
||||||
|
func obtainCert(acmeClient *lego.Client, domains []string, renew *certificate.Resource, user, dnsProvider, mainDomainSuffix string, acmeUseRateLimits bool, keyDatabase database.CertDB) (tls.Certificate, error) {
|
||||||
|
name := strings.TrimPrefix(domains[0], "*")
|
||||||
|
if dnsProvider == "" && len(domains[0]) > 0 && domains[0][0] == '*' {
|
||||||
|
domains = domains[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// lock to avoid simultaneous requests
|
||||||
|
_, working := obtainLocks.LoadOrStore(name, struct{}{})
|
||||||
|
if working {
|
||||||
|
for working {
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
_, working = obtainLocks.Load(name)
|
||||||
|
}
|
||||||
|
cert, ok := retrieveCertFromDB(name, mainDomainSuffix, dnsProvider, acmeUseRateLimits, keyDatabase)
|
||||||
|
if !ok {
|
||||||
|
return tls.Certificate{}, errors.New("certificate failed in synchronous request")
|
||||||
|
}
|
||||||
|
return cert, nil
|
||||||
|
}
|
||||||
|
defer obtainLocks.Delete(name)
|
||||||
|
|
||||||
|
if acmeClient == nil {
|
||||||
|
return mockCert(domains[0], "ACME client uninitialized. This is a server error, please report!", mainDomainSuffix, keyDatabase), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// request actual cert
|
||||||
|
var res *certificate.Resource
|
||||||
|
var err error
|
||||||
|
if renew != nil && renew.CertURL != "" {
|
||||||
|
if acmeUseRateLimits {
|
||||||
|
acmeClientRequestLimit.Take()
|
||||||
|
}
|
||||||
|
log.Debug().Msgf("Renewing certificate for: %v", domains)
|
||||||
|
res, err = acmeClient.Certificate.Renew(*renew, true, false, "")
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msgf("Couldn't renew certificate for %v, trying to request a new one", domains)
|
||||||
|
if acmeUseRateLimits {
|
||||||
|
acmeClientFailLimit.Take()
|
||||||
|
}
|
||||||
|
res = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if res == nil {
|
||||||
|
if user != "" {
|
||||||
|
if err := checkUserLimit(user); err != nil {
|
||||||
|
return tls.Certificate{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if acmeUseRateLimits {
|
||||||
|
acmeClientOrderLimit.Take()
|
||||||
|
acmeClientRequestLimit.Take()
|
||||||
|
}
|
||||||
|
log.Debug().Msgf("Re-requesting new certificate for %v", domains)
|
||||||
|
res, err = acmeClient.Certificate.Obtain(certificate.ObtainRequest{
|
||||||
|
Domains: domains,
|
||||||
|
Bundle: true,
|
||||||
|
MustStaple: false,
|
||||||
|
})
|
||||||
|
if acmeUseRateLimits && err != nil {
|
||||||
|
acmeClientFailLimit.Take()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msgf("Couldn't obtain again a certificate or %v", domains)
|
||||||
|
if renew != nil && renew.CertURL != "" {
|
||||||
|
tlsCertificate, err := tls.X509KeyPair(renew.Certificate, renew.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return mockCert(domains[0], err.Error(), mainDomainSuffix, keyDatabase), err
|
||||||
|
}
|
||||||
|
leaf, err := leaf(&tlsCertificate)
|
||||||
|
if err == nil && leaf.NotAfter.After(time.Now()) {
|
||||||
|
// avoid sending a mock cert instead of a still valid cert, instead abuse CSR field to store time to try again at
|
||||||
|
renew.CSR = []byte(strconv.FormatInt(time.Now().Add(6*time.Hour).Unix(), 10))
|
||||||
|
if err := keyDatabase.Put(name, renew); err != nil {
|
||||||
|
return mockCert(domains[0], err.Error(), mainDomainSuffix, keyDatabase), err
|
||||||
|
}
|
||||||
|
return tlsCertificate, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mockCert(domains[0], err.Error(), mainDomainSuffix, keyDatabase), err
|
||||||
|
}
|
||||||
|
log.Debug().Msgf("Obtained certificate for %v", domains)
|
||||||
|
|
||||||
|
if err := keyDatabase.Put(name, res); err != nil {
|
||||||
|
return tls.Certificate{}, err
|
||||||
|
}
|
||||||
|
tlsCertificate, err := tls.X509KeyPair(res.Certificate, res.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return tls.Certificate{}, err
|
||||||
|
}
|
||||||
|
return tlsCertificate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetupAcmeConfig(acmeAPI, acmeMail, acmeEabHmac, acmeEabKID string, acmeAcceptTerms bool) (*lego.Config, error) {
|
||||||
|
const configFile = "acme-account.json"
|
||||||
|
var myAcmeAccount AcmeAccount
|
||||||
|
var myAcmeConfig *lego.Config
|
||||||
|
|
||||||
|
if account, err := os.ReadFile(configFile); err == nil {
|
||||||
|
if err := json.Unmarshal(account, &myAcmeAccount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
myAcmeAccount.Key, err = certcrypto.ParsePEMPrivateKey([]byte(myAcmeAccount.KeyPEM))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
myAcmeConfig = lego.NewConfig(&myAcmeAccount)
|
||||||
|
myAcmeConfig.CADirURL = acmeAPI
|
||||||
|
myAcmeConfig.Certificate.KeyType = certcrypto.RSA2048
|
||||||
|
|
||||||
|
// Validate Config
|
||||||
|
_, err := lego.NewClient(myAcmeConfig)
|
||||||
|
if err != nil {
|
||||||
|
// TODO: should we fail hard instead?
|
||||||
|
log.Error().Err(err).Msg("Can't create ACME client, continuing with mock certs only")
|
||||||
|
}
|
||||||
|
return myAcmeConfig, nil
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
myAcmeAccount = AcmeAccount{
|
||||||
|
Email: acmeMail,
|
||||||
|
Key: privateKey,
|
||||||
|
KeyPEM: string(certcrypto.PEMEncode(privateKey)),
|
||||||
|
}
|
||||||
|
myAcmeConfig = lego.NewConfig(&myAcmeAccount)
|
||||||
|
myAcmeConfig.CADirURL = acmeAPI
|
||||||
|
myAcmeConfig.Certificate.KeyType = certcrypto.RSA2048
|
||||||
|
tempClient, err := lego.NewClient(myAcmeConfig)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Can't create ACME client, continuing with mock certs only")
|
||||||
|
} else {
|
||||||
|
// accept terms & log in to EAB
|
||||||
|
if acmeEabKID == "" || acmeEabHmac == "" {
|
||||||
|
reg, err := tempClient.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: acmeAcceptTerms})
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Can't register ACME account, continuing with mock certs only")
|
||||||
|
} else {
|
||||||
|
myAcmeAccount.Registration = reg
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reg, err := tempClient.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{
|
||||||
|
TermsOfServiceAgreed: acmeAcceptTerms,
|
||||||
|
Kid: acmeEabKID,
|
||||||
|
HmacEncoded: acmeEabHmac,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Can't register ACME account, continuing with mock certs only")
|
||||||
|
} else {
|
||||||
|
myAcmeAccount.Registration = reg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if myAcmeAccount.Registration != nil {
|
||||||
|
acmeAccountJSON, err := json.Marshal(myAcmeAccount)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("json.Marshalfailed, waiting for manual restart to avoid rate limits")
|
||||||
|
select {}
|
||||||
|
}
|
||||||
|
err = os.WriteFile(configFile, acmeAccountJSON, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("os.WriteFile failed, waiting for manual restart to avoid rate limits")
|
||||||
|
select {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return myAcmeConfig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetupCertificates(mainDomainSuffix, dnsProvider string, acmeConfig *lego.Config, acmeUseRateLimits, enableHTTPServer bool, challengeCache cache.SetGetKey, certDB database.CertDB) error {
|
||||||
|
// getting main cert before ACME account so that we can fail here without hitting rate limits
|
||||||
|
mainCertBytes, err := certDB.Get(mainDomainSuffix)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cert database is not working")
|
||||||
|
}
|
||||||
|
|
||||||
|
acmeClient, err = lego.NewClient(acmeConfig)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("Can't create ACME client, continuing with mock certs only")
|
||||||
|
} else {
|
||||||
|
err = acmeClient.Challenge.SetTLSALPN01Provider(AcmeTLSChallengeProvider{challengeCache})
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Can't create TLS-ALPN-01 provider")
|
||||||
|
}
|
||||||
|
if enableHTTPServer {
|
||||||
|
err = acmeClient.Challenge.SetHTTP01Provider(AcmeHTTPChallengeProvider{challengeCache})
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Can't create HTTP-01 provider")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mainDomainAcmeClient, err = lego.NewClient(acmeConfig)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Can't create ACME client, continuing with mock certs only")
|
||||||
|
} else {
|
||||||
|
if dnsProvider == "" {
|
||||||
|
// using mock server, don't use wildcard certs
|
||||||
|
err := mainDomainAcmeClient.Challenge.SetTLSALPN01Provider(AcmeTLSChallengeProvider{challengeCache})
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Can't create TLS-ALPN-01 provider")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
provider, err := dns.NewDNSChallengeProviderByName(dnsProvider)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Can't create DNS Challenge provider")
|
||||||
|
}
|
||||||
|
err = mainDomainAcmeClient.Challenge.SetDNS01Provider(provider)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Can't create DNS-01 provider")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mainCertBytes == nil {
|
||||||
|
_, err = obtainCert(mainDomainAcmeClient, []string{"*" + mainDomainSuffix, mainDomainSuffix[1:]}, nil, "", dnsProvider, mainDomainSuffix, acmeUseRateLimits, certDB)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Couldn't renew main domain certificate, continuing with mock certs only")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MaintainCertDB(ctx context.Context, interval time.Duration, mainDomainSuffix, dnsProvider string, acmeUseRateLimits bool, certDB database.CertDB) {
|
||||||
|
for {
|
||||||
|
// clean up expired certs
|
||||||
|
now := time.Now()
|
||||||
|
expiredCertCount := 0
|
||||||
|
keyDatabaseIterator := certDB.Items()
|
||||||
|
key, resBytes, err := keyDatabaseIterator.Next()
|
||||||
|
for err == nil {
|
||||||
|
if !strings.EqualFold(string(key), mainDomainSuffix) {
|
||||||
|
resGob := bytes.NewBuffer(resBytes)
|
||||||
|
resDec := gob.NewDecoder(resGob)
|
||||||
|
res := &certificate.Resource{}
|
||||||
|
err = resDec.Decode(res)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsCertificates, err := certcrypto.ParsePEMBundle(res.Certificate)
|
||||||
|
if err != nil || tlsCertificates[0].NotAfter.Before(now) {
|
||||||
|
err := certDB.Delete(string(key))
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msgf("Deleting expired certificate for %q failed", string(key))
|
||||||
|
} else {
|
||||||
|
expiredCertCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key, resBytes, err = keyDatabaseIterator.Next()
|
||||||
|
}
|
||||||
|
log.Debug().Msgf("Removed %d expired certificates from the database", expiredCertCount)
|
||||||
|
|
||||||
|
// compact the database
|
||||||
|
msg, err := certDB.Compact()
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Compacting key database failed")
|
||||||
|
} else {
|
||||||
|
log.Debug().Msgf("Compacted key database: %s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// update main cert
|
||||||
|
res, err := certDB.Get(mainDomainSuffix)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Msgf("Couldn't get cert for domain %q", mainDomainSuffix)
|
||||||
|
} else if res == nil {
|
||||||
|
log.Error().Msgf("Couldn't renew certificate for main domain %q expected main domain cert to exist, but it's missing - seems like the database is corrupted", mainDomainSuffix)
|
||||||
|
} else {
|
||||||
|
tlsCertificates, err := certcrypto.ParsePEMBundle(res.Certificate)
|
||||||
|
|
||||||
|
// renew main certificate 30 days before it expires
|
||||||
|
if tlsCertificates[0].NotAfter.Before(time.Now().Add(30 * 24 * time.Hour)) {
|
||||||
|
go (func() {
|
||||||
|
_, err = obtainCert(mainDomainAcmeClient, []string{"*" + mainDomainSuffix, mainDomainSuffix[1:]}, res, "", dnsProvider, mainDomainSuffix, acmeUseRateLimits, certDB)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Couldn't renew certificate for main domain")
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(interval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// leaf returns the parsed leaf certificate, either from c.leaf or by parsing
|
||||||
|
// the corresponding c.Certificate[0].
|
||||||
|
func leaf(c *tls.Certificate) (*x509.Certificate, error) {
|
||||||
|
if c.Leaf != nil {
|
||||||
|
return c.Leaf, nil
|
||||||
|
}
|
||||||
|
return x509.ParseCertificate(c.Certificate[0])
|
||||||
|
}
|
86
server/certificates/mock.go
Normal file
86
server/certificates/mock.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package certificates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-acme/lego/v4/certcrypto"
|
||||||
|
"github.com/go-acme/lego/v4/certificate"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/server/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mockCert(domain, msg, mainDomainSuffix string, keyDatabase database.CertDB) tls.Certificate {
|
||||||
|
key, err := certcrypto.GeneratePrivateKey(certcrypto.RSA2048)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
template := x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: domain,
|
||||||
|
Organization: []string{"Codeberg Pages Error Certificate (couldn't obtain ACME certificate)"},
|
||||||
|
OrganizationalUnit: []string{
|
||||||
|
"Will not try again for 6 hours to avoid hitting rate limits for your domain.",
|
||||||
|
"Check https://docs.codeberg.org/codeberg-pages/troubleshooting/ for troubleshooting tips, and feel " +
|
||||||
|
"free to create an issue at https://codeberg.org/Codeberg/pages-server if you can't solve it.\n",
|
||||||
|
"Error message: " + msg,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// certificates younger than 7 days are renewed, so this enforces the cert to not be renewed for a 6 hours
|
||||||
|
NotAfter: time.Now().Add(time.Hour*24*7 + time.Hour*6),
|
||||||
|
NotBefore: time.Now(),
|
||||||
|
|
||||||
|
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
}
|
||||||
|
certBytes, err := x509.CreateCertificate(
|
||||||
|
rand.Reader,
|
||||||
|
&template,
|
||||||
|
&template,
|
||||||
|
&key.(*rsa.PrivateKey).PublicKey,
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
err = pem.Encode(out, &pem.Block{
|
||||||
|
Bytes: certBytes,
|
||||||
|
Type: "CERTIFICATE",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
outBytes := out.Bytes()
|
||||||
|
res := &certificate.Resource{
|
||||||
|
PrivateKey: certcrypto.PEMEncode(key),
|
||||||
|
Certificate: outBytes,
|
||||||
|
IssuerCertificate: outBytes,
|
||||||
|
Domain: domain,
|
||||||
|
}
|
||||||
|
databaseName := domain
|
||||||
|
if domain == "*"+mainDomainSuffix || domain == mainDomainSuffix[1:] {
|
||||||
|
databaseName = mainDomainSuffix
|
||||||
|
}
|
||||||
|
if err := keyDatabase.Put(databaseName, res); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsCertificate, err := tls.X509KeyPair(res.Certificate, res.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return tlsCertificate
|
||||||
|
}
|
17
server/certificates/mock_test.go
Normal file
17
server/certificates/mock_test.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package certificates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/server/database"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMockCert(t *testing.T) {
|
||||||
|
db, err := database.NewTmpDB()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
cert := mockCert("example.com", "some error msg", "codeberg.page", db)
|
||||||
|
if assert.NotEmpty(t, cert) {
|
||||||
|
assert.NotEmpty(t, cert.Certificate)
|
||||||
|
}
|
||||||
|
}
|
64
server/context/context.go
Normal file
64
server/context/context.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package context
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdContext "context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/server/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Context struct {
|
||||||
|
RespWriter http.ResponseWriter
|
||||||
|
Req *http.Request
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(w http.ResponseWriter, r *http.Request) *Context {
|
||||||
|
return &Context{
|
||||||
|
RespWriter: w,
|
||||||
|
Req: r,
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Context) Context() stdContext.Context {
|
||||||
|
if c.Req != nil {
|
||||||
|
return c.Req.Context()
|
||||||
|
}
|
||||||
|
return stdContext.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Context) Response() *http.Response {
|
||||||
|
if c.Req != nil && c.Req.Response != nil {
|
||||||
|
return c.Req.Response
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Context) String(raw string, status ...int) {
|
||||||
|
code := http.StatusOK
|
||||||
|
if len(status) != 0 {
|
||||||
|
code = status[0]
|
||||||
|
}
|
||||||
|
c.RespWriter.WriteHeader(code)
|
||||||
|
_, _ = c.RespWriter.Write([]byte(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Context) Redirect(uri string, statusCode int) {
|
||||||
|
http.Redirect(c.RespWriter, c.Req, uri, statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path returns requested path.
|
||||||
|
//
|
||||||
|
// The returned bytes are valid until your request handler returns.
|
||||||
|
func (c *Context) Path() string {
|
||||||
|
return c.Req.URL.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Context) Host() string {
|
||||||
|
return c.Req.URL.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Context) TrimHostPort() string {
|
||||||
|
return utils.TrimHostPort(c.Req.Host)
|
||||||
|
}
|
15
server/database/interface.go
Normal file
15
server/database/interface.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/akrylysov/pogreb"
|
||||||
|
"github.com/go-acme/lego/v4/certificate"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CertDB interface {
|
||||||
|
Close() error
|
||||||
|
Put(name string, cert *certificate.Resource) error
|
||||||
|
Get(name string) (*certificate.Resource, error)
|
||||||
|
Delete(key string) error
|
||||||
|
Compact() (string, error)
|
||||||
|
Items() *pogreb.ItemIterator
|
||||||
|
}
|
55
server/database/mock.go
Normal file
55
server/database/mock.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/OrlovEvgeny/go-mcache"
|
||||||
|
"github.com/akrylysov/pogreb"
|
||||||
|
"github.com/go-acme/lego/v4/certificate"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ CertDB = tmpDB{}
|
||||||
|
|
||||||
|
type tmpDB struct {
|
||||||
|
intern *mcache.CacheDriver
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p tmpDB) Close() error {
|
||||||
|
_ = p.intern.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p tmpDB) Put(name string, cert *certificate.Resource) error {
|
||||||
|
return p.intern.Set(name, cert, p.ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p tmpDB) Get(name string) (*certificate.Resource, error) {
|
||||||
|
cert, has := p.intern.Get(name)
|
||||||
|
if !has {
|
||||||
|
return nil, fmt.Errorf("cert for %q not found", name)
|
||||||
|
}
|
||||||
|
return cert.(*certificate.Resource), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p tmpDB) Delete(key string) error {
|
||||||
|
p.intern.Remove(key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p tmpDB) Compact() (string, error) {
|
||||||
|
p.intern.Truncate()
|
||||||
|
return "Truncate done", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p tmpDB) Items() *pogreb.ItemIterator {
|
||||||
|
panic("ItemIterator not implemented for tmpDB")
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTmpDB() (CertDB, error) {
|
||||||
|
return &tmpDB{
|
||||||
|
intern: mcache.New(),
|
||||||
|
ttl: time.Minute,
|
||||||
|
}, nil
|
||||||
|
}
|
109
server/database/setup.go
Normal file
109
server/database/setup.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/gob"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/akrylysov/pogreb"
|
||||||
|
"github.com/akrylysov/pogreb/fs"
|
||||||
|
"github.com/go-acme/lego/v4/certificate"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ CertDB = aDB{}
|
||||||
|
|
||||||
|
type aDB struct {
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
intern *pogreb.DB
|
||||||
|
syncInterval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p aDB) Close() error {
|
||||||
|
p.cancel()
|
||||||
|
return p.intern.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p aDB) Put(name string, cert *certificate.Resource) error {
|
||||||
|
var resGob bytes.Buffer
|
||||||
|
if err := gob.NewEncoder(&resGob).Encode(cert); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return p.intern.Put([]byte(name), resGob.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p aDB) Get(name string) (*certificate.Resource, error) {
|
||||||
|
cert := &certificate.Resource{}
|
||||||
|
resBytes, err := p.intern.Get([]byte(name))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resBytes == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err := gob.NewDecoder(bytes.NewBuffer(resBytes)).Decode(cert); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cert, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p aDB) Delete(key string) error {
|
||||||
|
return p.intern.Delete([]byte(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p aDB) Compact() (string, error) {
|
||||||
|
result, err := p.intern.Compact()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%+v", result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p aDB) Items() *pogreb.ItemIterator {
|
||||||
|
return p.intern.Items()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ CertDB = &aDB{}
|
||||||
|
|
||||||
|
func (p aDB) sync() {
|
||||||
|
for {
|
||||||
|
err := p.intern.Sync()
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Syncing cert database failed")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-p.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(p.syncInterval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(path string) (CertDB, error) {
|
||||||
|
if path == "" {
|
||||||
|
return nil, fmt.Errorf("path not set")
|
||||||
|
}
|
||||||
|
db, err := pogreb.Open(path, &pogreb.Options{
|
||||||
|
BackgroundSyncInterval: 30 * time.Second,
|
||||||
|
BackgroundCompactionInterval: 6 * time.Hour,
|
||||||
|
FileSystem: fs.OSMMap,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
result := &aDB{
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
intern: db,
|
||||||
|
syncInterval: 5 * time.Minute,
|
||||||
|
}
|
||||||
|
|
||||||
|
go result.sync()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
60
server/dns/dns.go
Normal file
60
server/dns/dns.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package dns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/server/cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lookupCacheTimeout specifies the timeout for the DNS lookup cache.
|
||||||
|
var lookupCacheTimeout = 15 * time.Minute
|
||||||
|
|
||||||
|
// GetTargetFromDNS searches for CNAME or TXT entries on the request domain ending with MainDomainSuffix.
|
||||||
|
// If everything is fine, it returns the target data.
|
||||||
|
func GetTargetFromDNS(domain, mainDomainSuffix string, dnsLookupCache cache.SetGetKey) (targetOwner, targetRepo, targetBranch string) {
|
||||||
|
// Get CNAME or TXT
|
||||||
|
var cname string
|
||||||
|
var err error
|
||||||
|
if cachedName, ok := dnsLookupCache.Get(domain); ok {
|
||||||
|
cname = cachedName.(string)
|
||||||
|
} else {
|
||||||
|
cname, err = net.LookupCNAME(domain)
|
||||||
|
cname = strings.TrimSuffix(cname, ".")
|
||||||
|
if err != nil || !strings.HasSuffix(cname, mainDomainSuffix) {
|
||||||
|
cname = ""
|
||||||
|
// TODO: check if the A record matches!
|
||||||
|
names, err := net.LookupTXT(domain)
|
||||||
|
if err == nil {
|
||||||
|
for _, name := range names {
|
||||||
|
name = strings.TrimSuffix(name, ".")
|
||||||
|
if strings.HasSuffix(name, mainDomainSuffix) {
|
||||||
|
cname = name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = dnsLookupCache.Set(domain, cname, lookupCacheTimeout)
|
||||||
|
}
|
||||||
|
if cname == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cnameParts := strings.Split(strings.TrimSuffix(cname, mainDomainSuffix), ".")
|
||||||
|
targetOwner = cnameParts[len(cnameParts)-1]
|
||||||
|
if len(cnameParts) > 1 {
|
||||||
|
targetRepo = cnameParts[len(cnameParts)-2]
|
||||||
|
}
|
||||||
|
if len(cnameParts) > 2 {
|
||||||
|
targetBranch = cnameParts[len(cnameParts)-3]
|
||||||
|
}
|
||||||
|
if targetRepo == "" {
|
||||||
|
targetRepo = "pages"
|
||||||
|
}
|
||||||
|
if targetBranch == "" && targetRepo != "pages" {
|
||||||
|
targetBranch = "pages"
|
||||||
|
}
|
||||||
|
// if targetBranch is still empty, the caller must find the default branch
|
||||||
|
return
|
||||||
|
}
|
115
server/gitea/cache.go
Normal file
115
server/gitea/cache.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/server/cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// defaultBranchCacheTimeout specifies the timeout for the default branch cache. It can be quite long.
|
||||||
|
defaultBranchCacheTimeout = 15 * time.Minute
|
||||||
|
|
||||||
|
// branchExistenceCacheTimeout specifies the timeout for the branch timestamp & existence cache. It should be shorter
|
||||||
|
// than fileCacheTimeout, as that gets invalidated if the branch timestamp has changed. That way, repo changes will be
|
||||||
|
// picked up faster, while still allowing the content to be cached longer if nothing changes.
|
||||||
|
branchExistenceCacheTimeout = 5 * time.Minute
|
||||||
|
|
||||||
|
// fileCacheTimeout specifies the timeout for the file content cache - you might want to make this quite long, depending
|
||||||
|
// on your available memory.
|
||||||
|
// TODO: move as option into cache interface
|
||||||
|
fileCacheTimeout = 5 * time.Minute
|
||||||
|
|
||||||
|
// fileCacheSizeLimit limits the maximum file size that will be cached, and is set to 1 MB by default.
|
||||||
|
fileCacheSizeLimit = int64(1000 * 1000)
|
||||||
|
)
|
||||||
|
|
||||||
|
type FileResponse struct {
|
||||||
|
Exists bool
|
||||||
|
IsSymlink bool
|
||||||
|
ETag string
|
||||||
|
MimeType string
|
||||||
|
Body []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f FileResponse) IsEmpty() bool {
|
||||||
|
return len(f.Body) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f FileResponse) createHttpResponse(cacheKey string) (header http.Header, statusCode int) {
|
||||||
|
header = make(http.Header)
|
||||||
|
|
||||||
|
if f.Exists {
|
||||||
|
statusCode = http.StatusOK
|
||||||
|
} else {
|
||||||
|
statusCode = http.StatusNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.IsSymlink {
|
||||||
|
header.Set(giteaObjectTypeHeader, objTypeSymlink)
|
||||||
|
}
|
||||||
|
header.Set(ETagHeader, f.ETag)
|
||||||
|
header.Set(ContentTypeHeader, f.MimeType)
|
||||||
|
header.Set(ContentLengthHeader, fmt.Sprintf("%d", len(f.Body)))
|
||||||
|
header.Set(PagesCacheIndicatorHeader, "true")
|
||||||
|
|
||||||
|
log.Trace().Msgf("fileCache for %q used", cacheKey)
|
||||||
|
return header, statusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
type BranchTimestamp struct {
|
||||||
|
Branch string
|
||||||
|
Timestamp time.Time
|
||||||
|
notFound bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type writeCacheReader struct {
|
||||||
|
originalReader io.ReadCloser
|
||||||
|
buffer *bytes.Buffer
|
||||||
|
rileResponse *FileResponse
|
||||||
|
cacheKey string
|
||||||
|
cache cache.SetGetKey
|
||||||
|
hasError bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *writeCacheReader) Read(p []byte) (n int, err error) {
|
||||||
|
n, err = t.originalReader.Read(p)
|
||||||
|
if err != nil {
|
||||||
|
log.Trace().Err(err).Msgf("[cache] original reader for %q has returned an error", t.cacheKey)
|
||||||
|
t.hasError = true
|
||||||
|
} else if n > 0 {
|
||||||
|
_, _ = t.buffer.Write(p[:n])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *writeCacheReader) Close() error {
|
||||||
|
if !t.hasError {
|
||||||
|
fc := *t.rileResponse
|
||||||
|
fc.Body = t.buffer.Bytes()
|
||||||
|
_ = t.cache.Set(t.cacheKey, fc, fileCacheTimeout)
|
||||||
|
}
|
||||||
|
log.Trace().Msgf("cacheReader for %q saved=%t closed", t.cacheKey, !t.hasError)
|
||||||
|
return t.originalReader.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f FileResponse) CreateCacheReader(r io.ReadCloser, cache cache.SetGetKey, cacheKey string) io.ReadCloser {
|
||||||
|
if r == nil || cache == nil || cacheKey == "" {
|
||||||
|
log.Error().Msg("could not create CacheReader")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &writeCacheReader{
|
||||||
|
originalReader: r,
|
||||||
|
buffer: bytes.NewBuffer(make([]byte, 0)),
|
||||||
|
rileResponse: &f,
|
||||||
|
cache: cache,
|
||||||
|
cacheKey: cacheKey,
|
||||||
|
}
|
||||||
|
}
|
284
server/gitea/client.go
Normal file
284
server/gitea/client.go
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/sdk/gitea"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/server/cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrorNotFound = errors.New("not found")
|
||||||
|
|
||||||
|
const (
|
||||||
|
// cache key prefixe
|
||||||
|
branchTimestampCacheKeyPrefix = "branchTime"
|
||||||
|
defaultBranchCacheKeyPrefix = "defaultBranch"
|
||||||
|
rawContentCacheKeyPrefix = "rawContent"
|
||||||
|
|
||||||
|
// pages server
|
||||||
|
PagesCacheIndicatorHeader = "X-Pages-Cache"
|
||||||
|
symlinkReadLimit = 10000
|
||||||
|
|
||||||
|
// gitea
|
||||||
|
giteaObjectTypeHeader = "X-Gitea-Object-Type"
|
||||||
|
objTypeSymlink = "symlink"
|
||||||
|
|
||||||
|
// std
|
||||||
|
ETagHeader = "ETag"
|
||||||
|
ContentTypeHeader = "Content-Type"
|
||||||
|
ContentLengthHeader = "Content-Length"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
sdkClient *gitea.Client
|
||||||
|
responseCache cache.SetGetKey
|
||||||
|
|
||||||
|
giteaRoot string
|
||||||
|
|
||||||
|
followSymlinks bool
|
||||||
|
supportLFS bool
|
||||||
|
|
||||||
|
forbiddenMimeTypes map[string]bool
|
||||||
|
defaultMimeType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(giteaRoot, giteaAPIToken string, respCache cache.SetGetKey, followSymlinks, supportLFS bool) (*Client, error) {
|
||||||
|
rootURL, err := url.Parse(giteaRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
giteaRoot = strings.Trim(rootURL.String(), "/")
|
||||||
|
|
||||||
|
stdClient := http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
// TODO: pass down
|
||||||
|
var (
|
||||||
|
forbiddenMimeTypes map[string]bool
|
||||||
|
defaultMimeType string
|
||||||
|
)
|
||||||
|
|
||||||
|
if forbiddenMimeTypes == nil {
|
||||||
|
forbiddenMimeTypes = make(map[string]bool)
|
||||||
|
}
|
||||||
|
if defaultMimeType == "" {
|
||||||
|
defaultMimeType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
sdk, err := gitea.NewClient(giteaRoot, gitea.SetHTTPClient(&stdClient), gitea.SetToken(giteaAPIToken))
|
||||||
|
return &Client{
|
||||||
|
sdkClient: sdk,
|
||||||
|
responseCache: respCache,
|
||||||
|
|
||||||
|
giteaRoot: giteaRoot,
|
||||||
|
|
||||||
|
followSymlinks: followSymlinks,
|
||||||
|
supportLFS: supportLFS,
|
||||||
|
|
||||||
|
forbiddenMimeTypes: forbiddenMimeTypes,
|
||||||
|
defaultMimeType: defaultMimeType,
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) ContentWebLink(targetOwner, targetRepo, branch, resource string) string {
|
||||||
|
return path.Join(client.giteaRoot, targetOwner, targetRepo, "src/branch", branch, resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) GiteaRawContent(targetOwner, targetRepo, ref, resource string) ([]byte, error) {
|
||||||
|
reader, _, _, err := client.ServeRawContent(targetOwner, targetRepo, ref, resource)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
return io.ReadAll(reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) ServeRawContent(targetOwner, targetRepo, ref, resource string) (io.ReadCloser, http.Header, int, error) {
|
||||||
|
cacheKey := fmt.Sprintf("%s/%s/%s|%s|%s", rawContentCacheKeyPrefix, targetOwner, targetRepo, ref, resource)
|
||||||
|
log := log.With().Str("cache_key", cacheKey).Logger()
|
||||||
|
|
||||||
|
// handle if cache entry exist
|
||||||
|
if cache, ok := client.responseCache.Get(cacheKey); ok {
|
||||||
|
cache := cache.(FileResponse)
|
||||||
|
cachedHeader, cachedStatusCode := cache.createHttpResponse(cacheKey)
|
||||||
|
// TODO: check against some timestamp missmatch?!?
|
||||||
|
if cache.Exists {
|
||||||
|
if cache.IsSymlink {
|
||||||
|
linkDest := string(cache.Body)
|
||||||
|
log.Debug().Msgf("[cache] follow symlink from %q to %q", resource, linkDest)
|
||||||
|
return client.ServeRawContent(targetOwner, targetRepo, ref, linkDest)
|
||||||
|
} else {
|
||||||
|
log.Debug().Msg("[cache] return bytes")
|
||||||
|
return io.NopCloser(bytes.NewReader(cache.Body)), cachedHeader, cachedStatusCode, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, cachedHeader, cachedStatusCode, ErrorNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// not in cache, open reader via gitea api
|
||||||
|
reader, resp, err := client.sdkClient.GetFileReader(targetOwner, targetRepo, ref, resource, client.supportLFS)
|
||||||
|
if resp != nil {
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusOK:
|
||||||
|
// first handle symlinks
|
||||||
|
{
|
||||||
|
objType := resp.Header.Get(giteaObjectTypeHeader)
|
||||||
|
log.Trace().Msgf("server raw content object %q", objType)
|
||||||
|
if client.followSymlinks && objType == objTypeSymlink {
|
||||||
|
defer reader.Close()
|
||||||
|
// read limited chars for symlink
|
||||||
|
linkDestBytes, err := io.ReadAll(io.LimitReader(reader, symlinkReadLimit))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
linkDest := strings.TrimSpace(string(linkDestBytes))
|
||||||
|
|
||||||
|
// we store symlink not content to reduce duplicates in cache
|
||||||
|
if err := client.responseCache.Set(cacheKey, FileResponse{
|
||||||
|
Exists: true,
|
||||||
|
IsSymlink: true,
|
||||||
|
Body: []byte(linkDest),
|
||||||
|
ETag: resp.Header.Get(ETagHeader),
|
||||||
|
}, fileCacheTimeout); err != nil {
|
||||||
|
log.Error().Err(err).Msg("[cache] error on cache write")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().Msgf("follow symlink from %q to %q", resource, linkDest)
|
||||||
|
return client.ServeRawContent(targetOwner, targetRepo, ref, linkDest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// now we are sure it's content so set the MIME type
|
||||||
|
mimeType := client.getMimeTypeByExtension(resource)
|
||||||
|
resp.Response.Header.Set(ContentTypeHeader, mimeType)
|
||||||
|
|
||||||
|
if !shouldRespBeSavedToCache(resp.Response) {
|
||||||
|
return reader, resp.Response.Header, resp.StatusCode, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// now we write to cache and respond at the sime time
|
||||||
|
fileResp := FileResponse{
|
||||||
|
Exists: true,
|
||||||
|
ETag: resp.Header.Get(ETagHeader),
|
||||||
|
MimeType: mimeType,
|
||||||
|
}
|
||||||
|
return fileResp.CreateCacheReader(reader, client.responseCache, cacheKey), resp.Response.Header, resp.StatusCode, nil
|
||||||
|
|
||||||
|
case http.StatusNotFound:
|
||||||
|
if err := client.responseCache.Set(cacheKey, FileResponse{
|
||||||
|
Exists: false,
|
||||||
|
ETag: resp.Header.Get(ETagHeader),
|
||||||
|
}, fileCacheTimeout); err != nil {
|
||||||
|
log.Error().Err(err).Msg("[cache] error on cache write")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, resp.Response.Header, http.StatusNotFound, ErrorNotFound
|
||||||
|
default:
|
||||||
|
return nil, resp.Response.Header, resp.StatusCode, fmt.Errorf("unexpected status code '%d'", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil, http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) GiteaGetRepoBranchTimestamp(repoOwner, repoName, branchName string) (*BranchTimestamp, error) {
|
||||||
|
cacheKey := fmt.Sprintf("%s/%s/%s/%s", branchTimestampCacheKeyPrefix, repoOwner, repoName, branchName)
|
||||||
|
|
||||||
|
if stamp, ok := client.responseCache.Get(cacheKey); ok && stamp != nil {
|
||||||
|
branchTimeStamp := stamp.(*BranchTimestamp)
|
||||||
|
if branchTimeStamp.notFound {
|
||||||
|
log.Trace().Msgf("[cache] use branch %q not found", branchName)
|
||||||
|
return &BranchTimestamp{}, ErrorNotFound
|
||||||
|
}
|
||||||
|
log.Trace().Msgf("[cache] use branch %q exist", branchName)
|
||||||
|
return branchTimeStamp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
branch, resp, err := client.sdkClient.GetRepoBranch(repoOwner, repoName, branchName)
|
||||||
|
if err != nil {
|
||||||
|
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
||||||
|
log.Trace().Msgf("[cache] set cache branch %q not found", branchName)
|
||||||
|
if err := client.responseCache.Set(cacheKey, &BranchTimestamp{Branch: branchName, notFound: true}, branchExistenceCacheTimeout); err != nil {
|
||||||
|
log.Error().Err(err).Msg("[cache] error on cache write")
|
||||||
|
}
|
||||||
|
return &BranchTimestamp{}, ErrorNotFound
|
||||||
|
}
|
||||||
|
return &BranchTimestamp{}, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return &BranchTimestamp{}, fmt.Errorf("unexpected status code '%d'", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
stamp := &BranchTimestamp{
|
||||||
|
Branch: branch.Name,
|
||||||
|
Timestamp: branch.Commit.Timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Trace().Msgf("set cache branch [%s] exist", branchName)
|
||||||
|
if err := client.responseCache.Set(cacheKey, stamp, branchExistenceCacheTimeout); err != nil {
|
||||||
|
log.Error().Err(err).Msg("[cache] error on cache write")
|
||||||
|
}
|
||||||
|
return stamp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) GiteaGetRepoDefaultBranch(repoOwner, repoName string) (string, error) {
|
||||||
|
cacheKey := fmt.Sprintf("%s/%s/%s", defaultBranchCacheKeyPrefix, repoOwner, repoName)
|
||||||
|
|
||||||
|
if branch, ok := client.responseCache.Get(cacheKey); ok && branch != nil {
|
||||||
|
return branch.(string), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, resp, err := client.sdkClient.GetRepo(repoOwner, repoName)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("unexpected status code '%d'", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
branch := repo.DefaultBranch
|
||||||
|
if err := client.responseCache.Set(cacheKey, branch, defaultBranchCacheTimeout); err != nil {
|
||||||
|
log.Error().Err(err).Msg("[cache] error on cache write")
|
||||||
|
}
|
||||||
|
return branch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) getMimeTypeByExtension(resource string) string {
|
||||||
|
mimeType := mime.TypeByExtension(path.Ext(resource))
|
||||||
|
mimeTypeSplit := strings.SplitN(mimeType, ";", 2)
|
||||||
|
if client.forbiddenMimeTypes[mimeTypeSplit[0]] || mimeType == "" {
|
||||||
|
mimeType = client.defaultMimeType
|
||||||
|
}
|
||||||
|
log.Trace().Msgf("probe mime of %q is %q", resource, mimeType)
|
||||||
|
return mimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldRespBeSavedToCache(resp *http.Response) bool {
|
||||||
|
if resp == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
contentLengthRaw := resp.Header.Get(ContentLengthHeader)
|
||||||
|
if contentLengthRaw == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
contentLeng, err := strconv.ParseInt(contentLengthRaw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("could not parse content length")
|
||||||
|
}
|
||||||
|
|
||||||
|
// if content to big or could not be determined we not cache it
|
||||||
|
return contentLeng > 0 && contentLeng < fileCacheSizeLimit
|
||||||
|
}
|
113
server/handler/handler.go
Normal file
113
server/handler/handler.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/html"
|
||||||
|
"codeberg.org/codeberg/pages/server/cache"
|
||||||
|
"codeberg.org/codeberg/pages/server/context"
|
||||||
|
"codeberg.org/codeberg/pages/server/gitea"
|
||||||
|
"codeberg.org/codeberg/pages/server/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
headerAccessControlAllowOrigin = "Access-Control-Allow-Origin"
|
||||||
|
headerAccessControlAllowMethods = "Access-Control-Allow-Methods"
|
||||||
|
defaultPagesRepo = "pages"
|
||||||
|
defaultPagesBranch = "pages"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler handles a single HTTP request to the web server.
|
||||||
|
func Handler(mainDomainSuffix, rawDomain string,
|
||||||
|
giteaClient *gitea.Client,
|
||||||
|
rawInfoPage string,
|
||||||
|
blacklistedPaths, allowedCorsDomains []string,
|
||||||
|
dnsLookupCache, canonicalDomainCache cache.SetGetKey,
|
||||||
|
) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
log := log.With().Strs("Handler", []string{req.Host, req.RequestURI}).Logger()
|
||||||
|
ctx := context.New(w, req)
|
||||||
|
|
||||||
|
ctx.RespWriter.Header().Set("Server", "CodebergPages/"+version.Version)
|
||||||
|
|
||||||
|
// Force new default from specification (since November 2020) - see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy#strict-origin-when-cross-origin
|
||||||
|
ctx.RespWriter.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
|
||||||
|
// Enable browser caching for up to 10 minutes
|
||||||
|
ctx.RespWriter.Header().Set("Cache-Control", "public, max-age=600")
|
||||||
|
|
||||||
|
trimmedHost := ctx.TrimHostPort()
|
||||||
|
|
||||||
|
// Add HSTS for RawDomain and MainDomainSuffix
|
||||||
|
if hsts := getHSTSHeader(trimmedHost, mainDomainSuffix, rawDomain); hsts != "" {
|
||||||
|
ctx.RespWriter.Header().Set("Strict-Transport-Security", hsts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle all http methods
|
||||||
|
ctx.RespWriter.Header().Set("Allow", http.MethodGet+", "+http.MethodHead+", "+http.MethodOptions)
|
||||||
|
switch ctx.Req.Method {
|
||||||
|
case http.MethodOptions:
|
||||||
|
// return Allow header
|
||||||
|
ctx.RespWriter.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
case http.MethodGet,
|
||||||
|
http.MethodHead:
|
||||||
|
// end switch case and handle allowed requests
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
// Block all methods not required for static pages
|
||||||
|
ctx.String("Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block blacklisted paths (like ACME challenges)
|
||||||
|
for _, blacklistedPath := range blacklistedPaths {
|
||||||
|
if strings.HasPrefix(ctx.Path(), blacklistedPath) {
|
||||||
|
html.ReturnErrorPage(ctx, "requested blacklisted path", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow CORS for specified domains
|
||||||
|
allowCors := false
|
||||||
|
for _, allowedCorsDomain := range allowedCorsDomains {
|
||||||
|
if strings.EqualFold(trimmedHost, allowedCorsDomain) {
|
||||||
|
allowCors = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allowCors {
|
||||||
|
ctx.RespWriter.Header().Set(headerAccessControlAllowOrigin, "*")
|
||||||
|
ctx.RespWriter.Header().Set(headerAccessControlAllowMethods, http.MethodGet+", "+http.MethodHead)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare request information to Gitea
|
||||||
|
pathElements := strings.Split(strings.Trim(ctx.Path(), "/"), "/")
|
||||||
|
|
||||||
|
if rawDomain != "" && strings.EqualFold(trimmedHost, rawDomain) {
|
||||||
|
log.Debug().Msg("raw domain request detecded")
|
||||||
|
handleRaw(log, ctx, giteaClient,
|
||||||
|
mainDomainSuffix, rawInfoPage,
|
||||||
|
trimmedHost,
|
||||||
|
pathElements,
|
||||||
|
canonicalDomainCache)
|
||||||
|
} else if strings.HasSuffix(trimmedHost, mainDomainSuffix) {
|
||||||
|
log.Debug().Msg("subdomain request detecded")
|
||||||
|
handleSubDomain(log, ctx, giteaClient,
|
||||||
|
mainDomainSuffix,
|
||||||
|
trimmedHost,
|
||||||
|
pathElements,
|
||||||
|
canonicalDomainCache)
|
||||||
|
} else {
|
||||||
|
log.Debug().Msg("custom domain request detecded")
|
||||||
|
handleCustomDomain(log, ctx, giteaClient,
|
||||||
|
mainDomainSuffix,
|
||||||
|
trimmedHost,
|
||||||
|
pathElements,
|
||||||
|
dnsLookupCache, canonicalDomainCache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
71
server/handler/handler_custom_domain.go
Normal file
71
server/handler/handler_custom_domain.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/html"
|
||||||
|
"codeberg.org/codeberg/pages/server/cache"
|
||||||
|
"codeberg.org/codeberg/pages/server/context"
|
||||||
|
"codeberg.org/codeberg/pages/server/dns"
|
||||||
|
"codeberg.org/codeberg/pages/server/gitea"
|
||||||
|
"codeberg.org/codeberg/pages/server/upstream"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleCustomDomain(log zerolog.Logger, ctx *context.Context, giteaClient *gitea.Client,
|
||||||
|
mainDomainSuffix string,
|
||||||
|
trimmedHost string,
|
||||||
|
pathElements []string,
|
||||||
|
dnsLookupCache, canonicalDomainCache cache.SetGetKey,
|
||||||
|
) {
|
||||||
|
// Serve pages from custom domains
|
||||||
|
targetOwner, targetRepo, targetBranch := dns.GetTargetFromDNS(trimmedHost, mainDomainSuffix, dnsLookupCache)
|
||||||
|
if targetOwner == "" {
|
||||||
|
html.ReturnErrorPage(ctx,
|
||||||
|
"could not obtain repo owner from custom domain",
|
||||||
|
http.StatusFailedDependency)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pathParts := pathElements
|
||||||
|
canonicalLink := false
|
||||||
|
if strings.HasPrefix(pathElements[0], "@") {
|
||||||
|
targetBranch = pathElements[0][1:]
|
||||||
|
pathParts = pathElements[1:]
|
||||||
|
canonicalLink = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to use the given repo on the given branch or the default branch
|
||||||
|
log.Debug().Msg("custom domain preparations, now trying with details from DNS")
|
||||||
|
if targetOpt, works := tryBranch(log, ctx, giteaClient, &upstream.Options{
|
||||||
|
TryIndexPages: true,
|
||||||
|
TargetOwner: targetOwner,
|
||||||
|
TargetRepo: targetRepo,
|
||||||
|
TargetBranch: targetBranch,
|
||||||
|
TargetPath: path.Join(pathParts...),
|
||||||
|
}, canonicalLink); works {
|
||||||
|
canonicalDomain, valid := targetOpt.CheckCanonicalDomain(giteaClient, trimmedHost, mainDomainSuffix, canonicalDomainCache)
|
||||||
|
if !valid {
|
||||||
|
html.ReturnErrorPage(ctx, "domain not specified in <code>.domains</code> file", http.StatusMisdirectedRequest)
|
||||||
|
return
|
||||||
|
} else if canonicalDomain != trimmedHost {
|
||||||
|
// only redirect if the target is also a codeberg page!
|
||||||
|
targetOwner, _, _ = dns.GetTargetFromDNS(strings.SplitN(canonicalDomain, "/", 2)[0], mainDomainSuffix, dnsLookupCache)
|
||||||
|
if targetOwner != "" {
|
||||||
|
ctx.Redirect("https://"+canonicalDomain+"/"+targetOpt.TargetPath, http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
html.ReturnErrorPage(ctx, "target is no codeberg page", http.StatusFailedDependency)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("tryBranch, now trying upstream 7")
|
||||||
|
tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
html.ReturnErrorPage(ctx, "could not find target for custom domain", http.StatusFailedDependency)
|
||||||
|
}
|
67
server/handler/handler_raw_domain.go
Normal file
67
server/handler/handler_raw_domain.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/html"
|
||||||
|
"codeberg.org/codeberg/pages/server/cache"
|
||||||
|
"codeberg.org/codeberg/pages/server/context"
|
||||||
|
"codeberg.org/codeberg/pages/server/gitea"
|
||||||
|
"codeberg.org/codeberg/pages/server/upstream"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleRaw(log zerolog.Logger, ctx *context.Context, giteaClient *gitea.Client,
|
||||||
|
mainDomainSuffix, rawInfoPage string,
|
||||||
|
trimmedHost string,
|
||||||
|
pathElements []string,
|
||||||
|
canonicalDomainCache cache.SetGetKey,
|
||||||
|
) {
|
||||||
|
// Serve raw content from RawDomain
|
||||||
|
log.Debug().Msg("raw domain")
|
||||||
|
|
||||||
|
if len(pathElements) < 2 {
|
||||||
|
// https://{RawDomain}/{owner}/{repo}[/@{branch}]/{path} is required
|
||||||
|
ctx.Redirect(rawInfoPage, http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// raw.codeberg.org/example/myrepo/@main/index.html
|
||||||
|
if len(pathElements) > 2 && strings.HasPrefix(pathElements[2], "@") {
|
||||||
|
log.Debug().Msg("raw domain preparations, now trying with specified branch")
|
||||||
|
if targetOpt, works := tryBranch(log, ctx, giteaClient, &upstream.Options{
|
||||||
|
ServeRaw: true,
|
||||||
|
TargetOwner: pathElements[0],
|
||||||
|
TargetRepo: pathElements[1],
|
||||||
|
TargetBranch: pathElements[2][1:],
|
||||||
|
TargetPath: path.Join(pathElements[3:]...),
|
||||||
|
}, true); works {
|
||||||
|
log.Trace().Msg("tryUpstream: serve raw domain with specified branch")
|
||||||
|
tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Debug().Msg("missing branch info")
|
||||||
|
html.ReturnErrorPage(ctx, "missing branch info", http.StatusFailedDependency)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("raw domain preparations, now trying with default branch")
|
||||||
|
if targetOpt, works := tryBranch(log, ctx, giteaClient, &upstream.Options{
|
||||||
|
TryIndexPages: false,
|
||||||
|
ServeRaw: true,
|
||||||
|
TargetOwner: pathElements[0],
|
||||||
|
TargetRepo: pathElements[1],
|
||||||
|
TargetPath: path.Join(pathElements[2:]...),
|
||||||
|
}, true); works {
|
||||||
|
log.Trace().Msg("tryUpstream: serve raw domain with default branch")
|
||||||
|
tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache)
|
||||||
|
} else {
|
||||||
|
html.ReturnErrorPage(ctx,
|
||||||
|
fmt.Sprintf("raw domain could not find repo '%s/%s' or repo is empty", targetOpt.TargetOwner, targetOpt.TargetRepo),
|
||||||
|
http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
120
server/handler/handler_sub_domain.go
Normal file
120
server/handler/handler_sub_domain.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/html"
|
||||||
|
"codeberg.org/codeberg/pages/server/cache"
|
||||||
|
"codeberg.org/codeberg/pages/server/context"
|
||||||
|
"codeberg.org/codeberg/pages/server/gitea"
|
||||||
|
"codeberg.org/codeberg/pages/server/upstream"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleSubDomain(log zerolog.Logger, ctx *context.Context, giteaClient *gitea.Client,
|
||||||
|
mainDomainSuffix string,
|
||||||
|
trimmedHost string,
|
||||||
|
pathElements []string,
|
||||||
|
canonicalDomainCache cache.SetGetKey,
|
||||||
|
) {
|
||||||
|
// Serve pages from subdomains of MainDomainSuffix
|
||||||
|
log.Debug().Msg("main domain suffix")
|
||||||
|
|
||||||
|
targetOwner := strings.TrimSuffix(trimmedHost, mainDomainSuffix)
|
||||||
|
targetRepo := pathElements[0]
|
||||||
|
|
||||||
|
if targetOwner == "www" {
|
||||||
|
// www.codeberg.page redirects to codeberg.page // TODO: rm hardcoded - use cname?
|
||||||
|
ctx.Redirect("https://"+mainDomainSuffix[1:]+ctx.Path(), http.StatusPermanentRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the first directory is a repo with the second directory as a branch
|
||||||
|
// example.codeberg.page/myrepo/@main/index.html
|
||||||
|
if len(pathElements) > 1 && strings.HasPrefix(pathElements[1], "@") {
|
||||||
|
if targetRepo == defaultPagesRepo {
|
||||||
|
// example.codeberg.org/pages/@... redirects to example.codeberg.org/@...
|
||||||
|
ctx.Redirect("/"+strings.Join(pathElements[1:], "/"), http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("main domain preparations, now trying with specified repo & branch")
|
||||||
|
if targetOpt, works := tryBranch(log, ctx, giteaClient, &upstream.Options{
|
||||||
|
TryIndexPages: true,
|
||||||
|
TargetOwner: targetOwner,
|
||||||
|
TargetRepo: pathElements[0],
|
||||||
|
TargetBranch: pathElements[1][1:],
|
||||||
|
TargetPath: path.Join(pathElements[2:]...),
|
||||||
|
}, true); works {
|
||||||
|
log.Trace().Msg("tryUpstream: serve with specified repo and branch")
|
||||||
|
tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache)
|
||||||
|
} else {
|
||||||
|
html.ReturnErrorPage(ctx,
|
||||||
|
fmt.Sprintf("explizite set branch %q do not exist at '%s/%s'", targetOpt.TargetBranch, targetOpt.TargetOwner, targetOpt.TargetRepo),
|
||||||
|
http.StatusFailedDependency)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the first directory is a branch for the defaultPagesRepo
|
||||||
|
// example.codeberg.page/@main/index.html
|
||||||
|
if strings.HasPrefix(pathElements[0], "@") {
|
||||||
|
log.Debug().Msg("main domain preparations, now trying with specified branch")
|
||||||
|
if targetOpt, works := tryBranch(log, ctx, giteaClient, &upstream.Options{
|
||||||
|
TryIndexPages: true,
|
||||||
|
TargetOwner: targetOwner,
|
||||||
|
TargetRepo: defaultPagesRepo,
|
||||||
|
TargetBranch: pathElements[0][1:],
|
||||||
|
TargetPath: path.Join(pathElements[1:]...),
|
||||||
|
}, true); works {
|
||||||
|
log.Trace().Msg("tryUpstream: serve default pages repo with specified branch")
|
||||||
|
tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache)
|
||||||
|
} else {
|
||||||
|
html.ReturnErrorPage(ctx,
|
||||||
|
fmt.Sprintf("explizite set branch %q do not exist at '%s/%s'", targetOpt.TargetBranch, targetOpt.TargetOwner, targetOpt.TargetRepo),
|
||||||
|
http.StatusFailedDependency)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the first directory is a repo with a defaultPagesRepo branch
|
||||||
|
// example.codeberg.page/myrepo/index.html
|
||||||
|
// example.codeberg.page/pages/... is not allowed here.
|
||||||
|
log.Debug().Msg("main domain preparations, now trying with specified repo")
|
||||||
|
if pathElements[0] != defaultPagesRepo {
|
||||||
|
if targetOpt, works := tryBranch(log, ctx, giteaClient, &upstream.Options{
|
||||||
|
TryIndexPages: true,
|
||||||
|
TargetOwner: targetOwner,
|
||||||
|
TargetRepo: pathElements[0],
|
||||||
|
TargetBranch: defaultPagesBranch,
|
||||||
|
TargetPath: path.Join(pathElements[1:]...),
|
||||||
|
}, false); works {
|
||||||
|
log.Debug().Msg("tryBranch, now trying upstream 5")
|
||||||
|
tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to use the defaultPagesRepo on its default branch
|
||||||
|
// example.codeberg.page/index.html
|
||||||
|
log.Debug().Msg("main domain preparations, now trying with default repo/branch")
|
||||||
|
if targetOpt, works := tryBranch(log, ctx, giteaClient, &upstream.Options{
|
||||||
|
TryIndexPages: true,
|
||||||
|
TargetOwner: targetOwner,
|
||||||
|
TargetRepo: defaultPagesRepo,
|
||||||
|
TargetPath: path.Join(pathElements...),
|
||||||
|
}, false); works {
|
||||||
|
log.Debug().Msg("tryBranch, now trying upstream 6")
|
||||||
|
tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Couldn't find a valid repo/branch
|
||||||
|
html.ReturnErrorPage(ctx,
|
||||||
|
fmt.Sprintf("could not find a valid repository[%s]", targetRepo),
|
||||||
|
http.StatusNotFound)
|
||||||
|
}
|
49
server/handler/handler_test.go
Normal file
49
server/handler/handler_test.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/server/cache"
|
||||||
|
"codeberg.org/codeberg/pages/server/gitea"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandlerPerformance(t *testing.T) {
|
||||||
|
giteaClient, _ := gitea.NewClient("https://codeberg.org", "", cache.NewKeyValueCache(), false, false)
|
||||||
|
testHandler := Handler(
|
||||||
|
"codeberg.page", "raw.codeberg.org",
|
||||||
|
giteaClient,
|
||||||
|
"https://docs.codeberg.org/pages/raw-content/",
|
||||||
|
[]string{"/.well-known/acme-challenge/"},
|
||||||
|
[]string{"raw.codeberg.org", "fonts.codeberg.org", "design.codeberg.org"},
|
||||||
|
cache.NewKeyValueCache(),
|
||||||
|
cache.NewKeyValueCache(),
|
||||||
|
)
|
||||||
|
|
||||||
|
testCase := func(uri string, status int) {
|
||||||
|
t.Run(uri, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", uri, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
log.Printf("Start: %v\n", time.Now())
|
||||||
|
start := time.Now()
|
||||||
|
testHandler(w, req)
|
||||||
|
end := time.Now()
|
||||||
|
log.Printf("Done: %v\n", time.Now())
|
||||||
|
|
||||||
|
resp := w.Result()
|
||||||
|
|
||||||
|
if resp.StatusCode != status {
|
||||||
|
t.Errorf("request failed with status code %d", resp.StatusCode)
|
||||||
|
} else {
|
||||||
|
t.Logf("request took %d milliseconds", end.Sub(start).Milliseconds())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
testCase("https://mondstern.codeberg.page/", 404) // TODO: expect 200
|
||||||
|
testCase("https://codeberg.page/", 404) // TODO: expect 200
|
||||||
|
testCase("https://example.momar.xyz/", 424)
|
||||||
|
}
|
15
server/handler/hsts.go
Normal file
15
server/handler/hsts.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getHSTSHeader returns a HSTS header with includeSubdomains & preload for MainDomainSuffix and RawDomain, or an empty
|
||||||
|
// string for custom domains.
|
||||||
|
func getHSTSHeader(host, mainDomainSuffix, rawDomain string) string {
|
||||||
|
if strings.HasSuffix(host, mainDomainSuffix) || strings.EqualFold(host, rawDomain) {
|
||||||
|
return "max-age=63072000; includeSubdomains; preload"
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
76
server/handler/try.go
Normal file
76
server/handler/try.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/html"
|
||||||
|
"codeberg.org/codeberg/pages/server/cache"
|
||||||
|
"codeberg.org/codeberg/pages/server/context"
|
||||||
|
"codeberg.org/codeberg/pages/server/gitea"
|
||||||
|
"codeberg.org/codeberg/pages/server/upstream"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tryUpstream forwards the target request to the Gitea API, and shows an error page on failure.
|
||||||
|
func tryUpstream(ctx *context.Context, giteaClient *gitea.Client,
|
||||||
|
mainDomainSuffix, trimmedHost string,
|
||||||
|
options *upstream.Options,
|
||||||
|
canonicalDomainCache cache.SetGetKey,
|
||||||
|
) {
|
||||||
|
// check if a canonical domain exists on a request on MainDomain
|
||||||
|
if strings.HasSuffix(trimmedHost, mainDomainSuffix) {
|
||||||
|
canonicalDomain, _ := options.CheckCanonicalDomain(giteaClient, "", mainDomainSuffix, canonicalDomainCache)
|
||||||
|
if !strings.HasSuffix(strings.SplitN(canonicalDomain, "/", 2)[0], mainDomainSuffix) {
|
||||||
|
canonicalPath := ctx.Req.RequestURI
|
||||||
|
if options.TargetRepo != defaultPagesRepo {
|
||||||
|
path := strings.SplitN(canonicalPath, "/", 3)
|
||||||
|
if len(path) >= 3 {
|
||||||
|
canonicalPath = "/" + path[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.Redirect("https://"+canonicalDomain+canonicalPath, http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add host for debugging.
|
||||||
|
options.Host = trimmedHost
|
||||||
|
|
||||||
|
// Try to request the file from the Gitea API
|
||||||
|
if !options.Upstream(ctx, giteaClient) {
|
||||||
|
html.ReturnErrorPage(ctx, "", ctx.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryBranch checks if a branch exists and populates the target variables. If canonicalLink is non-empty,
|
||||||
|
// it will also disallow search indexing and add a Link header to the canonical URL.
|
||||||
|
func tryBranch(log zerolog.Logger, ctx *context.Context, giteaClient *gitea.Client,
|
||||||
|
targetOptions *upstream.Options, canonicalLink bool,
|
||||||
|
) (*upstream.Options, bool) {
|
||||||
|
if targetOptions.TargetOwner == "" || targetOptions.TargetRepo == "" {
|
||||||
|
log.Debug().Msg("tryBranch: owner or repo is empty")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace "~" to "/" so we can access branch that contains slash character
|
||||||
|
// Branch name cannot contain "~" so doing this is okay
|
||||||
|
targetOptions.TargetBranch = strings.ReplaceAll(targetOptions.TargetBranch, "~", "/")
|
||||||
|
|
||||||
|
// Check if the branch exists, otherwise treat it as a file path
|
||||||
|
branchExist, _ := targetOptions.GetBranchTimestamp(giteaClient)
|
||||||
|
if !branchExist {
|
||||||
|
log.Debug().Msg("tryBranch: branch doesn't exist")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if canonicalLink {
|
||||||
|
// Hide from search machines & add canonical link
|
||||||
|
ctx.RespWriter.Header().Set("X-Robots-Tag", "noarchive, noindex")
|
||||||
|
ctx.RespWriter.Header().Set("Link", targetOptions.ContentWebLink(giteaClient)+"; rel=\"canonical\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("tryBranch: true")
|
||||||
|
return targetOptions, true
|
||||||
|
}
|
27
server/setup.go
Normal file
27
server/setup.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/server/cache"
|
||||||
|
"codeberg.org/codeberg/pages/server/context"
|
||||||
|
"codeberg.org/codeberg/pages/server/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SetupHTTPACMEChallengeServer(challengeCache cache.SetGetKey) http.HandlerFunc {
|
||||||
|
challengePath := "/.well-known/acme-challenge/"
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ctx := context.New(w, req)
|
||||||
|
if strings.HasPrefix(ctx.Path(), challengePath) {
|
||||||
|
challenge, ok := challengeCache.Get(utils.TrimHostPort(ctx.Host()) + "/" + strings.TrimPrefix(ctx.Path(), challengePath))
|
||||||
|
if !ok || challenge == nil {
|
||||||
|
ctx.String("no challenge for this token", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
ctx.String(challenge.(string))
|
||||||
|
} else {
|
||||||
|
ctx.Redirect("https://"+ctx.Host()+ctx.Path(), http.StatusMovedPermanently)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
64
server/upstream/domains.go
Normal file
64
server/upstream/domains.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package upstream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/server/cache"
|
||||||
|
"codeberg.org/codeberg/pages/server/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// canonicalDomainCacheTimeout specifies the timeout for the canonical domain cache.
|
||||||
|
var canonicalDomainCacheTimeout = 15 * time.Minute
|
||||||
|
|
||||||
|
const canonicalDomainConfig = ".domains"
|
||||||
|
|
||||||
|
// CheckCanonicalDomain returns the canonical domain specified in the repo (using the `.domains` file).
|
||||||
|
func (o *Options) CheckCanonicalDomain(giteaClient *gitea.Client, actualDomain, mainDomainSuffix string, canonicalDomainCache cache.SetGetKey) (string, bool) {
|
||||||
|
var (
|
||||||
|
domains []string
|
||||||
|
valid bool
|
||||||
|
)
|
||||||
|
if cachedValue, ok := canonicalDomainCache.Get(o.TargetOwner + "/" + o.TargetRepo + "/" + o.TargetBranch); ok {
|
||||||
|
domains = cachedValue.([]string)
|
||||||
|
for _, domain := range domains {
|
||||||
|
if domain == actualDomain {
|
||||||
|
valid = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body, err := giteaClient.GiteaRawContent(o.TargetOwner, o.TargetRepo, o.TargetBranch, canonicalDomainConfig)
|
||||||
|
if err == nil {
|
||||||
|
for _, domain := range strings.Split(string(body), "\n") {
|
||||||
|
domain = strings.ToLower(domain)
|
||||||
|
domain = strings.TrimSpace(domain)
|
||||||
|
domain = strings.TrimPrefix(domain, "http://")
|
||||||
|
domain = strings.TrimPrefix(domain, "https://")
|
||||||
|
if len(domain) > 0 && !strings.HasPrefix(domain, "#") && !strings.ContainsAny(domain, "\t /") && strings.ContainsRune(domain, '.') {
|
||||||
|
domains = append(domains, domain)
|
||||||
|
}
|
||||||
|
if domain == actualDomain {
|
||||||
|
valid = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != gitea.ErrorNotFound {
|
||||||
|
log.Error().Err(err).Msgf("could not read %s of %s/%s", canonicalDomainConfig, o.TargetOwner, o.TargetRepo)
|
||||||
|
} else {
|
||||||
|
log.Info().Err(err).Msgf("could not read %s of %s/%s", canonicalDomainConfig, o.TargetOwner, o.TargetRepo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
domains = append(domains, o.TargetOwner+mainDomainSuffix)
|
||||||
|
if domains[len(domains)-1] == actualDomain {
|
||||||
|
valid = true
|
||||||
|
}
|
||||||
|
if o.TargetRepo != "" && o.TargetRepo != "pages" {
|
||||||
|
domains[len(domains)-1] += "/" + o.TargetRepo
|
||||||
|
}
|
||||||
|
_ = canonicalDomainCache.Set(o.TargetOwner+"/"+o.TargetRepo+"/"+o.TargetBranch, domains, canonicalDomainCacheTimeout)
|
||||||
|
}
|
||||||
|
return domains[0], valid
|
||||||
|
}
|
28
server/upstream/header.go
Normal file
28
server/upstream/header.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package upstream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/server/context"
|
||||||
|
"codeberg.org/codeberg/pages/server/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setHeader set values to response header
|
||||||
|
func (o *Options) setHeader(ctx *context.Context, header http.Header) {
|
||||||
|
if eTag := header.Get(gitea.ETagHeader); eTag != "" {
|
||||||
|
ctx.RespWriter.Header().Set(gitea.ETagHeader, eTag)
|
||||||
|
}
|
||||||
|
if cacheIndicator := header.Get(gitea.PagesCacheIndicatorHeader); cacheIndicator != "" {
|
||||||
|
ctx.RespWriter.Header().Set(gitea.PagesCacheIndicatorHeader, cacheIndicator)
|
||||||
|
}
|
||||||
|
if length := header.Get(gitea.ContentLengthHeader); length != "" {
|
||||||
|
ctx.RespWriter.Header().Set(gitea.ContentLengthHeader, length)
|
||||||
|
}
|
||||||
|
if mime := header.Get(gitea.ContentTypeHeader); mime == "" || o.ServeRaw {
|
||||||
|
ctx.RespWriter.Header().Set(gitea.ContentTypeHeader, rawMime)
|
||||||
|
} else {
|
||||||
|
ctx.RespWriter.Header().Set(gitea.ContentTypeHeader, mime)
|
||||||
|
}
|
||||||
|
ctx.RespWriter.Header().Set(headerLastModified, o.BranchTimestamp.In(time.UTC).Format(time.RFC1123))
|
||||||
|
}
|
47
server/upstream/helper.go
Normal file
47
server/upstream/helper.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package upstream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/server/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetBranchTimestamp finds the default branch (if branch is "") and save branch and it's last modification time to Options
|
||||||
|
func (o *Options) GetBranchTimestamp(giteaClient *gitea.Client) (bool, error) {
|
||||||
|
log := log.With().Strs("BranchInfo", []string{o.TargetOwner, o.TargetRepo, o.TargetBranch}).Logger()
|
||||||
|
|
||||||
|
if o.TargetBranch == "" {
|
||||||
|
// Get default branch
|
||||||
|
defaultBranch, err := giteaClient.GiteaGetRepoDefaultBranch(o.TargetOwner, o.TargetRepo)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("Could't fetch default branch from repository")
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
log.Debug().Msgf("Succesfully fetched default branch %q from Gitea", defaultBranch)
|
||||||
|
o.TargetBranch = defaultBranch
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp, err := giteaClient.GiteaGetRepoBranchTimestamp(o.TargetOwner, o.TargetRepo, o.TargetBranch)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, gitea.ErrorNotFound) {
|
||||||
|
log.Error().Err(err).Msg("Could not get latest commit's timestamp from branch")
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if timestamp == nil || timestamp.Branch == "" {
|
||||||
|
return false, fmt.Errorf("empty response")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().Msgf("Succesfully fetched latest commit's timestamp from branch: %#v", timestamp)
|
||||||
|
o.BranchTimestamp = timestamp.Timestamp
|
||||||
|
o.TargetBranch = timestamp.Branch
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Options) ContentWebLink(giteaClient *gitea.Client) string {
|
||||||
|
return giteaClient.ContentWebLink(o.TargetOwner, o.TargetRepo, o.TargetBranch, o.TargetPath) + "; rel=\"canonical\""
|
||||||
|
}
|
200
server/upstream/upstream.go
Normal file
200
server/upstream/upstream.go
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
package upstream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"codeberg.org/codeberg/pages/html"
|
||||||
|
"codeberg.org/codeberg/pages/server/context"
|
||||||
|
"codeberg.org/codeberg/pages/server/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
headerLastModified = "Last-Modified"
|
||||||
|
headerIfModifiedSince = "If-Modified-Since"
|
||||||
|
|
||||||
|
rawMime = "text/plain; charset=utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// upstreamIndexPages lists pages that may be considered as index pages for directories.
|
||||||
|
var upstreamIndexPages = []string{
|
||||||
|
"index.html",
|
||||||
|
}
|
||||||
|
|
||||||
|
// upstreamNotFoundPages lists pages that may be considered as custom 404 Not Found pages.
|
||||||
|
var upstreamNotFoundPages = []string{
|
||||||
|
"404.html",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options provides various options for the upstream request.
|
||||||
|
type Options struct {
|
||||||
|
TargetOwner string
|
||||||
|
TargetRepo string
|
||||||
|
TargetBranch string
|
||||||
|
TargetPath string
|
||||||
|
|
||||||
|
// Used for debugging purposes.
|
||||||
|
Host string
|
||||||
|
|
||||||
|
TryIndexPages bool
|
||||||
|
BranchTimestamp time.Time
|
||||||
|
// internal
|
||||||
|
appendTrailingSlash bool
|
||||||
|
redirectIfExists string
|
||||||
|
|
||||||
|
ServeRaw bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upstream requests a file from the Gitea API at GiteaRoot and writes it to the request context.
|
||||||
|
func (o *Options) Upstream(ctx *context.Context, giteaClient *gitea.Client) (final bool) {
|
||||||
|
log := log.With().Strs("upstream", []string{o.TargetOwner, o.TargetRepo, o.TargetBranch, o.TargetPath}).Logger()
|
||||||
|
|
||||||
|
if o.TargetOwner == "" || o.TargetRepo == "" {
|
||||||
|
html.ReturnErrorPage(ctx, "either repo owner or name info is missing", http.StatusBadRequest)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the branch exists and when it was modified
|
||||||
|
if o.BranchTimestamp.IsZero() {
|
||||||
|
branchExist, err := o.GetBranchTimestamp(giteaClient)
|
||||||
|
// handle 404
|
||||||
|
if err != nil && errors.Is(err, gitea.ErrorNotFound) || !branchExist {
|
||||||
|
html.ReturnErrorPage(ctx,
|
||||||
|
fmt.Sprintf("branch %q for '%s/%s' not found", o.TargetBranch, o.TargetOwner, o.TargetRepo),
|
||||||
|
http.StatusNotFound)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle unexpected errors
|
||||||
|
if err != nil {
|
||||||
|
html.ReturnErrorPage(ctx,
|
||||||
|
fmt.Sprintf("could not get timestamp of branch %q: %v", o.TargetBranch, err),
|
||||||
|
http.StatusFailedDependency)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the browser has a cached version
|
||||||
|
if ctx.Response() != nil {
|
||||||
|
if ifModifiedSince, err := time.Parse(time.RFC1123, ctx.Response().Header.Get(headerIfModifiedSince)); err == nil {
|
||||||
|
if ifModifiedSince.After(o.BranchTimestamp) {
|
||||||
|
ctx.RespWriter.WriteHeader(http.StatusNotModified)
|
||||||
|
log.Trace().Msg("check response against last modified: valid")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Trace().Msg("check response against last modified: outdated")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("Preparing")
|
||||||
|
|
||||||
|
reader, header, statusCode, err := giteaClient.ServeRawContent(o.TargetOwner, o.TargetRepo, o.TargetBranch, o.TargetPath)
|
||||||
|
if reader != nil {
|
||||||
|
defer reader.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("Aquisting")
|
||||||
|
|
||||||
|
// Handle not found error
|
||||||
|
if err != nil && errors.Is(err, gitea.ErrorNotFound) {
|
||||||
|
if o.TryIndexPages {
|
||||||
|
// copy the o struct & try if an index page exists
|
||||||
|
optionsForIndexPages := *o
|
||||||
|
optionsForIndexPages.TryIndexPages = false
|
||||||
|
optionsForIndexPages.appendTrailingSlash = true
|
||||||
|
for _, indexPage := range upstreamIndexPages {
|
||||||
|
optionsForIndexPages.TargetPath = strings.TrimSuffix(o.TargetPath, "/") + "/" + indexPage
|
||||||
|
if optionsForIndexPages.Upstream(ctx, giteaClient) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// compatibility fix for GitHub Pages (/example → /example.html)
|
||||||
|
optionsForIndexPages.appendTrailingSlash = false
|
||||||
|
optionsForIndexPages.redirectIfExists = strings.TrimSuffix(ctx.Path(), "/") + ".html"
|
||||||
|
optionsForIndexPages.TargetPath = o.TargetPath + ".html"
|
||||||
|
if optionsForIndexPages.Upstream(ctx, giteaClient) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.StatusCode = http.StatusNotFound
|
||||||
|
if o.TryIndexPages {
|
||||||
|
// copy the o struct & try if a not found page exists
|
||||||
|
optionsForNotFoundPages := *o
|
||||||
|
optionsForNotFoundPages.TryIndexPages = false
|
||||||
|
optionsForNotFoundPages.appendTrailingSlash = false
|
||||||
|
for _, notFoundPage := range upstreamNotFoundPages {
|
||||||
|
optionsForNotFoundPages.TargetPath = "/" + notFoundPage
|
||||||
|
if optionsForNotFoundPages.Upstream(ctx, giteaClient) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle unexpected client errors
|
||||||
|
if err != nil || reader == nil || statusCode != http.StatusOK {
|
||||||
|
log.Debug().Msg("Handling error")
|
||||||
|
var msg string
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
msg = "gitea client returned unexpected error"
|
||||||
|
log.Error().Err(err).Msg(msg)
|
||||||
|
msg = fmt.Sprintf("%s: %v", msg, err)
|
||||||
|
}
|
||||||
|
if reader == nil {
|
||||||
|
msg = "gitea client returned no reader"
|
||||||
|
log.Error().Msg(msg)
|
||||||
|
}
|
||||||
|
if statusCode != http.StatusOK {
|
||||||
|
msg = fmt.Sprintf("Couldn't fetch contents (status code %d)", statusCode)
|
||||||
|
log.Error().Msg(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
html.ReturnErrorPage(ctx, msg, http.StatusInternalServerError)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append trailing slash if missing (for index files), and redirect to fix filenames in general
|
||||||
|
// o.appendTrailingSlash is only true when looking for index pages
|
||||||
|
if o.appendTrailingSlash && !strings.HasSuffix(ctx.Path(), "/") {
|
||||||
|
ctx.Redirect(ctx.Path()+"/", http.StatusTemporaryRedirect)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(ctx.Path(), "/index.html") {
|
||||||
|
ctx.Redirect(strings.TrimSuffix(ctx.Path(), "index.html"), http.StatusTemporaryRedirect)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if o.redirectIfExists != "" {
|
||||||
|
ctx.Redirect(o.redirectIfExists, http.StatusTemporaryRedirect)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set ETag & MIME
|
||||||
|
o.setHeader(ctx, header)
|
||||||
|
|
||||||
|
log.Debug().Msg("Prepare response")
|
||||||
|
|
||||||
|
ctx.RespWriter.WriteHeader(ctx.StatusCode)
|
||||||
|
|
||||||
|
// Write the response body to the original request
|
||||||
|
if reader != nil {
|
||||||
|
_, err := io.Copy(ctx.RespWriter, reader)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msgf("Couldn't write body for %q", o.TargetPath)
|
||||||
|
html.ReturnErrorPage(ctx, "", http.StatusInternalServerError)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().Msg("Sending response")
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
13
server/utils/utils.go
Normal file
13
server/utils/utils.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TrimHostPort(host string) string {
|
||||||
|
i := strings.IndexByte(host, ':')
|
||||||
|
if i >= 0 {
|
||||||
|
return host[:i]
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
13
server/utils/utils_test.go
Normal file
13
server/utils/utils_test.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTrimHostPort(t *testing.T) {
|
||||||
|
assert.EqualValues(t, "aa", TrimHostPort("aa"))
|
||||||
|
assert.EqualValues(t, "", TrimHostPort(":"))
|
||||||
|
assert.EqualValues(t, "example.com", TrimHostPort("example.com:80"))
|
||||||
|
}
|
3
server/version/version.go
Normal file
3
server/version/version.go
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
var Version string = "dev"
|
Reference in New Issue
Block a user