mirror of
https://github.com/TecharoHQ/anubis.git
synced 2026-04-05 16:28:17 +00:00
Compare commits
10 Commits
json/fixsp
...
Xe/osiris
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4092180626 | ||
|
|
153da4f5ac | ||
|
|
89b6af05a3 | ||
|
|
9a711f1635 | ||
|
|
dabbe63bb6 | ||
|
|
0aed7d3688 | ||
|
|
2af731033c | ||
|
|
d9c4e37978 | ||
|
|
1eafebedbc | ||
|
|
115ee97d1d |
@@ -21,7 +21,9 @@
|
|||||||
"golang.go",
|
"golang.go",
|
||||||
"unifiedjs.vscode-mdx",
|
"unifiedjs.vscode-mdx",
|
||||||
"a-h.templ",
|
"a-h.templ",
|
||||||
"redhat.vscode-yaml"
|
"redhat.vscode-yaml",
|
||||||
|
"hashicorp.hcl",
|
||||||
|
"fredwangwang.vscode-hcl-format"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
.dockerignore
Normal file
25
.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
.env
|
||||||
|
*.deb
|
||||||
|
*.rpm
|
||||||
|
|
||||||
|
# Additional package locks
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# Go binaries and test artifacts
|
||||||
|
main
|
||||||
|
*.test
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# MacOS
|
||||||
|
.DS_store
|
||||||
|
|
||||||
|
# Intellij
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# how does this get here
|
||||||
|
doc/VERSION
|
||||||
|
|
||||||
|
web/static/js/*
|
||||||
|
!web/static/js/.gitignore
|
||||||
29
.github/workflows/docker-pr.yml
vendored
29
.github/workflows/docker-pr.yml
vendored
@@ -2,7 +2,7 @@ name: Docker image builds (pull requests)
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main" ]
|
branches: ["main"]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOCKER_METADATA_SET_OUTPUT_ENV: "true"
|
DOCKER_METADATA_SET_OUTPUT_ENV: "true"
|
||||||
@@ -11,7 +11,32 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
buildx-bake:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-tags: true
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
id: build
|
||||||
|
uses: docker/bake-action@76f9fa3a758507623da19f6092dc4089a7e61592 # v6.6.0
|
||||||
|
with:
|
||||||
|
source: .
|
||||||
|
push: true
|
||||||
|
sbom: true
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
set: |
|
||||||
|
osiris.tags=ttl.sh/techaro/pr-${{ github.event.number }}/osiris:24h
|
||||||
|
|
||||||
|
containerbuild:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|||||||
33
.github/workflows/docker.yml
vendored
33
.github/workflows/docker.yml
vendored
@@ -17,7 +17,38 @@ permissions:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
buildx-bake:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-tags: true
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||||
|
|
||||||
|
- name: Log into registry
|
||||||
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
id: build
|
||||||
|
uses: docker/bake-action@76f9fa3a758507623da19f6092dc4089a7e61592 # v6.6.0
|
||||||
|
with:
|
||||||
|
source: .
|
||||||
|
push: true
|
||||||
|
sbom: true
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
set: ""
|
||||||
|
|
||||||
|
containerbuild:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|||||||
4
.vscode/extensions.json
vendored
4
.vscode/extensions.json
vendored
@@ -5,6 +5,8 @@
|
|||||||
"golang.go",
|
"golang.go",
|
||||||
"unifiedjs.vscode-mdx",
|
"unifiedjs.vscode-mdx",
|
||||||
"a-h.templ",
|
"a-h.templ",
|
||||||
"redhat.vscode-yaml"
|
"redhat.vscode-yaml",
|
||||||
|
"hashicorp.hcl",
|
||||||
|
"fredwangwang.vscode-hcl-format"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
39
cmd/osiris/internal/config/bind.go
Normal file
39
cmd/osiris/internal/config/bind.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidHostpost = errors.New("bind: invalid host:port")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Bind struct {
|
||||||
|
HTTP string `hcl:"http"`
|
||||||
|
HTTPS string `hcl:"https"`
|
||||||
|
Metrics string `hcl:"metrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bind) Valid() error {
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
if _, _, err := net.SplitHostPort(b.HTTP); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("%w %q: %w", ErrInvalidHostpost, b.HTTP, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := net.SplitHostPort(b.HTTPS); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("%w %q: %w", ErrInvalidHostpost, b.HTTPS, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := net.SplitHostPort(b.Metrics); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("%w %q: %w", ErrInvalidHostpost, b.Metrics, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) != 0 {
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
55
cmd/osiris/internal/config/bind_test.go
Normal file
55
cmd/osiris/internal/config/bind_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBindValid(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
precondition func(t *testing.T)
|
||||||
|
bind Bind
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic",
|
||||||
|
precondition: nil,
|
||||||
|
bind: Bind{
|
||||||
|
HTTP: ":8081",
|
||||||
|
HTTPS: ":8082",
|
||||||
|
Metrics: ":8083",
|
||||||
|
},
|
||||||
|
err: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid ports",
|
||||||
|
precondition: func(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", ":8081")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { ln.Close() })
|
||||||
|
},
|
||||||
|
bind: Bind{
|
||||||
|
HTTP: "",
|
||||||
|
HTTPS: "",
|
||||||
|
Metrics: "",
|
||||||
|
},
|
||||||
|
err: ErrInvalidHostpost,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.precondition != nil {
|
||||||
|
tt.precondition(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tt.bind.Valid(); !errors.Is(err, tt.err) {
|
||||||
|
t.Logf("want: %v", tt.err)
|
||||||
|
t.Logf("got: %v", err)
|
||||||
|
t.Error("got wrong error from validation function")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
31
cmd/osiris/internal/config/config.go
Normal file
31
cmd/osiris/internal/config/config.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Toplevel struct {
|
||||||
|
Bind Bind `hcl:"bind,block"`
|
||||||
|
Domains []Domain `hcl:"domain,block"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Toplevel) Valid() error {
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
if err := t.Bind.Valid(); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("invalid bind block:\n%w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range t.Domains {
|
||||||
|
if err := d.Valid(); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("when parsing domain %s: %w", d.Name, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) != 0 {
|
||||||
|
return fmt.Errorf("invalid configuration file:\n%w", errors.Join(errs...))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
66
cmd/osiris/internal/config/domain.go
Normal file
66
cmd/osiris/internal/config/domain.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"golang.org/x/net/idna"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidDomainName = errors.New("domain: name is invalid")
|
||||||
|
ErrInvalidDomainTLSConfig = errors.New("domain: TLS config is invalid")
|
||||||
|
ErrInvalidURL = errors.New("invalid URL")
|
||||||
|
ErrInvalidURLScheme = errors.New("URL has invalid scheme")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Domain struct {
|
||||||
|
Name string `hcl:"name,label"`
|
||||||
|
TLS TLS `hcl:"tls,block"`
|
||||||
|
Target string `hcl:"target"`
|
||||||
|
InsecureSkipVerify bool `hcl:"insecure_skip_verify,optional"`
|
||||||
|
HealthTarget string `hcl:"health_target"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Domain) Valid() error {
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
if _, err := idna.Lookup.ToASCII(d.Name); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("%w %q: %w", ErrInvalidDomainName, d.Name, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := d.TLS.Valid(); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("%w: %w", ErrInvalidDomainTLSConfig, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := isURLValid(d.Target); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("target has %w %q: %w", ErrInvalidURL, d.Target, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := isURLValid(d.HealthTarget); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("health_target has %w %q: %w", ErrInvalidURL, d.HealthTarget, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) != 0 {
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isURLValid(input string) error {
|
||||||
|
u, err := url.Parse(input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch u.Scheme {
|
||||||
|
case "http", "https", "h2c", "unix":
|
||||||
|
// do nothing
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w %s has scheme %s (want http, https, h2c, unix)", ErrInvalidURLScheme, input, u.Scheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
89
cmd/osiris/internal/config/domain_test.go
Normal file
89
cmd/osiris/internal/config/domain_test.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDomainValid(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
input Domain
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple happy path",
|
||||||
|
input: Domain{
|
||||||
|
Name: "anubis.techaro.lol",
|
||||||
|
TLS: TLS{
|
||||||
|
Cert: "./testdata/tls/selfsigned.crt",
|
||||||
|
Key: "./testdata/tls/selfsigned.key",
|
||||||
|
},
|
||||||
|
Target: "http://localhost:3000",
|
||||||
|
HealthTarget: "http://localhost:9091/healthz",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid domain name",
|
||||||
|
input: Domain{
|
||||||
|
Name: "\uFFFD.techaro.lol",
|
||||||
|
TLS: TLS{
|
||||||
|
Cert: "./testdata/tls/selfsigned.crt",
|
||||||
|
Key: "./testdata/tls/selfsigned.key",
|
||||||
|
},
|
||||||
|
Target: "http://localhost:3000",
|
||||||
|
HealthTarget: "http://localhost:9091/healthz",
|
||||||
|
},
|
||||||
|
err: ErrInvalidDomainName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid tls config",
|
||||||
|
input: Domain{
|
||||||
|
Name: "anubis.techaro.lol",
|
||||||
|
TLS: TLS{
|
||||||
|
Cert: "./testdata/tls/invalid.crt",
|
||||||
|
Key: "./testdata/tls/invalid.key",
|
||||||
|
},
|
||||||
|
Target: "http://localhost:3000",
|
||||||
|
HealthTarget: "http://localhost:9091/healthz",
|
||||||
|
},
|
||||||
|
err: ErrInvalidDomainTLSConfig,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid URL",
|
||||||
|
input: Domain{
|
||||||
|
Name: "anubis.techaro.lol",
|
||||||
|
TLS: TLS{
|
||||||
|
Cert: "./testdata/tls/selfsigned.crt",
|
||||||
|
Key: "./testdata/tls/selfsigned.key",
|
||||||
|
},
|
||||||
|
Target: "file://[::1:3000",
|
||||||
|
HealthTarget: "file://[::1:9091/healthz",
|
||||||
|
},
|
||||||
|
err: ErrInvalidURL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong URL scheme",
|
||||||
|
input: Domain{
|
||||||
|
Name: "anubis.techaro.lol",
|
||||||
|
TLS: TLS{
|
||||||
|
Cert: "./testdata/tls/selfsigned.crt",
|
||||||
|
Key: "./testdata/tls/selfsigned.key",
|
||||||
|
},
|
||||||
|
Target: "file://localhost:3000",
|
||||||
|
HealthTarget: "file://localhost:9091/healthz",
|
||||||
|
},
|
||||||
|
err: ErrInvalidURLScheme,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if err := tt.input.Valid(); !errors.Is(err, tt.err) {
|
||||||
|
t.Logf("want: %v", tt.err)
|
||||||
|
t.Logf("got: %v", err)
|
||||||
|
t.Error("got wrong error from validation function")
|
||||||
|
} else {
|
||||||
|
t.Log(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
1
cmd/osiris/internal/config/testdata/tls/invalid.crt
vendored
Normal file
1
cmd/osiris/internal/config/testdata/tls/invalid.crt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
aorsentaeiorsntoiearnstoieanrsoietnaioresntoeiar
|
||||||
1
cmd/osiris/internal/config/testdata/tls/invalid.key
vendored
Normal file
1
cmd/osiris/internal/config/testdata/tls/invalid.key
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
aorsentaeiorsntoiearnstoieanrsoietnaioresntoeiar
|
||||||
11
cmd/osiris/internal/config/testdata/tls/selfsigned.crt
vendored
Normal file
11
cmd/osiris/internal/config/testdata/tls/selfsigned.crt
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBnzCCAVGgAwIBAgIUAw8funCpiB3ZAAPoWdSCWnzbsFIwBQYDK2VwMEUxCzAJ
|
||||||
|
BgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5l
|
||||||
|
dCBXaWRnaXRzIFB0eSBMdGQwHhcNMjUwNzE4MTkwMjM1WhcNMjUwODE3MTkwMjM1
|
||||||
|
WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwY
|
||||||
|
SW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMCowBQYDK2VwAyEAcXDHXV3vgpvjtTaz
|
||||||
|
s0Oj/73rMr06bhyGGhleYS1MNoWjUzBRMB0GA1UdDgQWBBQwmfKPthucFHB6Wfgz
|
||||||
|
2Nj5nkMQOjAfBgNVHSMEGDAWgBQwmfKPthucFHB6Wfgz2Nj5nkMQOjAPBgNVHRMB
|
||||||
|
Af8EBTADAQH/MAUGAytlcANBALBYbULlGwB7Ro0UTgUoQDNxEvayn3qzVFHIt7lC
|
||||||
|
/2/NzNBkk4yPT+a4mbRuydxLkv+JIvmQbarZxpksYnWlCAM=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
3
cmd/osiris/internal/config/testdata/tls/selfsigned.key
vendored
Normal file
3
cmd/osiris/internal/config/testdata/tls/selfsigned.key
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MC4CAQAwBQYDK2VwBCIEIOHKoX22Mha6SnnpLm34fSSfTUDbRiDCi6N1nOgTOlds
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
40
cmd/osiris/internal/config/tls.go
Normal file
40
cmd/osiris/internal/config/tls.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrCantReadTLS = errors.New("tls: can't read TLS")
|
||||||
|
ErrInvalidTLSKeypair = errors.New("tls: can't parse TLS keypair")
|
||||||
|
)
|
||||||
|
|
||||||
|
type TLS struct {
|
||||||
|
Cert string `hcl:"cert"`
|
||||||
|
Key string `hcl:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t TLS) Valid() error {
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
if _, err := os.Stat(t.Cert); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("%w certificate %s: %w", ErrCantReadTLS, t.Cert, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(t.Key); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("%w key %s: %w", ErrCantReadTLS, t.Key, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tls.LoadX509KeyPair(t.Cert, t.Key); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("%w (%s, %s): %w", ErrInvalidTLSKeypair, t.Cert, t.Key, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) != 0 {
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
48
cmd/osiris/internal/config/tls_test.go
Normal file
48
cmd/osiris/internal/config/tls_test.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTLSValid(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
input TLS
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple selfsigned",
|
||||||
|
input: TLS{
|
||||||
|
Cert: "./testdata/tls/selfsigned.crt",
|
||||||
|
Key: "./testdata/tls/selfsigned.key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "files don't exist",
|
||||||
|
input: TLS{
|
||||||
|
Cert: "./testdata/tls/nonexistent.crt",
|
||||||
|
Key: "./testdata/tls/nonexistent.key",
|
||||||
|
},
|
||||||
|
err: ErrCantReadTLS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid keypair",
|
||||||
|
input: TLS{
|
||||||
|
Cert: "./testdata/tls/invalid.crt",
|
||||||
|
Key: "./testdata/tls/invalid.key",
|
||||||
|
},
|
||||||
|
err: ErrInvalidTLSKeypair,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if err := tt.input.Valid(); !errors.Is(err, tt.err) {
|
||||||
|
t.Logf("want: %v", tt.err)
|
||||||
|
t.Logf("got: %v", err)
|
||||||
|
t.Error("got wrong error from validation function")
|
||||||
|
} else {
|
||||||
|
t.Log(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
85
cmd/osiris/internal/entrypoint/entrypoint.go
Normal file
85
cmd/osiris/internal/entrypoint/entrypoint.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package entrypoint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/TecharoHQ/anubis/cmd/osiris/internal/config"
|
||||||
|
"github.com/TecharoHQ/anubis/internal"
|
||||||
|
"github.com/hashicorp/hcl/v2/hclsimple"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
healthv1 "google.golang.org/grpc/health/grpc_health_v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
ConfigFname string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Main(ctx context.Context, opts Options) error {
|
||||||
|
internal.SetHealth("osiris", healthv1.HealthCheckResponse_NOT_SERVING)
|
||||||
|
|
||||||
|
var cfg config.Toplevel
|
||||||
|
if err := hclsimple.DecodeFile(opts.ConfigFname, nil, &cfg); err != nil {
|
||||||
|
return fmt.Errorf("can't read configuration file %s:\n\n%w", opts.ConfigFname, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.Valid(); err != nil {
|
||||||
|
return fmt.Errorf("configuration file %s is invalid:\n\n%w", opts.ConfigFname, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rtr, err := NewRouter(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rtr.opts = opts
|
||||||
|
go rtr.backgroundReloadConfig(ctx)
|
||||||
|
|
||||||
|
g, gCtx := errgroup.WithContext(ctx)
|
||||||
|
|
||||||
|
// HTTP
|
||||||
|
g.Go(func() error {
|
||||||
|
ln, err := net.Listen("tcp", cfg.Bind.HTTP)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("(HTTP) can't bind to tcp %s: %w", cfg.Bind.HTTP, err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
go func(ctx context.Context) {
|
||||||
|
<-ctx.Done()
|
||||||
|
ln.Close()
|
||||||
|
}(ctx)
|
||||||
|
|
||||||
|
slog.Info("listening", "for", "http", "bind", cfg.Bind.HTTP)
|
||||||
|
|
||||||
|
return rtr.HandleHTTP(gCtx, ln)
|
||||||
|
})
|
||||||
|
|
||||||
|
// HTTPS
|
||||||
|
g.Go(func() error {
|
||||||
|
ln, err := net.Listen("tcp", cfg.Bind.HTTPS)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("(https) can't bind to tcp %s: %w", cfg.Bind.HTTPS, err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
go func(ctx context.Context) {
|
||||||
|
<-ctx.Done()
|
||||||
|
ln.Close()
|
||||||
|
}(ctx)
|
||||||
|
|
||||||
|
slog.Info("listening", "for", "https", "bind", cfg.Bind.HTTPS)
|
||||||
|
|
||||||
|
return rtr.HandleHTTPS(gCtx, ln)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
g.Go(func() error {
|
||||||
|
return rtr.ListenAndServeMetrics(gCtx, cfg.Bind.Metrics)
|
||||||
|
})
|
||||||
|
|
||||||
|
internal.SetHealth("osiris", healthv1.HealthCheckResponse_SERVING)
|
||||||
|
|
||||||
|
return g.Wait()
|
||||||
|
}
|
||||||
93
cmd/osiris/internal/entrypoint/entrypoint_test.go
Normal file
93
cmd/osiris/internal/entrypoint/entrypoint_test.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package entrypoint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMainGoodConfig(t *testing.T) {
|
||||||
|
files, err := os.ReadDir("./testdata/good")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, st := range files {
|
||||||
|
t.Run(st.Name(), func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
cfg := loadConfig(t, filepath.Join("testdata", "good", st.Name()))
|
||||||
|
|
||||||
|
go func(ctx context.Context) {
|
||||||
|
if err := Main(ctx, Options{
|
||||||
|
ConfigFname: filepath.Join("testdata", "good", st.Name()),
|
||||||
|
}); err != nil {
|
||||||
|
var netOpErr *net.OpError
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, context.Canceled):
|
||||||
|
// Context was canceled, this is expected
|
||||||
|
return
|
||||||
|
case errors.As(err, &netOpErr):
|
||||||
|
// Network operation error occurred
|
||||||
|
t.Logf("Network operation error: %v", netOpErr)
|
||||||
|
return
|
||||||
|
case errors.Is(err, http.ErrServerClosed):
|
||||||
|
// Server was closed, this is expected
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
// Other unexpected error
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(ctx)
|
||||||
|
|
||||||
|
wait := 5 * time.Millisecond
|
||||||
|
|
||||||
|
for i := range make([]struct{}, 10) {
|
||||||
|
if i != 0 {
|
||||||
|
time.Sleep(wait)
|
||||||
|
wait = wait * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("try %d (wait=%s)", i+1, wait)
|
||||||
|
|
||||||
|
resp, err := http.Get("http://localhost" + cfg.Bind.Metrics + "/readyz")
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatal("router initialization did not work")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainBadConfig(t *testing.T) {
|
||||||
|
files, err := os.ReadDir("./testdata/bad")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, st := range files {
|
||||||
|
t.Run(st.Name(), func(t *testing.T) {
|
||||||
|
if err := Main(t.Context(), Options{
|
||||||
|
ConfigFname: filepath.Join("testdata", "bad", st.Name()),
|
||||||
|
}); err == nil {
|
||||||
|
t.Error("wanted an error but got none")
|
||||||
|
} else {
|
||||||
|
t.Log(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
35
cmd/osiris/internal/entrypoint/h2c.go
Normal file
35
cmd/osiris/internal/entrypoint/h2c.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package entrypoint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"golang.org/x/net/http2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newH2CReverseProxy(target *url.URL) *httputil.ReverseProxy {
|
||||||
|
target.Scheme = "http"
|
||||||
|
|
||||||
|
director := func(req *http.Request) {
|
||||||
|
req.URL.Scheme = target.Scheme
|
||||||
|
req.URL.Host = target.Host
|
||||||
|
req.Host = target.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use h2c transport
|
||||||
|
transport := &http2.Transport{
|
||||||
|
AllowHTTP: true,
|
||||||
|
DialTLS: func(network, addr string, cfg *tls.Config) (net.Conn, error) {
|
||||||
|
// Just do plain TCP (h2c)
|
||||||
|
return net.Dial(network, addr)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return &httputil.ReverseProxy{
|
||||||
|
Director: director,
|
||||||
|
Transport: transport,
|
||||||
|
}
|
||||||
|
}
|
||||||
51
cmd/osiris/internal/entrypoint/h2c_test.go
Normal file
51
cmd/osiris/internal/entrypoint/h2c_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package entrypoint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/net/http2"
|
||||||
|
"golang.org/x/net/http2/h2c"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newH2cServer(t *testing.T, h http.Handler) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
h2s := &http2.Server{}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(h2c.NewHandler(h, h2s))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
srv.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestH2CReverseProxy(t *testing.T) {
|
||||||
|
h := &ackHandler{}
|
||||||
|
|
||||||
|
srv := newH2cServer(t, h)
|
||||||
|
|
||||||
|
u, err := url.Parse(srv.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rp := httptest.NewServer(newH2CReverseProxy(u))
|
||||||
|
defer rp.Close()
|
||||||
|
|
||||||
|
resp, err := rp.Client().Get(rp.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("wrong status code from reverse proxy: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !h.ack {
|
||||||
|
t.Error("h2c handler was not executed")
|
||||||
|
}
|
||||||
|
}
|
||||||
72
cmd/osiris/internal/entrypoint/metrics.go
Normal file
72
cmd/osiris/internal/entrypoint/metrics.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package entrypoint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/TecharoHQ/anubis/internal"
|
||||||
|
healthv1 "google.golang.org/grpc/health/grpc_health_v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func healthz(w http.ResponseWriter, r *http.Request) {
|
||||||
|
services, err := internal.HealthSrv.List(r.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("can't get list of services", "err", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var keys []string
|
||||||
|
for k := range services.Statuses {
|
||||||
|
if k == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
var msg bytes.Buffer
|
||||||
|
|
||||||
|
var healthy bool = true
|
||||||
|
|
||||||
|
for _, k := range keys {
|
||||||
|
st := services.Statuses[k].GetStatus()
|
||||||
|
fmt.Fprintf(&msg, "%s: %s\n", k, st)
|
||||||
|
switch st {
|
||||||
|
case healthv1.HealthCheckResponse_SERVING:
|
||||||
|
// do nothing
|
||||||
|
default:
|
||||||
|
healthy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !healthy {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write(msg.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func readyz(w http.ResponseWriter, r *http.Request) {
|
||||||
|
st, ok := internal.GetHealth("osiris")
|
||||||
|
if !ok {
|
||||||
|
slog.Error("health service osiris does not exist, file a bug")
|
||||||
|
http.Error(w, "health service osiris does not exist", http.StatusExpectationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch st {
|
||||||
|
case healthv1.HealthCheckResponse_NOT_SERVING:
|
||||||
|
http.Error(w, "NOT OK", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
case healthv1.HealthCheckResponse_SERVING:
|
||||||
|
fmt.Fprintln(w, "OK")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
http.Error(w, "UNKNOWN", http.StatusFailedDependency)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
66
cmd/osiris/internal/entrypoint/metrics_test.go
Normal file
66
cmd/osiris/internal/entrypoint/metrics_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package entrypoint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TecharoHQ/anubis/internal"
|
||||||
|
healthv1 "google.golang.org/grpc/health/grpc_health_v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHealthz(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(healthz))
|
||||||
|
|
||||||
|
internal.SetHealth("osiris", healthv1.HealthCheckResponse_NOT_SERVING)
|
||||||
|
|
||||||
|
resp, err := srv.Client().Get(srv.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
t.Errorf("wanted not ready but got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal.SetHealth("osiris", healthv1.HealthCheckResponse_SERVING)
|
||||||
|
|
||||||
|
resp, err = srv.Client().Get(srv.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("wanted ready but got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadyz(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(readyz))
|
||||||
|
|
||||||
|
internal.SetHealth("osiris", healthv1.HealthCheckResponse_NOT_SERVING)
|
||||||
|
|
||||||
|
resp, err := srv.Client().Get(srv.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
t.Errorf("wanted not ready but got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal.SetHealth("osiris", healthv1.HealthCheckResponse_SERVING)
|
||||||
|
|
||||||
|
resp, err = srv.Client().Get(srv.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("wanted ready but got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
320
cmd/osiris/internal/entrypoint/router.go
Normal file
320
cmd/osiris/internal/entrypoint/router.go
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
package entrypoint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TecharoHQ/anubis/cmd/osiris/internal/config"
|
||||||
|
"github.com/TecharoHQ/anubis/internal"
|
||||||
|
"github.com/TecharoHQ/anubis/internal/fingerprint"
|
||||||
|
"github.com/felixge/httpsnoop"
|
||||||
|
"github.com/hashicorp/hcl/v2/hclsimple"
|
||||||
|
"github.com/lum8rjack/go-ja4h"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrTargetInvalid = errors.New("[unexpected] target invalid")
|
||||||
|
ErrNoHandler = errors.New("[unexpected] no handler for domain")
|
||||||
|
ErrInvalidTLSKeypair = errors.New("[unexpected] invalid TLS keypair")
|
||||||
|
ErrNoCert = errors.New("this server does not have a certificate for that domain")
|
||||||
|
|
||||||
|
requestsPerDomain = promauto.NewGaugeVec(prometheus.GaugeOpts{
|
||||||
|
Namespace: "techaro",
|
||||||
|
Subsystem: "osiris",
|
||||||
|
Name: "request_count",
|
||||||
|
}, []string{"domain", "method", "response_code"})
|
||||||
|
|
||||||
|
responseTime = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Namespace: "techaro",
|
||||||
|
Subsystem: "osiris",
|
||||||
|
Name: "response_time",
|
||||||
|
}, []string{"domain"})
|
||||||
|
|
||||||
|
unresolvedRequests = promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Namespace: "techaro",
|
||||||
|
Subsystem: "osiris",
|
||||||
|
Name: "unresolved_requests",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
type Router struct {
|
||||||
|
lock sync.RWMutex
|
||||||
|
routes map[string]http.Handler
|
||||||
|
tlsCerts map[string]*tls.Certificate
|
||||||
|
opts Options
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rtr *Router) setConfig(c config.Toplevel) error {
|
||||||
|
var errs []error
|
||||||
|
newMap := map[string]http.Handler{}
|
||||||
|
newCerts := map[string]*tls.Certificate{}
|
||||||
|
|
||||||
|
for _, d := range c.Domains {
|
||||||
|
var domainErrs []error
|
||||||
|
|
||||||
|
u, err := url.Parse(d.Target)
|
||||||
|
if err != nil {
|
||||||
|
domainErrs = append(domainErrs, fmt.Errorf("%w %q: %v", ErrTargetInvalid, d.Target, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
var h http.Handler
|
||||||
|
|
||||||
|
if u != nil {
|
||||||
|
switch u.Scheme {
|
||||||
|
case "http", "https":
|
||||||
|
rp := httputil.NewSingleHostReverseProxy(u)
|
||||||
|
|
||||||
|
if d.InsecureSkipVerify {
|
||||||
|
rp.Transport = &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h = rp
|
||||||
|
case "h2c":
|
||||||
|
h = newH2CReverseProxy(u)
|
||||||
|
case "unix":
|
||||||
|
h = &httputil.ReverseProxy{
|
||||||
|
Director: func(r *http.Request) {
|
||||||
|
r.URL.Scheme = "http"
|
||||||
|
r.URL.Host = d.Name
|
||||||
|
r.Host = d.Name
|
||||||
|
},
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
return net.Dial("unix", strings.TrimPrefix(d.Target, "unix://"))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if h == nil {
|
||||||
|
domainErrs = append(domainErrs, ErrNoHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
newMap[d.Name] = h
|
||||||
|
|
||||||
|
cert, err := tls.LoadX509KeyPair(d.TLS.Cert, d.TLS.Key)
|
||||||
|
if err != nil {
|
||||||
|
domainErrs = append(domainErrs, fmt.Errorf("%w: %w", ErrInvalidTLSKeypair, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
newCerts[d.Name] = &cert
|
||||||
|
|
||||||
|
if len(domainErrs) != 0 {
|
||||||
|
errs = append(errs, fmt.Errorf("invalid domain %s: %w", d.Name, errors.Join(domainErrs...)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) != 0 {
|
||||||
|
return fmt.Errorf("can't compile config to routing map: %w", errors.Join(errs...))
|
||||||
|
}
|
||||||
|
|
||||||
|
rtr.lock.Lock()
|
||||||
|
rtr.routes = newMap
|
||||||
|
rtr.tlsCerts = newCerts
|
||||||
|
rtr.lock.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rtr *Router) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
|
rtr.lock.RLock()
|
||||||
|
cert, ok := rtr.tlsCerts[hello.ServerName]
|
||||||
|
rtr.lock.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrNoCert
|
||||||
|
}
|
||||||
|
|
||||||
|
return cert, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rtr *Router) loadConfig() error {
|
||||||
|
slog.Info("reloading config", "fname", rtr.opts.ConfigFname)
|
||||||
|
var cfg config.Toplevel
|
||||||
|
if err := hclsimple.DecodeFile(rtr.opts.ConfigFname, nil, &cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.Valid(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rtr.setConfig(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("done!")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rtr *Router) backgroundReloadConfig(ctx context.Context) {
|
||||||
|
t := time.NewTicker(time.Hour)
|
||||||
|
defer t.Stop()
|
||||||
|
ch := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(ch, syscall.SIGHUP)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if err := rtr.loadConfig(); err != nil {
|
||||||
|
slog.Error("can't reload config", "fname", rtr.opts.ConfigFname, "err", err)
|
||||||
|
}
|
||||||
|
case <-ch:
|
||||||
|
if err := rtr.loadConfig(); err != nil {
|
||||||
|
slog.Error("can't reload config", "fname", rtr.opts.ConfigFname, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRouter(c config.Toplevel) (*Router, error) {
|
||||||
|
result := &Router{
|
||||||
|
routes: map[string]http.Handler{},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := result.setConfig(c); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rtr *Router) HandleHTTP(ctx context.Context, ln net.Listener) error {
|
||||||
|
srv := http.Server{
|
||||||
|
Handler: rtr,
|
||||||
|
ErrorLog: internal.GetFilteredHTTPLogger(),
|
||||||
|
}
|
||||||
|
|
||||||
|
go func(ctx context.Context) {
|
||||||
|
<-ctx.Done()
|
||||||
|
srv.Close()
|
||||||
|
}(ctx)
|
||||||
|
|
||||||
|
return srv.Serve(ln)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rtr *Router) HandleHTTPS(ctx context.Context, ln net.Listener) error {
|
||||||
|
tc := &tls.Config{
|
||||||
|
GetCertificate: rtr.GetCertificate,
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Handler: rtr,
|
||||||
|
ErrorLog: internal.GetFilteredHTTPLogger(),
|
||||||
|
TLSConfig: tc,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func(ctx context.Context) {
|
||||||
|
<-ctx.Done()
|
||||||
|
srv.Close()
|
||||||
|
}(ctx)
|
||||||
|
|
||||||
|
fingerprint.ApplyTLSFingerprinter(srv)
|
||||||
|
|
||||||
|
return srv.ServeTLS(ln, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rtr *Router) ListenAndServeMetrics(ctx context.Context, addr string) error {
|
||||||
|
ln, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("(metrics) can't bind to tcp %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
go func(ctx context.Context) {
|
||||||
|
<-ctx.Done()
|
||||||
|
ln.Close()
|
||||||
|
}(ctx)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
mux.Handle("/metrics", promhttp.Handler())
|
||||||
|
mux.HandleFunc("/readyz", readyz)
|
||||||
|
mux.HandleFunc("/healthz", healthz)
|
||||||
|
|
||||||
|
slog.Info("listening", "for", "metrics", "bind", addr)
|
||||||
|
|
||||||
|
srv := http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: mux,
|
||||||
|
ErrorLog: internal.GetFilteredHTTPLogger(),
|
||||||
|
}
|
||||||
|
|
||||||
|
go func(ctx context.Context) {
|
||||||
|
<-ctx.Done()
|
||||||
|
srv.Close()
|
||||||
|
}(ctx)
|
||||||
|
|
||||||
|
return srv.Serve(ln)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rtr *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var host = r.Host
|
||||||
|
|
||||||
|
if strings.Contains(host, ":") {
|
||||||
|
host, _, _ = net.SplitHostPort(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
var h http.Handler
|
||||||
|
var ok bool
|
||||||
|
|
||||||
|
ja4hFP := ja4h.JA4H(r)
|
||||||
|
|
||||||
|
slog.Info("got request", "method", r.Method, "host", host, "path", r.URL.Path)
|
||||||
|
|
||||||
|
rtr.lock.RLock()
|
||||||
|
h, ok = rtr.routes[host]
|
||||||
|
rtr.lock.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
unresolvedRequests.Inc()
|
||||||
|
http.NotFound(w, r) // TODO(Xe): brand this
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Header.Set("X-Http-Ja4h-Fingerprint", ja4hFP)
|
||||||
|
|
||||||
|
if fp := fingerprint.GetTLSFingerprint(r); fp != nil {
|
||||||
|
if ja3n := fp.JA3N(); ja3n != nil {
|
||||||
|
r.Header.Set("X-Tls-Ja3n-Fingerprint", ja3n.String())
|
||||||
|
}
|
||||||
|
if ja4 := fp.JA4(); ja4 != nil {
|
||||||
|
r.Header.Set("X-Tls-Ja4-Fingerprint", ja4.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tcpFP := fingerprint.GetTCPFingerprint(r); tcpFP != nil {
|
||||||
|
r.Header.Set("X-Tcp-Ja4t-Fingerprint", tcpFP.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
m := httpsnoop.CaptureMetrics(h, w, r)
|
||||||
|
|
||||||
|
requestsPerDomain.WithLabelValues(host, r.Method, fmt.Sprint(m.Code)).Inc()
|
||||||
|
responseTime.WithLabelValues(host).Observe(float64(m.Duration.Milliseconds()))
|
||||||
|
|
||||||
|
slog.Info("request completed", "host", host, "method", r.Method, "response_code", m.Code, "duration_ms", m.Duration.Milliseconds())
|
||||||
|
}
|
||||||
319
cmd/osiris/internal/entrypoint/router_test.go
Normal file
319
cmd/osiris/internal/entrypoint/router_test.go
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
package entrypoint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TecharoHQ/anubis/cmd/osiris/internal/config"
|
||||||
|
"github.com/hashicorp/hcl/v2/hclsimple"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadConfig(t *testing.T, fname string) config.Toplevel {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var cfg config.Toplevel
|
||||||
|
if err := hclsimple.DecodeFile(fname, nil, &cfg); err != nil {
|
||||||
|
t.Fatalf("can't read configuration file %s: %v", fname, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.Valid(); err != nil {
|
||||||
|
t.Errorf("configuration file %s is invalid: %v", "./testdata/selfsigned.hcl", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRouter(t *testing.T, cfg config.Toplevel) *Router {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rtr, err := NewRouter(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRouter(t *testing.T) {
|
||||||
|
cfg := loadConfig(t, "./testdata/good/selfsigned.hcl")
|
||||||
|
rtr := newRouter(t, cfg)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(rtr)
|
||||||
|
defer srv.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRouterFails(t *testing.T) {
|
||||||
|
cfg := loadConfig(t, "./testdata/good/selfsigned.hcl")
|
||||||
|
|
||||||
|
cfg.Domains = append(cfg.Domains, config.Domain{
|
||||||
|
Name: "test1.internal",
|
||||||
|
TLS: config.TLS{
|
||||||
|
Cert: "./testdata/tls/invalid.crt",
|
||||||
|
Key: "./testdata/tls/invalid.key",
|
||||||
|
},
|
||||||
|
Target: cfg.Domains[0].Target,
|
||||||
|
HealthTarget: cfg.Domains[0].HealthTarget,
|
||||||
|
})
|
||||||
|
|
||||||
|
rtr, err := NewRouter(cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("wanted an error but got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(rtr)
|
||||||
|
defer srv.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouterSetConfig(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
configFname string
|
||||||
|
mutation func(cfg config.Toplevel) config.Toplevel
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic",
|
||||||
|
configFname: "./testdata/good/selfsigned.hcl",
|
||||||
|
mutation: func(cfg config.Toplevel) config.Toplevel {
|
||||||
|
return cfg
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all schemes",
|
||||||
|
configFname: "./testdata/good/selfsigned.hcl",
|
||||||
|
mutation: func(cfg config.Toplevel) config.Toplevel {
|
||||||
|
cfg.Domains = append(cfg.Domains, config.Domain{
|
||||||
|
Name: "http.internal",
|
||||||
|
TLS: cfg.Domains[0].TLS,
|
||||||
|
Target: "http://[::1]:3000",
|
||||||
|
HealthTarget: cfg.Domains[0].HealthTarget,
|
||||||
|
})
|
||||||
|
cfg.Domains = append(cfg.Domains, config.Domain{
|
||||||
|
Name: "https.internal",
|
||||||
|
TLS: cfg.Domains[0].TLS,
|
||||||
|
Target: "https://[::1]:3000",
|
||||||
|
HealthTarget: cfg.Domains[0].HealthTarget,
|
||||||
|
})
|
||||||
|
cfg.Domains = append(cfg.Domains, config.Domain{
|
||||||
|
Name: "h2c.internal",
|
||||||
|
TLS: cfg.Domains[0].TLS,
|
||||||
|
Target: "h2c://[::1]:3000",
|
||||||
|
HealthTarget: cfg.Domains[0].HealthTarget,
|
||||||
|
})
|
||||||
|
cfg.Domains = append(cfg.Domains, config.Domain{
|
||||||
|
Name: "unix.internal",
|
||||||
|
TLS: cfg.Domains[0].TLS,
|
||||||
|
Target: "unix://foo.sock",
|
||||||
|
HealthTarget: cfg.Domains[0].HealthTarget,
|
||||||
|
})
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid TLS",
|
||||||
|
configFname: "./testdata/good/selfsigned.hcl",
|
||||||
|
mutation: func(cfg config.Toplevel) config.Toplevel {
|
||||||
|
cfg.Domains = append(cfg.Domains, config.Domain{
|
||||||
|
Name: "test1.internal",
|
||||||
|
TLS: config.TLS{
|
||||||
|
Cert: "./testdata/tls/invalid.crt",
|
||||||
|
Key: "./testdata/tls/invalid.key",
|
||||||
|
},
|
||||||
|
Target: cfg.Domains[0].Target,
|
||||||
|
HealthTarget: cfg.Domains[0].HealthTarget,
|
||||||
|
})
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
},
|
||||||
|
err: ErrInvalidTLSKeypair,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "target is not a valid URL",
|
||||||
|
configFname: "./testdata/good/selfsigned.hcl",
|
||||||
|
mutation: func(cfg config.Toplevel) config.Toplevel {
|
||||||
|
cfg.Domains = append(cfg.Domains, config.Domain{
|
||||||
|
Name: "test1.internal",
|
||||||
|
TLS: cfg.Domains[0].TLS,
|
||||||
|
Target: "http://[::1:443",
|
||||||
|
HealthTarget: cfg.Domains[0].HealthTarget,
|
||||||
|
})
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
},
|
||||||
|
err: ErrTargetInvalid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid target scheme",
|
||||||
|
configFname: "./testdata/good/selfsigned.hcl",
|
||||||
|
mutation: func(cfg config.Toplevel) config.Toplevel {
|
||||||
|
cfg.Domains = append(cfg.Domains, config.Domain{
|
||||||
|
Name: "test1.internal",
|
||||||
|
TLS: cfg.Domains[0].TLS,
|
||||||
|
Target: "foo://",
|
||||||
|
HealthTarget: cfg.Domains[0].HealthTarget,
|
||||||
|
})
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
},
|
||||||
|
err: ErrNoHandler,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := loadConfig(t, tt.configFname)
|
||||||
|
rtr := newRouter(t, cfg)
|
||||||
|
|
||||||
|
cfg = tt.mutation(cfg)
|
||||||
|
|
||||||
|
if err := rtr.setConfig(cfg); !errors.Is(err, tt.err) {
|
||||||
|
t.Logf("want: %v", tt.err)
|
||||||
|
t.Logf("got: %v", err)
|
||||||
|
t.Error("got wrong error from rtr.setConfig function")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ackHandler struct {
|
||||||
|
ack bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ah *ackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ah.ack = true
|
||||||
|
fmt.Fprintln(w, "OK")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ah *ackHandler) Reset() {
|
||||||
|
ah.ack = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUnixServer(t *testing.T, h http.Handler) string {
|
||||||
|
sockName := filepath.Join(t.TempDir(), "s")
|
||||||
|
ln, err := net.Listen("unix", sockName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("can't listen on %s: %v", sockName, err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ln.Close()
|
||||||
|
os.Remove(sockName)
|
||||||
|
})
|
||||||
|
|
||||||
|
go func(ctx context.Context) {
|
||||||
|
srv := &http.Server{
|
||||||
|
Handler: h,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
srv.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
srv.Serve(ln)
|
||||||
|
}(t.Context())
|
||||||
|
|
||||||
|
return "unix://" + sockName
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouterGetCertificate(t *testing.T) {
|
||||||
|
cfg := loadConfig(t, "./testdata/good/selfsigned.hcl")
|
||||||
|
rtr := newRouter(t, cfg)
|
||||||
|
|
||||||
|
for _, tt := range []struct {
|
||||||
|
domainName string
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
domainName: "osiris.local.cetacean.club",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domainName: "whacky-fun.local",
|
||||||
|
err: ErrNoCert,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tt.domainName, func(t *testing.T) {
|
||||||
|
if _, err := rtr.GetCertificate(&tls.ClientHelloInfo{ServerName: tt.domainName}); !errors.Is(err, tt.err) {
|
||||||
|
t.Logf("want: %v", tt.err)
|
||||||
|
t.Logf("got: %v", err)
|
||||||
|
t.Error("got wrong error from rtr.GetCertificate")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouterServeAllProtocols(t *testing.T) {
|
||||||
|
cfg := loadConfig(t, "./testdata/good/all_protocols.hcl")
|
||||||
|
|
||||||
|
httpAckHandler := &ackHandler{}
|
||||||
|
httpsAckHandler := &ackHandler{}
|
||||||
|
h2cAckHandler := &ackHandler{}
|
||||||
|
unixAckHandler := &ackHandler{}
|
||||||
|
|
||||||
|
httpSrv := httptest.NewServer(httpAckHandler)
|
||||||
|
httpsSrv := httptest.NewTLSServer(httpsAckHandler)
|
||||||
|
h2cSrv := newH2cServer(t, h2cAckHandler)
|
||||||
|
unixPath := newUnixServer(t, unixAckHandler)
|
||||||
|
|
||||||
|
cfg.Domains[0].Target = httpSrv.URL
|
||||||
|
cfg.Domains[1].Target = httpsSrv.URL
|
||||||
|
cfg.Domains[2].Target = strings.ReplaceAll(h2cSrv.URL, "http:", "h2c:")
|
||||||
|
cfg.Domains[3].Target = unixPath
|
||||||
|
|
||||||
|
// enc := json.NewEncoder(os.Stderr)
|
||||||
|
// enc.SetIndent("", " ")
|
||||||
|
// enc.Encode(cfg)
|
||||||
|
|
||||||
|
rtr := newRouter(t, cfg)
|
||||||
|
|
||||||
|
cli := &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("plain http", func(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", ":0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ln.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
go rtr.HandleHTTP(t.Context(), ln)
|
||||||
|
|
||||||
|
serverURL := "http://" + ln.Addr().String()
|
||||||
|
t.Log(serverURL)
|
||||||
|
|
||||||
|
for _, d := range cfg.Domains {
|
||||||
|
t.Run(d.Name, func(t *testing.T) {
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, serverURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Host = d.Name
|
||||||
|
|
||||||
|
resp, err := cli.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("wrong status code %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
0
cmd/osiris/internal/entrypoint/testdata/bad/empty.hcl
vendored
Normal file
0
cmd/osiris/internal/entrypoint/testdata/bad/empty.hcl
vendored
Normal file
15
cmd/osiris/internal/entrypoint/testdata/bad/invalid.hcl
vendored
Normal file
15
cmd/osiris/internal/entrypoint/testdata/bad/invalid.hcl
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
bind {
|
||||||
|
http = ":65530"
|
||||||
|
https = ":65531"
|
||||||
|
metrics = ":65532"
|
||||||
|
}
|
||||||
|
|
||||||
|
domain "osiris.local.cetacean.club" {
|
||||||
|
tls {
|
||||||
|
cert = "./testdata/invalid.crt"
|
||||||
|
key = "./testdata/invalid.key"
|
||||||
|
}
|
||||||
|
|
||||||
|
target = "http://localhost:3000"
|
||||||
|
health_target = "http://localhost:9091/healthz"
|
||||||
|
}
|
||||||
46
cmd/osiris/internal/entrypoint/testdata/good/all_protocols.hcl
vendored
Normal file
46
cmd/osiris/internal/entrypoint/testdata/good/all_protocols.hcl
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
bind {
|
||||||
|
http = ":65520"
|
||||||
|
https = ":65521"
|
||||||
|
metrics = ":65522"
|
||||||
|
}
|
||||||
|
|
||||||
|
domain "http.internal" {
|
||||||
|
tls {
|
||||||
|
cert = "./testdata/selfsigned.crt"
|
||||||
|
key = "./testdata/selfsigned.key"
|
||||||
|
}
|
||||||
|
|
||||||
|
target = "http://localhost:65510" # XXX(Xe) this is overwritten
|
||||||
|
health_target = "http://localhost:9091/healthz"
|
||||||
|
}
|
||||||
|
|
||||||
|
domain "https.internal" {
|
||||||
|
tls {
|
||||||
|
cert = "./testdata/selfsigned.crt"
|
||||||
|
key = "./testdata/selfsigned.key"
|
||||||
|
}
|
||||||
|
|
||||||
|
target = "https://localhost:65511" # XXX(Xe) this is overwritten
|
||||||
|
insecure_skip_verify = true
|
||||||
|
health_target = "http://localhost:9091/healthz"
|
||||||
|
}
|
||||||
|
|
||||||
|
domain "h2c.internal" {
|
||||||
|
tls {
|
||||||
|
cert = "./testdata/selfsigned.crt"
|
||||||
|
key = "./testdata/selfsigned.key"
|
||||||
|
}
|
||||||
|
|
||||||
|
target = "h2c://localhost:65511" # XXX(Xe) this is overwritten
|
||||||
|
health_target = "http://localhost:9091/healthz"
|
||||||
|
}
|
||||||
|
|
||||||
|
domain "unix.internal" {
|
||||||
|
tls {
|
||||||
|
cert = "./testdata/selfsigned.crt"
|
||||||
|
key = "./testdata/selfsigned.key"
|
||||||
|
}
|
||||||
|
|
||||||
|
target = "http://localhost:65511" # XXX(Xe) this is overwritten
|
||||||
|
health_target = "http://localhost:9091/healthz"
|
||||||
|
}
|
||||||
15
cmd/osiris/internal/entrypoint/testdata/good/selfsigned.hcl
vendored
Normal file
15
cmd/osiris/internal/entrypoint/testdata/good/selfsigned.hcl
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
bind {
|
||||||
|
http = ":65530"
|
||||||
|
https = ":65531"
|
||||||
|
metrics = ":65532"
|
||||||
|
}
|
||||||
|
|
||||||
|
domain "osiris.local.cetacean.club" {
|
||||||
|
tls {
|
||||||
|
cert = "./testdata/selfsigned.crt"
|
||||||
|
key = "./testdata/selfsigned.key"
|
||||||
|
}
|
||||||
|
|
||||||
|
target = "http://localhost:3000"
|
||||||
|
health_target = "http://localhost:9091/healthz"
|
||||||
|
}
|
||||||
11
cmd/osiris/internal/entrypoint/testdata/selfsigned.crt
vendored
Normal file
11
cmd/osiris/internal/entrypoint/testdata/selfsigned.crt
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBnzCCAVGgAwIBAgIUOLTjSYOjFk00IemtFTC4oEZs988wBQYDK2VwMEUxCzAJ
|
||||||
|
BgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5l
|
||||||
|
dCBXaWRnaXRzIFB0eSBMdGQwHhcNMjUwNzE4MjEyNDIzWhcNMjUwODE3MjEyNDIz
|
||||||
|
WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwY
|
||||||
|
SW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMCowBQYDK2VwAyEAPHphABS15+4VV6R1
|
||||||
|
vYzBQYIycQmOmlbA8QcfwzuB2VajUzBRMB0GA1UdDgQWBBT2s+MQ4AR6cbK4V0+d
|
||||||
|
XZnok1orhDAfBgNVHSMEGDAWgBT2s+MQ4AR6cbK4V0+dXZnok1orhDAPBgNVHRMB
|
||||||
|
Af8EBTADAQH/MAUGAytlcANBAOdoJbRMnHmkEETzVtXP+jkAI9yQNRXujnglApGP
|
||||||
|
8I5pvIYVgYCgoQrnb4haVWFldHM1T9H698n19e/egfFb+w4=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
3
cmd/osiris/internal/entrypoint/testdata/selfsigned.key
vendored
Normal file
3
cmd/osiris/internal/entrypoint/testdata/selfsigned.key
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MC4CAQAwBQYDK2VwBCIEIBop42tiZ0yzhaKo9NAc0PlAyBsE8NAE0i9Z7s2lgZuR
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
43
cmd/osiris/main.go
Normal file
43
cmd/osiris/main.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/TecharoHQ/anubis"
|
||||||
|
"github.com/TecharoHQ/anubis/cmd/osiris/internal/entrypoint"
|
||||||
|
"github.com/TecharoHQ/anubis/internal"
|
||||||
|
"github.com/facebookgo/flagenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
configFname = flag.String("config", "./osiris.hcl", "Configuration file (HCL), see docs")
|
||||||
|
slogLevel = flag.String("slog-level", "INFO", "logging level (see https://pkg.go.dev/log/slog#hdr-Levels)")
|
||||||
|
versionFlag = flag.Bool("version", false, "if true, show version information then quit")
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flagenv.Parse()
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *versionFlag {
|
||||||
|
fmt.Println("Osiris", anubis.Version)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
internal.InitSlog(*slogLevel)
|
||||||
|
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := entrypoint.Main(ctx, entrypoint.Options{
|
||||||
|
ConfigFname: *configFname,
|
||||||
|
}); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
15
cmd/osiris/osiris.hcl
Normal file
15
cmd/osiris/osiris.hcl
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
bind {
|
||||||
|
http = ":3004"
|
||||||
|
https = ":3005"
|
||||||
|
metrics = ":9091"
|
||||||
|
}
|
||||||
|
|
||||||
|
domain "osiris.local.cetacean.club" {
|
||||||
|
tls {
|
||||||
|
cert = "./internal/config/testdata/tls/selfsigned.crt"
|
||||||
|
key = "./internal/config/testdata/tls/selfsigned.key"
|
||||||
|
}
|
||||||
|
|
||||||
|
target = "http://localhost:3000"
|
||||||
|
health_target = "http://localhost:9091/healthz"
|
||||||
|
}
|
||||||
32
docker-bake.hcl
Normal file
32
docker-bake.hcl
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
variable "ALPINE_VERSION" { default = "3.22" }
|
||||||
|
variable "GITHUB_SHA" { default = "devel" }
|
||||||
|
variable "VERSION" { default = "devel-docker" }
|
||||||
|
|
||||||
|
group "default" {
|
||||||
|
targets = [
|
||||||
|
"osiris",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
target "osiris" {
|
||||||
|
args = {
|
||||||
|
ALPINE_VERSION = "3.22"
|
||||||
|
VERSION = "${VERSION}"
|
||||||
|
}
|
||||||
|
context = "."
|
||||||
|
dockerfile = "./docker/osiris.Dockerfile"
|
||||||
|
platforms = [
|
||||||
|
"linux/amd64",
|
||||||
|
"linux/arm64",
|
||||||
|
"linux/arm/v7",
|
||||||
|
"linux/ppc64le",
|
||||||
|
"linux/riscv64",
|
||||||
|
]
|
||||||
|
pull = true
|
||||||
|
sbom = true
|
||||||
|
provenance = true
|
||||||
|
tags = [
|
||||||
|
"ghcr.io/techarohq/anubis/osiris:${VERSION}",
|
||||||
|
"ghcr.io/techarohq/anubis/osiris:main"
|
||||||
|
]
|
||||||
|
}
|
||||||
30
docker/osiris.Dockerfile
Normal file
30
docker/osiris.Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
ARG ALPINE_VERSION=edge
|
||||||
|
FROM --platform=${BUILDPLATFORM} alpine:${ALPINE_VERSION} AS build
|
||||||
|
|
||||||
|
RUN apk -U add go nodejs git build-base git npm bash zstd brotli gzip
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN --mount=type=cache,target=/root/.cache --mount=type=cache,target=/root/go go mod download
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
ARG VERSION=devel-docker
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN --mount=type=cache,target=/root/.cache --mount=type=cache,target=/root/go GOOS=${TARGETOS} GOARCH=${TARGETARCH} CGO_ENABLED=0 GOARM=7 go build -gcflags "all=-N -l" -o /app/bin/osiris -ldflags "-s -w -extldflags -static -X github.com/TecharoHQ/anubis.Version=${VERSION}" ./cmd/osiris
|
||||||
|
|
||||||
|
FROM alpine:${ALPINE_VERSION} AS run
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk -U add ca-certificates mailcap
|
||||||
|
|
||||||
|
COPY --from=build /app/bin/osiris /app/bin/osiris
|
||||||
|
|
||||||
|
CMD ["/app/bin/osiris"]
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/TecharoHQ/anubis"
|
||||||
6
go.mod
6
go.mod
@@ -47,8 +47,10 @@ require (
|
|||||||
github.com/Songmu/gitconfig v0.2.0 // indirect
|
github.com/Songmu/gitconfig v0.2.0 // indirect
|
||||||
github.com/TecharoHQ/yeet v0.6.0 // indirect
|
github.com/TecharoHQ/yeet v0.6.0 // indirect
|
||||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
|
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
|
||||||
|
github.com/agext/levenshtein v1.2.1 // indirect
|
||||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||||
|
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect
|
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect
|
||||||
github.com/cavaliergopher/cpio v1.0.1 // indirect
|
github.com/cavaliergopher/cpio v1.0.1 // indirect
|
||||||
@@ -93,6 +95,7 @@ require (
|
|||||||
github.com/goccy/go-yaml v1.12.0 // indirect
|
github.com/goccy/go-yaml v1.12.0 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/google/go-github/v70 v70.0.0 // indirect
|
github.com/google/go-github/v70 v70.0.0 // indirect
|
||||||
github.com/google/go-querystring v1.1.0 // indirect
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||||
@@ -101,6 +104,7 @@ require (
|
|||||||
github.com/goreleaser/fileglob v1.3.0 // indirect
|
github.com/goreleaser/fileglob v1.3.0 // indirect
|
||||||
github.com/goreleaser/nfpm/v2 v2.42.1 // indirect
|
github.com/goreleaser/nfpm/v2 v2.42.1 // indirect
|
||||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||||
|
github.com/hashicorp/hcl/v2 v2.24.0 // indirect
|
||||||
github.com/huandu/xstrings v1.5.0 // indirect
|
github.com/huandu/xstrings v1.5.0 // indirect
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||||
@@ -111,6 +115,7 @@ require (
|
|||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||||
|
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/go-archive v0.1.0 // indirect
|
github.com/moby/go-archive v0.1.0 // indirect
|
||||||
@@ -150,6 +155,7 @@ require (
|
|||||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
|
github.com/zclconf/go-cty v1.16.3 // indirect
|
||||||
gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect
|
gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
||||||
|
|||||||
10
go.sum
10
go.sum
@@ -40,6 +40,8 @@ github.com/TecharoHQ/yeet v0.6.0 h1:RCBAjr7wIlllsgy0tpvWpLX7jsZgu2tiuBY3RrprcR0=
|
|||||||
github.com/TecharoHQ/yeet v0.6.0/go.mod h1:bj2V4Fg8qKQXoiuPZa3HuawrE8g+LsOQv/9q2WyGSsA=
|
github.com/TecharoHQ/yeet v0.6.0/go.mod h1:bj2V4Fg8qKQXoiuPZa3HuawrE8g+LsOQv/9q2WyGSsA=
|
||||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
|
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
|
||||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
|
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
|
||||||
|
github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=
|
||||||
|
github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
|
||||||
github.com/a-h/templ v0.3.920 h1:IQjjTu4KGrYreHo/ewzSeS8uefecisPayIIc9VflLSE=
|
github.com/a-h/templ v0.3.920 h1:IQjjTu4KGrYreHo/ewzSeS8uefecisPayIIc9VflLSE=
|
||||||
github.com/a-h/templ v0.3.920/go.mod h1:FFAu4dI//ESmEN7PQkJ7E7QfnSEMdcnu7QrAY8Dn334=
|
github.com/a-h/templ v0.3.920/go.mod h1:FFAu4dI//ESmEN7PQkJ7E7QfnSEMdcnu7QrAY8Dn334=
|
||||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||||
@@ -48,6 +50,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
|
|||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
|
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
|
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
|
||||||
|
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
|
||||||
|
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
@@ -225,6 +229,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1ns
|
|||||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
|
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
|
||||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||||
|
github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE=
|
||||||
|
github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM=
|
||||||
github.com/henvic/httpretty v0.0.6/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo=
|
github.com/henvic/httpretty v0.0.6/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo=
|
||||||
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
|
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
|
||||||
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||||
@@ -278,6 +284,8 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1
|
|||||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||||
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
|
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
|
||||||
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
|
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
|
||||||
|
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
|
||||||
|
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
@@ -403,6 +411,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
|
|||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk=
|
||||||
|
github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
|
||||||
gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8=
|
gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8=
|
||||||
gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0=
|
gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0=
|
||||||
go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
|
go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
|
||||||
|
|||||||
97
internal/fingerprint/ja3n.go
Normal file
97
internal/fingerprint/ja3n.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package fingerprint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/hex"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TLSFingerprintJA3N represents a JA3N fingerprint
|
||||||
|
type TLSFingerprintJA3N [md5.Size]byte
|
||||||
|
|
||||||
|
func (f TLSFingerprintJA3N) String() string {
|
||||||
|
return hex.EncodeToString(f[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildJA3N(hello *tls.ClientHelloInfo, sortExtensions bool) TLSFingerprintJA3N {
|
||||||
|
buf := make([]byte, 0, 256)
|
||||||
|
|
||||||
|
{
|
||||||
|
var sslVersion uint16
|
||||||
|
var hasGrease bool
|
||||||
|
for _, v := range hello.SupportedVersions {
|
||||||
|
if v&greaseMask != greaseValue {
|
||||||
|
if v > sslVersion {
|
||||||
|
sslVersion = v
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hasGrease = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maximum TLS 1.2 as specified on JA3, as TLS 1.3 is put in SupportedVersions
|
||||||
|
if slices.Contains(hello.Extensions, extensionSupportedVersions) && hasGrease && sslVersion > tls.VersionTLS12 {
|
||||||
|
sslVersion = tls.VersionTLS12
|
||||||
|
}
|
||||||
|
|
||||||
|
buf = strconv.AppendUint(buf, uint64(sslVersion), 10)
|
||||||
|
buf = append(buf, ',')
|
||||||
|
}
|
||||||
|
|
||||||
|
n := 0
|
||||||
|
for _, cipher := range hello.CipherSuites {
|
||||||
|
//if !slices.Contains(greaseValues[:], cipher) {
|
||||||
|
if cipher&greaseMask != greaseValue {
|
||||||
|
buf = strconv.AppendUint(buf, uint64(cipher), 10)
|
||||||
|
buf = append(buf, '-')
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buf = buf[:len(buf)-n]
|
||||||
|
buf = append(buf, ',')
|
||||||
|
n = 0
|
||||||
|
|
||||||
|
extensions := hello.Extensions
|
||||||
|
if sortExtensions {
|
||||||
|
extensions = slices.Clone(extensions)
|
||||||
|
slices.Sort(extensions)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, extension := range extensions {
|
||||||
|
if extension&greaseMask != greaseValue {
|
||||||
|
buf = strconv.AppendUint(buf, uint64(extension), 10)
|
||||||
|
buf = append(buf, '-')
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buf = buf[:len(buf)-n]
|
||||||
|
buf = append(buf, ',')
|
||||||
|
n = 0
|
||||||
|
|
||||||
|
for _, curve := range hello.SupportedCurves {
|
||||||
|
if curve&greaseMask != greaseValue {
|
||||||
|
buf = strconv.AppendUint(buf, uint64(curve), 10)
|
||||||
|
buf = append(buf, '-')
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buf = buf[:len(buf)-n]
|
||||||
|
buf = append(buf, ',')
|
||||||
|
n = 0
|
||||||
|
|
||||||
|
for _, point := range hello.SupportedPoints {
|
||||||
|
buf = strconv.AppendUint(buf, uint64(point), 10)
|
||||||
|
buf = append(buf, '-')
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
buf = buf[:len(buf)-n]
|
||||||
|
|
||||||
|
sum := md5.Sum(buf)
|
||||||
|
return TLSFingerprintJA3N(sum[:])
|
||||||
|
}
|
||||||
176
internal/fingerprint/ja4.go
Normal file
176
internal/fingerprint/ja4.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package fingerprint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TLSFingerprintJA4 represents a JA4 fingerprint
|
||||||
|
type TLSFingerprintJA4 struct {
|
||||||
|
A [10]byte
|
||||||
|
B [6]byte
|
||||||
|
C [6]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *TLSFingerprintJA4) String() string {
|
||||||
|
if f == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join([]string{
|
||||||
|
string(f.A[:]),
|
||||||
|
hex.EncodeToString(f.B[:]),
|
||||||
|
hex.EncodeToString(f.C[:]),
|
||||||
|
}, "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildJA4(hello *tls.ClientHelloInfo) (ja4 TLSFingerprintJA4) {
|
||||||
|
buf := make([]byte, 0, 36)
|
||||||
|
|
||||||
|
hasQuic := false
|
||||||
|
|
||||||
|
for _, ext := range hello.Extensions {
|
||||||
|
if ext == extensionQUICTransportParameters {
|
||||||
|
hasQuic = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch hasQuic {
|
||||||
|
case true:
|
||||||
|
buf = append(buf, 'q')
|
||||||
|
case false:
|
||||||
|
buf = append(buf, 't')
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
var sslVersion uint16
|
||||||
|
for _, v := range hello.SupportedVersions {
|
||||||
|
if v&greaseMask != greaseValue {
|
||||||
|
if v > sslVersion {
|
||||||
|
sslVersion = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sslVersion {
|
||||||
|
case tls.VersionTLS10:
|
||||||
|
buf = append(buf, '1', '0')
|
||||||
|
case tls.VersionTLS11:
|
||||||
|
buf = append(buf, '1', '1')
|
||||||
|
case tls.VersionTLS12:
|
||||||
|
buf = append(buf, '1', '2')
|
||||||
|
case tls.VersionTLS13:
|
||||||
|
buf = append(buf, '1', '3')
|
||||||
|
default:
|
||||||
|
sslVersion -= 0x0201
|
||||||
|
buf = strconv.AppendUint(buf, uint64(sslVersion>>8), 10)
|
||||||
|
buf = strconv.AppendUint(buf, uint64(sslVersion&0xff), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(hello.Extensions, extensionServerName) && hello.ServerName != "" {
|
||||||
|
buf = append(buf, 'd')
|
||||||
|
} else {
|
||||||
|
buf = append(buf, 'i')
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphers := make([]uint16, 0, len(hello.CipherSuites))
|
||||||
|
for _, cipher := range hello.CipherSuites {
|
||||||
|
if cipher&greaseMask != greaseValue {
|
||||||
|
ciphers = append(ciphers, cipher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extensionCount := 0
|
||||||
|
extensions := make([]uint16, 0, len(hello.Extensions))
|
||||||
|
for _, extension := range hello.Extensions {
|
||||||
|
if extension&greaseMask != greaseValue {
|
||||||
|
extensionCount++
|
||||||
|
if extension != extensionALPN && extension != extensionServerName {
|
||||||
|
extensions = append(extensions, extension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
schemes := make([]tls.SignatureScheme, 0, len(hello.SignatureSchemes))
|
||||||
|
|
||||||
|
for _, scheme := range hello.SignatureSchemes {
|
||||||
|
if scheme&greaseMask != greaseValue {
|
||||||
|
schemes = append(schemes, scheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: maybe little endian
|
||||||
|
slices.Sort(ciphers)
|
||||||
|
slices.Sort(extensions)
|
||||||
|
//slices.Sort(schemes)
|
||||||
|
|
||||||
|
if len(ciphers) < 10 {
|
||||||
|
buf = append(buf, '0')
|
||||||
|
buf = strconv.AppendUint(buf, uint64(len(ciphers)), 10)
|
||||||
|
} else if len(ciphers) > 99 {
|
||||||
|
buf = append(buf, '9', '9')
|
||||||
|
} else {
|
||||||
|
buf = strconv.AppendUint(buf, uint64(len(ciphers)), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
if extensionCount < 10 {
|
||||||
|
buf = append(buf, '0')
|
||||||
|
buf = strconv.AppendUint(buf, uint64(extensionCount), 10)
|
||||||
|
} else if extensionCount > 99 {
|
||||||
|
buf = append(buf, '9', '9')
|
||||||
|
} else {
|
||||||
|
buf = strconv.AppendUint(buf, uint64(extensionCount), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(hello.SupportedProtos) > 0 && len(hello.SupportedProtos[0]) > 1 {
|
||||||
|
buf = append(buf, hello.SupportedProtos[0][0], hello.SupportedProtos[0][len(hello.SupportedProtos[0])-1])
|
||||||
|
} else {
|
||||||
|
buf = append(buf, '0', '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(ja4.A[:], buf)
|
||||||
|
|
||||||
|
ja4.B = ja4SHA256(uint16SliceToHex(ciphers))
|
||||||
|
|
||||||
|
extBuf := uint16SliceToHex(extensions)
|
||||||
|
|
||||||
|
if len(schemes) > 0 {
|
||||||
|
extBuf = append(extBuf, '_')
|
||||||
|
extBuf = append(extBuf, uint16SliceToHex(schemes)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
ja4.C = ja4SHA256(extBuf)
|
||||||
|
|
||||||
|
return ja4
|
||||||
|
}
|
||||||
|
|
||||||
|
func uint16SliceToHex[T ~uint16](in []T) (out []byte) {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out = slices.Grow(out, hex.EncodedLen(len(in)*2)+len(in))
|
||||||
|
|
||||||
|
for _, n := range in {
|
||||||
|
out = append(out, fmt.Sprintf("%04x", uint16(n))...)
|
||||||
|
out = append(out, ',')
|
||||||
|
}
|
||||||
|
out = out[:len(out)-1]
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func ja4SHA256(buf []byte) [6]byte {
|
||||||
|
if len(buf) == 0 {
|
||||||
|
return [6]byte{0, 0, 0, 0, 0, 0}
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(buf)
|
||||||
|
|
||||||
|
return [6]byte(sum[:6])
|
||||||
|
}
|
||||||
46
internal/fingerprint/tcp.go
Normal file
46
internal/fingerprint/tcp.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package fingerprint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JA4T represents a TCP fingerprint
|
||||||
|
type JA4T struct {
|
||||||
|
Window uint32
|
||||||
|
Options []uint8
|
||||||
|
MSS uint16
|
||||||
|
WindowScale uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j JA4T) String() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// Start with the window size
|
||||||
|
fmt.Fprintf(&sb, "%d", j.Window)
|
||||||
|
|
||||||
|
// Append each option
|
||||||
|
for i, opt := range j.Options {
|
||||||
|
if i == 0 {
|
||||||
|
fmt.Fprint(&sb, "_")
|
||||||
|
} else {
|
||||||
|
fmt.Fprint(&sb, "-")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "%d", opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append MSS and WindowScale
|
||||||
|
fmt.Fprintf(&sb, "_%d_%d", j.MSS, j.WindowScale)
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTCPFingerprint extracts TCP fingerprint from HTTP request context
|
||||||
|
func GetTCPFingerprint(r *http.Request) *JA4T {
|
||||||
|
ptr := r.Context().Value(tcpFingerprintKey{})
|
||||||
|
if fpPtr, ok := ptr.(*JA4T); ok && ptr != nil && fpPtr != nil {
|
||||||
|
return fpPtr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
106
internal/fingerprint/tcp_freebsd.go
Normal file
106
internal/fingerprint/tcp_freebsd.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
//go:build freebsd
|
||||||
|
|
||||||
|
package fingerprint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tcpInfo struct {
|
||||||
|
State uint8
|
||||||
|
Options uint8
|
||||||
|
SndScale uint8
|
||||||
|
RcvScale uint8
|
||||||
|
__pad [4]byte
|
||||||
|
Rto uint32
|
||||||
|
Ato uint32
|
||||||
|
SndMss uint32
|
||||||
|
RcvMss uint32
|
||||||
|
Unacked uint32
|
||||||
|
Sacked uint32
|
||||||
|
Lost uint32
|
||||||
|
Retrans uint32
|
||||||
|
Fackets uint32
|
||||||
|
Last_data_sent uint32
|
||||||
|
Last_ack_sent uint32
|
||||||
|
Last_data_recv uint32
|
||||||
|
Last_ack_recv uint32
|
||||||
|
Pmtu uint32
|
||||||
|
Rcv_ssthresh uint32
|
||||||
|
RTT uint32
|
||||||
|
RTTvar uint32
|
||||||
|
Snd_ssthresh uint32
|
||||||
|
Snd_cwnd uint32
|
||||||
|
Advmss uint32
|
||||||
|
Reordering uint32
|
||||||
|
Rcv_rtt uint32
|
||||||
|
Rcv_space uint32
|
||||||
|
Total_retrans uint32
|
||||||
|
Snd_wnd uint32
|
||||||
|
// Truncated for brevity — add more fields if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignTCPFingerprint extracts TCP fingerprint information from a connection
|
||||||
|
func AssignTCPFingerprint(conn net.Conn) (*JA4T, error) {
|
||||||
|
tcpConn, ok := conn.(*net.TCPConn)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("not a TCPConn")
|
||||||
|
}
|
||||||
|
|
||||||
|
rawConn, err := tcpConn.SyscallConn()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SyscallConn failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var info tcpInfo
|
||||||
|
var sysErr error
|
||||||
|
|
||||||
|
err = rawConn.Control(func(fd uintptr) {
|
||||||
|
size := uint32(unsafe.Sizeof(info))
|
||||||
|
_, _, errno := syscall.Syscall6(
|
||||||
|
syscall.SYS_GETSOCKOPT,
|
||||||
|
fd,
|
||||||
|
uintptr(syscall.IPPROTO_TCP),
|
||||||
|
uintptr(syscall.TCP_INFO),
|
||||||
|
uintptr(unsafe.Pointer(&info)),
|
||||||
|
uintptr(unsafe.Pointer(&size)),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if errno != 0 {
|
||||||
|
sysErr = errno
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SyscallConn.Control: %w", err)
|
||||||
|
}
|
||||||
|
if sysErr != nil {
|
||||||
|
return nil, fmt.Errorf("getsockopt TCP_INFO: %w", sysErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
fp := &JA4T{
|
||||||
|
Window: info.Snd_wnd,
|
||||||
|
MSS: uint16(info.SndMss),
|
||||||
|
WindowScale: info.SndScale,
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
TCPI_OPT_TIMESTAMPS = 1 << 0
|
||||||
|
TCPI_OPT_SACK = 1 << 1
|
||||||
|
TCPI_OPT_WSCALE = 1 << 2
|
||||||
|
)
|
||||||
|
|
||||||
|
if info.Options&TCPI_OPT_SACK != 0 {
|
||||||
|
fp.Options = append(fp.Options, 4, 1)
|
||||||
|
}
|
||||||
|
if info.Options&TCPI_OPT_TIMESTAMPS != 0 {
|
||||||
|
fp.Options = append(fp.Options, 8, 1)
|
||||||
|
}
|
||||||
|
if info.Options&TCPI_OPT_WSCALE != 0 {
|
||||||
|
fp.Options = append(fp.Options, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fp, nil
|
||||||
|
}
|
||||||
132
internal/fingerprint/tcp_linux.go
Normal file
132
internal/fingerprint/tcp_linux.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package fingerprint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tcpInfo struct {
|
||||||
|
State uint8
|
||||||
|
Ca_state uint8
|
||||||
|
Retransmits uint8
|
||||||
|
Probes uint8
|
||||||
|
Backoff uint8
|
||||||
|
Options uint8
|
||||||
|
Wnd_scale uint8
|
||||||
|
Delivery_rate_app_limited uint8
|
||||||
|
|
||||||
|
Rto uint32
|
||||||
|
Ato uint32
|
||||||
|
SndMss uint32
|
||||||
|
RcvMss uint32
|
||||||
|
|
||||||
|
Unacked uint32
|
||||||
|
Sacked uint32
|
||||||
|
Lost uint32
|
||||||
|
Retrans uint32
|
||||||
|
Fackets uint32
|
||||||
|
|
||||||
|
Last_data_sent uint32
|
||||||
|
Last_ack_sent uint32
|
||||||
|
Last_data_recv uint32
|
||||||
|
Last_ack_recv uint32
|
||||||
|
PMTU uint32
|
||||||
|
Rcv_ssthresh uint32
|
||||||
|
RTT uint32
|
||||||
|
RTTvar uint32
|
||||||
|
Snd_ssthresh uint32
|
||||||
|
Snd_cwnd uint32
|
||||||
|
Advmss uint32
|
||||||
|
Reordering uint32
|
||||||
|
Rcv_rtt uint32
|
||||||
|
Rcv_space uint32
|
||||||
|
Total_retrans uint32
|
||||||
|
Pacing_rate uint64
|
||||||
|
Max_pacing_rate uint64
|
||||||
|
Bytes_acked uint64
|
||||||
|
Bytes_received uint64
|
||||||
|
Segs_out uint32
|
||||||
|
Segs_in uint32
|
||||||
|
Notsent_bytes uint32
|
||||||
|
Min_rtt uint32
|
||||||
|
Data_segs_in uint32
|
||||||
|
Data_segs_out uint32
|
||||||
|
Delivery_rate uint64
|
||||||
|
Busy_time uint64
|
||||||
|
Rwnd_limited uint64
|
||||||
|
Sndbuf_limited uint64
|
||||||
|
Delivered uint32
|
||||||
|
Delivered_ce uint32
|
||||||
|
Bytes_sent uint64
|
||||||
|
Bytes_retrans uint64
|
||||||
|
DSACK_dups uint32
|
||||||
|
Reord_seen uint32
|
||||||
|
Rcv_ooopack uint32
|
||||||
|
Snd_wnd uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignTCPFingerprint extracts TCP fingerprint information from a connection
|
||||||
|
func AssignTCPFingerprint(conn net.Conn) (*JA4T, error) {
|
||||||
|
tcpConn, ok := conn.(*net.TCPConn)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("not a TCPConn")
|
||||||
|
}
|
||||||
|
|
||||||
|
rawConn, err := tcpConn.SyscallConn()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SyscallConn failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var info tcpInfo
|
||||||
|
var sysErr error
|
||||||
|
|
||||||
|
err = rawConn.Control(func(fd uintptr) {
|
||||||
|
size := uint32(unsafe.Sizeof(info))
|
||||||
|
_, _, errno := syscall.Syscall6(
|
||||||
|
syscall.SYS_GETSOCKOPT,
|
||||||
|
fd,
|
||||||
|
uintptr(syscall.IPPROTO_TCP),
|
||||||
|
uintptr(syscall.TCP_INFO),
|
||||||
|
uintptr(unsafe.Pointer(&info)),
|
||||||
|
uintptr(unsafe.Pointer(&size)),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if errno != 0 {
|
||||||
|
sysErr = errno
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SyscallConn.Control: %w", err)
|
||||||
|
}
|
||||||
|
if sysErr != nil {
|
||||||
|
return nil, fmt.Errorf("getsockopt TCP_INFO: %w", sysErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
fp := &JA4T{
|
||||||
|
Window: info.Snd_wnd,
|
||||||
|
MSS: uint16(info.SndMss),
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
TCPI_OPT_TIMESTAMPS = 1 << 0
|
||||||
|
TCPI_OPT_SACK = 1 << 1
|
||||||
|
TCPI_OPT_WSCALE = 1 << 2
|
||||||
|
)
|
||||||
|
|
||||||
|
if info.Options&TCPI_OPT_SACK != 0 {
|
||||||
|
fp.Options = append(fp.Options, 4, 1)
|
||||||
|
}
|
||||||
|
if info.Options&TCPI_OPT_TIMESTAMPS != 0 {
|
||||||
|
fp.Options = append(fp.Options, 8, 1)
|
||||||
|
}
|
||||||
|
if info.Options&TCPI_OPT_WSCALE != 0 {
|
||||||
|
fp.Options = append(fp.Options, 3)
|
||||||
|
fp.WindowScale = info.Wnd_scale
|
||||||
|
}
|
||||||
|
|
||||||
|
return fp, nil
|
||||||
|
}
|
||||||
11
internal/fingerprint/tcp_unsupported.go
Normal file
11
internal/fingerprint/tcp_unsupported.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build !linux && !freebsd
|
||||||
|
|
||||||
|
package fingerprint
|
||||||
|
|
||||||
|
import "net"
|
||||||
|
|
||||||
|
// AssignTCPFingerprint is not supported on this platform
|
||||||
|
func AssignTCPFingerprint(conn net.Conn) (*JA4T, error) {
|
||||||
|
// Not supported on macOS and other platforms
|
||||||
|
return &JA4T{}, nil
|
||||||
|
}
|
||||||
110
internal/fingerprint/tls.go
Normal file
110
internal/fingerprint/tls.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package fingerprint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApplyTLSFingerprinter configures a TLS server to capture TLS fingerprints
|
||||||
|
func ApplyTLSFingerprinter(server *http.Server) {
|
||||||
|
if server.TLSConfig == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
server.TLSConfig = server.TLSConfig.Clone()
|
||||||
|
|
||||||
|
getConfigForClient := server.TLSConfig.GetConfigForClient
|
||||||
|
|
||||||
|
if getConfigForClient == nil {
|
||||||
|
getConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.TLSConfig.GetConfigForClient = func(clientHello *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||||
|
ja3n, ja4 := buildTLSFingerprint(clientHello)
|
||||||
|
ptr := clientHello.Context().Value(tlsFingerprintKey{})
|
||||||
|
if fpPtr, ok := ptr.(*TLSFingerprint); ok && ptr != nil && fpPtr != nil {
|
||||||
|
fpPtr.ja3n.Store(&ja3n)
|
||||||
|
fpPtr.ja4.Store(&ja4)
|
||||||
|
}
|
||||||
|
return getConfigForClient(clientHello)
|
||||||
|
}
|
||||||
|
server.ConnContext = func(ctx context.Context, c net.Conn) context.Context {
|
||||||
|
ctx = context.WithValue(ctx, tlsFingerprintKey{}, &TLSFingerprint{})
|
||||||
|
|
||||||
|
if tc, ok := c.(*tls.Conn); ok {
|
||||||
|
tcpFP, err := AssignTCPFingerprint(tc.NetConn())
|
||||||
|
if err == nil {
|
||||||
|
ctx = context.WithValue(ctx, tcpFingerprintKey{}, tcpFP)
|
||||||
|
} else {
|
||||||
|
slog.Debug("ja4t error", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tcpFingerprintKey struct{}
|
||||||
|
type tlsFingerprintKey struct{}
|
||||||
|
|
||||||
|
// TLSFingerprint represents TLS fingerprint data
|
||||||
|
type TLSFingerprint struct {
|
||||||
|
ja3n atomic.Pointer[TLSFingerprintJA3N]
|
||||||
|
ja4 atomic.Pointer[TLSFingerprintJA4]
|
||||||
|
}
|
||||||
|
|
||||||
|
// JA3N returns the JA3N fingerprint
|
||||||
|
func (f *TLSFingerprint) JA3N() *TLSFingerprintJA3N {
|
||||||
|
return f.ja3n.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// JA4 returns the JA4 fingerprint
|
||||||
|
func (f *TLSFingerprint) JA4() *TLSFingerprintJA4 {
|
||||||
|
return f.ja4.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const greaseMask = 0x0F0F
|
||||||
|
const greaseValue = 0x0a0a
|
||||||
|
|
||||||
|
// TLS extension numbers
|
||||||
|
const (
|
||||||
|
extensionServerName uint16 = 0
|
||||||
|
extensionStatusRequest uint16 = 5
|
||||||
|
extensionSupportedCurves uint16 = 10 // supported_groups in TLS 1.3, see RFC 8446, Section 4.2.7
|
||||||
|
extensionSupportedPoints uint16 = 11
|
||||||
|
extensionSignatureAlgorithms uint16 = 13
|
||||||
|
extensionALPN uint16 = 16
|
||||||
|
extensionSCT uint16 = 18
|
||||||
|
extensionExtendedMasterSecret uint16 = 23
|
||||||
|
extensionSessionTicket uint16 = 35
|
||||||
|
extensionPreSharedKey uint16 = 41
|
||||||
|
extensionEarlyData uint16 = 42
|
||||||
|
extensionSupportedVersions uint16 = 43
|
||||||
|
extensionCookie uint16 = 44
|
||||||
|
extensionPSKModes uint16 = 45
|
||||||
|
extensionCertificateAuthorities uint16 = 47
|
||||||
|
extensionSignatureAlgorithmsCert uint16 = 50
|
||||||
|
extensionKeyShare uint16 = 51
|
||||||
|
extensionQUICTransportParameters uint16 = 57
|
||||||
|
extensionRenegotiationInfo uint16 = 0xff01
|
||||||
|
extensionECHOuterExtensions uint16 = 0xfd00
|
||||||
|
extensionEncryptedClientHello uint16 = 0xfe0d
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildTLSFingerprint(hello *tls.ClientHelloInfo) (TLSFingerprintJA3N, TLSFingerprintJA4) {
|
||||||
|
return TLSFingerprintJA3N(buildJA3N(hello, true)), buildJA4(hello)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTLSFingerprint extracts TLS fingerprint from HTTP request context
|
||||||
|
func GetTLSFingerprint(r *http.Request) *TLSFingerprint {
|
||||||
|
ptr := r.Context().Value(tlsFingerprintKey{})
|
||||||
|
if fpPtr, ok := ptr.(*TLSFingerprint); ok && ptr != nil && fpPtr != nil {
|
||||||
|
return fpPtr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user