Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
b3850f2988 build(deps): bump the gomod group with 6 updates
---
updated-dependencies:
- dependency-name: github.com/a-h/templ
  dependency-version: 0.3.920
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: gomod
- dependency-name: github.com/gaissmai/bart
  dependency-version: 0.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: github.com/golang-jwt/jwt/v5
  dependency-version: 5.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: gomod
- dependency-name: github.com/google/cel-go
  dependency-version: 0.26.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: github.com/testcontainers/testcontainers-go
  dependency-version: 0.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: k8s.io/apimachinery
  dependency-version: 0.33.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: gomod
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-21 00:51:45 +00:00
47 changed files with 216 additions and 2627 deletions

View File

@@ -21,9 +21,7 @@
"golang.go",
"unifiedjs.vscode-mdx",
"a-h.templ",
"redhat.vscode-yaml",
"hashicorp.hcl",
"fredwangwang.vscode-hcl-format"
"redhat.vscode-yaml"
]
}
}

View File

@@ -1,25 +0,0 @@
.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

View File

@@ -2,7 +2,7 @@ name: Docker image builds (pull requests)
on:
pull_request:
branches: ["main"]
branches: [ "main" ]
env:
DOCKER_METADATA_SET_OUTPUT_ENV: "true"
@@ -11,32 +11,7 @@ permissions:
contents: read
jobs:
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:
build:
runs-on: ubuntu-24.04
steps:
- name: Checkout code

View File

@@ -17,38 +17,7 @@ permissions:
pull-requests: write
jobs:
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:
build:
runs-on: ubuntu-24.04
steps:
- name: Checkout code

View File

@@ -53,14 +53,14 @@ jobs:
push: true
- name: Apply k8s manifests to limsa lominsa
uses: actions-hub/kubectl@b5b19eeb6a0ffde16637e398f8b96ef01eb8fdb7 # v1.33.3
uses: actions-hub/kubectl@d50394b7d704525f93faefce1e65a6329ff67271 # v1.33.2
env:
KUBE_CONFIG: ${{ secrets.LIMSA_LOMINSA_KUBECONFIG }}
with:
args: apply -k docs/manifest
- name: Apply k8s manifests to limsa lominsa
uses: actions-hub/kubectl@b5b19eeb6a0ffde16637e398f8b96ef01eb8fdb7 # v1.33.3
uses: actions-hub/kubectl@d50394b7d704525f93faefce1e65a6329ff67271 # v1.33.2
env:
KUBE_CONFIG: ${{ secrets.LIMSA_LOMINSA_KUBECONFIG }}
with:

View File

@@ -21,7 +21,7 @@ jobs:
persist-credentials: false
- name: Install the latest version of uv
uses: astral-sh/setup-uv@7edac99f961f18b581bbd960d59d049f04c0002f # v6.4.1
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1
- name: Run zizmor 🌈
run: uvx zizmor --format sarif . > results.sarif

View File

@@ -5,8 +5,6 @@
"golang.go",
"unifiedjs.vscode-mdx",
"a-h.templ",
"redhat.vscode-yaml",
"hashicorp.hcl",
"fredwangwang.vscode-hcl-format"
"redhat.vscode-yaml"
]
}

View File

@@ -1,39 +0,0 @@
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
}

View File

@@ -1,55 +0,0 @@
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")
}
})
}
}

View File

@@ -1,31 +0,0 @@
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
}

View File

@@ -1,66 +0,0 @@
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
}

View File

@@ -1,89 +0,0 @@
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)
}
})
}
}

View File

@@ -1 +0,0 @@
aorsentaeiorsntoiearnstoieanrsoietnaioresntoeiar

View File

@@ -1 +0,0 @@
aorsentaeiorsntoiearnstoieanrsoietnaioresntoeiar

View File

@@ -1,11 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIBnzCCAVGgAwIBAgIUAw8funCpiB3ZAAPoWdSCWnzbsFIwBQYDK2VwMEUxCzAJ
BgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5l
dCBXaWRnaXRzIFB0eSBMdGQwHhcNMjUwNzE4MTkwMjM1WhcNMjUwODE3MTkwMjM1
WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwY
SW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMCowBQYDK2VwAyEAcXDHXV3vgpvjtTaz
s0Oj/73rMr06bhyGGhleYS1MNoWjUzBRMB0GA1UdDgQWBBQwmfKPthucFHB6Wfgz
2Nj5nkMQOjAfBgNVHSMEGDAWgBQwmfKPthucFHB6Wfgz2Nj5nkMQOjAPBgNVHRMB
Af8EBTADAQH/MAUGAytlcANBALBYbULlGwB7Ro0UTgUoQDNxEvayn3qzVFHIt7lC
/2/NzNBkk4yPT+a4mbRuydxLkv+JIvmQbarZxpksYnWlCAM=
-----END CERTIFICATE-----

View File

@@ -1,3 +0,0 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIOHKoX22Mha6SnnpLm34fSSfTUDbRiDCi6N1nOgTOlds
-----END PRIVATE KEY-----

View File

@@ -1,40 +0,0 @@
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
}

View File

@@ -1,48 +0,0 @@
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)
}
})
}
}

View File

@@ -1,85 +0,0 @@
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()
}

View File

@@ -1,93 +0,0 @@
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)
}
})
}
}

View File

@@ -1,35 +0,0 @@
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,
}
}

View File

@@ -1,51 +0,0 @@
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")
}
}

View File

@@ -1,72 +0,0 @@
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
}
}

View File

@@ -1,66 +0,0 @@
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)
}
}

View File

@@ -1,320 +0,0 @@
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())
}

View File

@@ -1,319 +0,0 @@
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)
}
})
}
})
}

View File

@@ -1,15 +0,0 @@
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"
}

View File

@@ -1,46 +0,0 @@
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"
}

View File

@@ -1,15 +0,0 @@
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"
}

View File

@@ -1,11 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIBnzCCAVGgAwIBAgIUOLTjSYOjFk00IemtFTC4oEZs988wBQYDK2VwMEUxCzAJ
BgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5l
dCBXaWRnaXRzIFB0eSBMdGQwHhcNMjUwNzE4MjEyNDIzWhcNMjUwODE3MjEyNDIz
WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwY
SW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMCowBQYDK2VwAyEAPHphABS15+4VV6R1
vYzBQYIycQmOmlbA8QcfwzuB2VajUzBRMB0GA1UdDgQWBBT2s+MQ4AR6cbK4V0+d
XZnok1orhDAfBgNVHSMEGDAWgBT2s+MQ4AR6cbK4V0+dXZnok1orhDAPBgNVHRMB
Af8EBTADAQH/MAUGAytlcANBAOdoJbRMnHmkEETzVtXP+jkAI9yQNRXujnglApGP
8I5pvIYVgYCgoQrnb4haVWFldHM1T9H698n19e/egfFb+w4=
-----END CERTIFICATE-----

View File

@@ -1,3 +0,0 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIBop42tiZ0yzhaKo9NAc0PlAyBsE8NAE0i9Z7s2lgZuR
-----END PRIVATE KEY-----

View File

@@ -1,43 +0,0 @@
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)
}
}

View File

@@ -1,15 +0,0 @@
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"
}

View File

@@ -1,32 +0,0 @@
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"
]
}

View File

@@ -1,30 +0,0 @@
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
View File

@@ -47,10 +47,8 @@ require (
github.com/Songmu/gitconfig v0.2.0 // indirect
github.com/TecharoHQ/yeet v0.6.0 // 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/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/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect
github.com/cavaliergopher/cpio v1.0.1 // indirect
@@ -95,7 +93,6 @@ require (
github.com/goccy/go-yaml v1.12.0 // indirect
github.com/gogo/protobuf v1.3.2 // 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-querystring v1.1.0 // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
@@ -104,7 +101,6 @@ require (
github.com/goreleaser/fileglob v1.3.0 // indirect
github.com/goreleaser/nfpm/v2 v2.42.1 // 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/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
@@ -115,7 +111,6 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
@@ -155,7 +150,6 @@ require (
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // 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
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect

10
go.sum
View File

@@ -40,8 +40,6 @@ github.com/TecharoHQ/yeet v0.6.0 h1:RCBAjr7wIlllsgy0tpvWpLX7jsZgu2tiuBY3RrprcR0=
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/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/go.mod h1:FFAu4dI//ESmEN7PQkJ7E7QfnSEMdcnu7QrAY8Dn334=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
@@ -50,8 +48,6 @@ 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/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/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/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -229,8 +225,6 @@ 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/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
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/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
@@ -284,8 +278,6 @@ 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/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-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/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
@@ -411,8 +403,6 @@ 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/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
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/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0=
go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=

View File

@@ -1,97 +0,0 @@
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[:])
}

View File

@@ -1,176 +0,0 @@
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])
}

View File

@@ -1,46 +0,0 @@
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
}

View File

@@ -1,106 +0,0 @@
//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
}

View File

@@ -1,132 +0,0 @@
//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
}

View File

@@ -1,11 +0,0 @@
//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
}

View File

@@ -1,110 +0,0 @@
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
}

407
package-lock.json generated
View File

@@ -9,9 +9,9 @@
"version": "1.21.0",
"license": "ISC",
"devDependencies": {
"cssnano": "^7.1.0",
"cssnano-preset-advanced": "^7.0.8",
"esbuild": "^0.25.8",
"cssnano": "^7.0.7",
"cssnano-preset-advanced": "^7.0.7",
"esbuild": "^0.25.6",
"playwright": "^1.52.0",
"postcss-cli": "^11.0.1",
"postcss-import": "^16.1.1",
@@ -20,9 +20,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz",
"integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz",
"integrity": "sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw==",
"cpu": [
"ppc64"
],
@@ -37,9 +37,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz",
"integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.6.tgz",
"integrity": "sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg==",
"cpu": [
"arm"
],
@@ -54,9 +54,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz",
"integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.6.tgz",
"integrity": "sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA==",
"cpu": [
"arm64"
],
@@ -71,9 +71,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz",
"integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.6.tgz",
"integrity": "sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A==",
"cpu": [
"x64"
],
@@ -88,9 +88,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz",
"integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.6.tgz",
"integrity": "sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA==",
"cpu": [
"arm64"
],
@@ -105,9 +105,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz",
"integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.6.tgz",
"integrity": "sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg==",
"cpu": [
"x64"
],
@@ -122,9 +122,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz",
"integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.6.tgz",
"integrity": "sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg==",
"cpu": [
"arm64"
],
@@ -139,9 +139,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz",
"integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.6.tgz",
"integrity": "sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ==",
"cpu": [
"x64"
],
@@ -156,9 +156,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz",
"integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.6.tgz",
"integrity": "sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw==",
"cpu": [
"arm"
],
@@ -173,9 +173,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz",
"integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.6.tgz",
"integrity": "sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ==",
"cpu": [
"arm64"
],
@@ -190,9 +190,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz",
"integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.6.tgz",
"integrity": "sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw==",
"cpu": [
"ia32"
],
@@ -207,9 +207,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz",
"integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.6.tgz",
"integrity": "sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg==",
"cpu": [
"loong64"
],
@@ -224,9 +224,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz",
"integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.6.tgz",
"integrity": "sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw==",
"cpu": [
"mips64el"
],
@@ -241,9 +241,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz",
"integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.6.tgz",
"integrity": "sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw==",
"cpu": [
"ppc64"
],
@@ -258,9 +258,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz",
"integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.6.tgz",
"integrity": "sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w==",
"cpu": [
"riscv64"
],
@@ -275,9 +275,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz",
"integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.6.tgz",
"integrity": "sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw==",
"cpu": [
"s390x"
],
@@ -292,9 +292,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz",
"integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.6.tgz",
"integrity": "sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig==",
"cpu": [
"x64"
],
@@ -309,9 +309,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz",
"integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.6.tgz",
"integrity": "sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q==",
"cpu": [
"arm64"
],
@@ -326,9 +326,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz",
"integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.6.tgz",
"integrity": "sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g==",
"cpu": [
"x64"
],
@@ -343,9 +343,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz",
"integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.6.tgz",
"integrity": "sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg==",
"cpu": [
"arm64"
],
@@ -360,9 +360,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz",
"integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.6.tgz",
"integrity": "sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw==",
"cpu": [
"x64"
],
@@ -377,9 +377,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz",
"integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.6.tgz",
"integrity": "sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA==",
"cpu": [
"arm64"
],
@@ -394,9 +394,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz",
"integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.6.tgz",
"integrity": "sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA==",
"cpu": [
"x64"
],
@@ -411,9 +411,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz",
"integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.6.tgz",
"integrity": "sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q==",
"cpu": [
"arm64"
],
@@ -428,9 +428,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz",
"integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.6.tgz",
"integrity": "sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ==",
"cpu": [
"ia32"
],
@@ -445,9 +445,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz",
"integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.6.tgz",
"integrity": "sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA==",
"cpu": [
"x64"
],
@@ -461,6 +461,16 @@
"node": ">=18"
}
},
"node_modules/@trysound/sax": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -591,9 +601,9 @@
}
},
"node_modules/browserslist": {
"version": "4.25.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
"integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==",
"version": "4.25.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz",
"integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==",
"dev": true,
"funding": [
{
@@ -611,8 +621,8 @@
],
"license": "MIT",
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
"caniuse-lite": "^1.0.30001718",
"electron-to-chromium": "^1.5.160",
"node-releases": "^2.0.19",
"update-browserslist-db": "^1.1.3"
},
@@ -637,9 +647,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001727",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
"version": "1.0.30001724",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001724.tgz",
"integrity": "sha512-WqJo7p0TbHDOythNTqYujmaJTvtYRZrjpP8TCvH6Vb9CYJerJNKamKzIWOM4BkQatWj9H2lYulpdAQNBe7QhNA==",
"dev": true,
"funding": [
{
@@ -725,13 +735,13 @@
"license": "MIT"
},
"node_modules/commander": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
"node": ">= 10"
}
},
"node_modules/concat-map": {
@@ -755,9 +765,9 @@
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
"integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@@ -772,13 +782,13 @@
}
},
"node_modules/css-tree": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
"integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.12.2",
"mdn-data": "2.0.30",
"source-map-js": "^1.0.1"
},
"engines": {
@@ -786,9 +796,9 @@
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
"integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
@@ -812,13 +822,13 @@
}
},
"node_modules/cssnano": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.0.tgz",
"integrity": "sha512-Pu3rlKkd0ZtlCUzBrKL1Z4YmhKppjC1H9jo7u1o4qaKqyhvixFgu5qLyNIAOjSTg9DjVPtUqdROq2EfpVMEe+w==",
"version": "7.0.7",
"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.0.7.tgz",
"integrity": "sha512-evKu7yiDIF7oS+EIpwFlMF730ijRyLFaM2o5cTxRGJR9OKHKkc+qP443ZEVR9kZG0syaAJJCPJyfv5pbrxlSng==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssnano-preset-default": "^7.0.8",
"cssnano-preset-default": "^7.0.7",
"lilconfig": "^3.1.3"
},
"engines": {
@@ -833,15 +843,15 @@
}
},
"node_modules/cssnano-preset-advanced": {
"version": "7.0.8",
"resolved": "https://registry.npmjs.org/cssnano-preset-advanced/-/cssnano-preset-advanced-7.0.8.tgz",
"integrity": "sha512-KYw7gH8xmIzTwHefuM/m3lkMz4jn5EbjxZO2RHVsOGvrXCxSjbc0/f/gELWW9ZIgbQdJMCkijEo76gBYGY4S3Q==",
"version": "7.0.7",
"resolved": "https://registry.npmjs.org/cssnano-preset-advanced/-/cssnano-preset-advanced-7.0.7.tgz",
"integrity": "sha512-uBLTct5OBy3r+WL8zB+RAUsok2E5eWdi3xvrotI0aS/E3KvEKKyaXIlAXLHUI0MSopHXJEuUX4jMTr9VkUtWkA==",
"dev": true,
"license": "MIT",
"dependencies": {
"autoprefixer": "^10.4.21",
"browserslist": "^4.25.1",
"cssnano-preset-default": "^7.0.8",
"browserslist": "^4.24.5",
"cssnano-preset-default": "^7.0.7",
"postcss-discard-unused": "^7.0.4",
"postcss-merge-idents": "^7.0.1",
"postcss-reduce-idents": "^7.0.1",
@@ -855,27 +865,27 @@
}
},
"node_modules/cssnano-preset-default": {
"version": "7.0.8",
"resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-7.0.8.tgz",
"integrity": "sha512-d+3R2qwrUV3g4LEMOjnndognKirBZISylDZAF/TPeCWVjEwlXS2e4eN4ICkoobRe7pD3H6lltinKVyS1AJhdjQ==",
"version": "7.0.7",
"resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-7.0.7.tgz",
"integrity": "sha512-jW6CG/7PNB6MufOrlovs1TvBTEVmhY45yz+bd0h6nw3h6d+1e+/TX+0fflZ+LzvZombbT5f+KC063w9VoHeHow==",
"dev": true,
"license": "MIT",
"dependencies": {
"browserslist": "^4.25.1",
"browserslist": "^4.24.5",
"css-declaration-sorter": "^7.2.0",
"cssnano-utils": "^5.0.1",
"postcss-calc": "^10.1.1",
"postcss-colormin": "^7.0.4",
"postcss-convert-values": "^7.0.6",
"postcss-colormin": "^7.0.3",
"postcss-convert-values": "^7.0.5",
"postcss-discard-comments": "^7.0.4",
"postcss-discard-duplicates": "^7.0.2",
"postcss-discard-empty": "^7.0.1",
"postcss-discard-overridden": "^7.0.1",
"postcss-merge-longhand": "^7.0.5",
"postcss-merge-rules": "^7.0.6",
"postcss-merge-rules": "^7.0.5",
"postcss-minify-font-values": "^7.0.1",
"postcss-minify-gradients": "^7.0.1",
"postcss-minify-params": "^7.0.4",
"postcss-minify-params": "^7.0.3",
"postcss-minify-selectors": "^7.0.5",
"postcss-normalize-charset": "^7.0.1",
"postcss-normalize-display-values": "^7.0.1",
@@ -883,13 +893,13 @@
"postcss-normalize-repeat-style": "^7.0.1",
"postcss-normalize-string": "^7.0.1",
"postcss-normalize-timing-functions": "^7.0.1",
"postcss-normalize-unicode": "^7.0.4",
"postcss-normalize-unicode": "^7.0.3",
"postcss-normalize-url": "^7.0.1",
"postcss-normalize-whitespace": "^7.0.1",
"postcss-ordered-values": "^7.0.2",
"postcss-reduce-initial": "^7.0.4",
"postcss-reduce-initial": "^7.0.3",
"postcss-reduce-transforms": "^7.0.1",
"postcss-svgo": "^7.1.0",
"postcss-svgo": "^7.0.2",
"postcss-unique-selectors": "^7.0.4"
},
"engines": {
@@ -1025,9 +1035,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.187",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.187.tgz",
"integrity": "sha512-cl5Jc9I0KGUoOoSbxvTywTa40uspGJt/BDBoDLoxJRSBpWh4FFXBsjNRHfQrONsV/OoEjDfHUmZQa2d6Ze4YgA==",
"version": "1.5.171",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.171.tgz",
"integrity": "sha512-scWpzXEJEMrGJa4Y6m/tVotb0WuvNmasv3wWVzUAeCgKU0ToFOhUW6Z+xWnRQANMYGxN4ngJXIThgBJOqzVPCQ==",
"dev": true,
"license": "ISC"
},
@@ -1052,9 +1062,9 @@
}
},
"node_modules/esbuild": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz",
"integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==",
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz",
"integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1065,32 +1075,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.8",
"@esbuild/android-arm": "0.25.8",
"@esbuild/android-arm64": "0.25.8",
"@esbuild/android-x64": "0.25.8",
"@esbuild/darwin-arm64": "0.25.8",
"@esbuild/darwin-x64": "0.25.8",
"@esbuild/freebsd-arm64": "0.25.8",
"@esbuild/freebsd-x64": "0.25.8",
"@esbuild/linux-arm": "0.25.8",
"@esbuild/linux-arm64": "0.25.8",
"@esbuild/linux-ia32": "0.25.8",
"@esbuild/linux-loong64": "0.25.8",
"@esbuild/linux-mips64el": "0.25.8",
"@esbuild/linux-ppc64": "0.25.8",
"@esbuild/linux-riscv64": "0.25.8",
"@esbuild/linux-s390x": "0.25.8",
"@esbuild/linux-x64": "0.25.8",
"@esbuild/netbsd-arm64": "0.25.8",
"@esbuild/netbsd-x64": "0.25.8",
"@esbuild/openbsd-arm64": "0.25.8",
"@esbuild/openbsd-x64": "0.25.8",
"@esbuild/openharmony-arm64": "0.25.8",
"@esbuild/sunos-x64": "0.25.8",
"@esbuild/win32-arm64": "0.25.8",
"@esbuild/win32-ia32": "0.25.8",
"@esbuild/win32-x64": "0.25.8"
"@esbuild/aix-ppc64": "0.25.6",
"@esbuild/android-arm": "0.25.6",
"@esbuild/android-arm64": "0.25.6",
"@esbuild/android-x64": "0.25.6",
"@esbuild/darwin-arm64": "0.25.6",
"@esbuild/darwin-x64": "0.25.6",
"@esbuild/freebsd-arm64": "0.25.6",
"@esbuild/freebsd-x64": "0.25.6",
"@esbuild/linux-arm": "0.25.6",
"@esbuild/linux-arm64": "0.25.6",
"@esbuild/linux-ia32": "0.25.6",
"@esbuild/linux-loong64": "0.25.6",
"@esbuild/linux-mips64el": "0.25.6",
"@esbuild/linux-ppc64": "0.25.6",
"@esbuild/linux-riscv64": "0.25.6",
"@esbuild/linux-s390x": "0.25.6",
"@esbuild/linux-x64": "0.25.6",
"@esbuild/netbsd-arm64": "0.25.6",
"@esbuild/netbsd-x64": "0.25.6",
"@esbuild/openbsd-arm64": "0.25.6",
"@esbuild/openbsd-x64": "0.25.6",
"@esbuild/openharmony-arm64": "0.25.6",
"@esbuild/sunos-x64": "0.25.6",
"@esbuild/win32-arm64": "0.25.6",
"@esbuild/win32-ia32": "0.25.6",
"@esbuild/win32-x64": "0.25.6"
}
},
"node_modules/escalade": {
@@ -1370,9 +1380,9 @@
}
},
"node_modules/mdn-data": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
"integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
"version": "2.0.30",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
"dev": true,
"license": "CC0-1.0"
},
@@ -1608,13 +1618,13 @@
}
},
"node_modules/postcss-colormin": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-7.0.4.tgz",
"integrity": "sha512-ziQuVzQZBROpKpfeDwmrG+Vvlr0YWmY/ZAk99XD+mGEBuEojoFekL41NCsdhyNUtZI7DPOoIWIR7vQQK9xwluw==",
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-7.0.3.tgz",
"integrity": "sha512-xZxQcSyIVZbSsl1vjoqZAcMYYdnJsIyG8OvqShuuqf12S88qQboxxEy0ohNCOLwVPXTU+hFHvJPACRL2B5ohTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"browserslist": "^4.25.1",
"browserslist": "^4.24.5",
"caniuse-api": "^3.0.0",
"colord": "^2.9.3",
"postcss-value-parser": "^4.2.0"
@@ -1627,13 +1637,13 @@
}
},
"node_modules/postcss-convert-values": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-7.0.6.tgz",
"integrity": "sha512-MD/eb39Mr60hvgrqpXsgbiqluawYg/8K4nKsqRsuDX9f+xN1j6awZCUv/5tLH8ak3vYp/EMXwdcnXvfZYiejCQ==",
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-7.0.5.tgz",
"integrity": "sha512-0VFhH8nElpIs3uXKnVtotDJJNX0OGYSZmdt4XfSfvOMrFw1jKfpwpZxfC4iN73CTM/MWakDEmsHQXkISYj4BXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"browserslist": "^4.25.1",
"browserslist": "^4.24.5",
"postcss-value-parser": "^4.2.0"
},
"engines": {
@@ -1827,13 +1837,13 @@
}
},
"node_modules/postcss-merge-rules": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-7.0.6.tgz",
"integrity": "sha512-2jIPT4Tzs8K87tvgCpSukRQ2jjd+hH6Bb8rEEOUDmmhOeTcqDg5fEFK8uKIu+Pvc3//sm3Uu6FRqfyv7YF7+BQ==",
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-7.0.5.tgz",
"integrity": "sha512-ZonhuSwEaWA3+xYbOdJoEReKIBs5eDiBVLAGpYZpNFPzXZcEE5VKR7/qBEQvTZpiwjqhhqEQ+ax5O3VShBj9Wg==",
"dev": true,
"license": "MIT",
"dependencies": {
"browserslist": "^4.25.1",
"browserslist": "^4.24.5",
"caniuse-api": "^3.0.0",
"cssnano-utils": "^5.0.1",
"postcss-selector-parser": "^7.1.0"
@@ -1880,13 +1890,13 @@
}
},
"node_modules/postcss-minify-params": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-7.0.4.tgz",
"integrity": "sha512-3OqqUddfH8c2e7M35W6zIwv7jssM/3miF9cbCSb1iJiWvtguQjlxZGIHK9JRmc8XAKmE2PFGtHSM7g/VcW97sw==",
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-7.0.3.tgz",
"integrity": "sha512-vUKV2+f5mtjewYieanLX0xemxIp1t0W0H/D11u+kQV/MWdygOO7xPMkbK+r9P6Lhms8MgzKARF/g5OPXhb8tgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"browserslist": "^4.25.1",
"browserslist": "^4.24.5",
"cssnano-utils": "^5.0.1",
"postcss-value-parser": "^4.2.0"
},
@@ -2008,13 +2018,13 @@
}
},
"node_modules/postcss-normalize-unicode": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-7.0.4.tgz",
"integrity": "sha512-LvIURTi1sQoZqj8mEIE8R15yvM+OhbR1avynMtI9bUzj5gGKR/gfZFd8O7VMj0QgJaIFzxDwxGl/ASMYAkqO8g==",
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-7.0.3.tgz",
"integrity": "sha512-EcoA29LvG3F+EpOh03iqu+tJY3uYYKzArqKJHxDhUYLa2u58aqGq16K6/AOsXD9yqLN8O6y9mmePKN5cx6krOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"browserslist": "^4.25.1",
"browserslist": "^4.24.5",
"postcss-value-parser": "^4.2.0"
},
"engines": {
@@ -2090,13 +2100,13 @@
}
},
"node_modules/postcss-reduce-initial": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-7.0.4.tgz",
"integrity": "sha512-rdIC9IlMBn7zJo6puim58Xd++0HdbvHeHaPgXsimMfG1ijC5A9ULvNLSE0rUKVJOvNMcwewW4Ga21ngyJjY/+Q==",
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-7.0.3.tgz",
"integrity": "sha512-RFvkZaqiWtGMlVjlUHpaxGqEL27lgt+Q2Ixjf83CRAzqdo+TsDyGPtJUbPx2MuYIJ+sCQc2TrOvRnhcXQfgIVA==",
"dev": true,
"license": "MIT",
"dependencies": {
"browserslist": "^4.25.1",
"browserslist": "^4.24.5",
"caniuse-api": "^3.0.0"
},
"engines": {
@@ -2164,14 +2174,14 @@
}
},
"node_modules/postcss-svgo": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-7.1.0.tgz",
"integrity": "sha512-KnAlfmhtoLz6IuU3Sij2ycusNs4jPW+QoFE5kuuUOK8awR6tMxZQrs5Ey3BUz7nFCzT3eqyFgqkyrHiaU2xx3w==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-7.0.2.tgz",
"integrity": "sha512-5Dzy66JlnRM6pkdOTF8+cGsB1fnERTE8Nc+Eed++fOWo1hdsBptCsbG8UuJkgtZt75bRtMJIrPeZmtfANixdFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.2.0",
"svgo": "^4.0.0"
"svgo": "^3.3.2"
},
"engines": {
"node": "^18.12.0 || ^20.9.0 || >= 18"
@@ -2326,13 +2336,6 @@
"url": "0.10.x"
}
},
"node_modules/sax": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
"integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==",
"dev": true,
"license": "ISC"
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -2425,25 +2428,25 @@
}
},
"node_modules/svgo": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz",
"integrity": "sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==",
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz",
"integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"commander": "^11.1.0",
"@trysound/sax": "0.2.0",
"commander": "^7.2.0",
"css-select": "^5.1.0",
"css-tree": "^3.0.1",
"css-tree": "^2.3.1",
"css-what": "^6.1.0",
"csso": "^5.0.5",
"picocolors": "^1.1.1",
"sax": "^1.4.1"
"picocolors": "^1.0.0"
},
"bin": {
"svgo": "bin/svgo.js"
"svgo": "bin/svgo"
},
"engines": {
"node": ">=16"
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",

View File

@@ -18,9 +18,9 @@
"author": "",
"license": "ISC",
"devDependencies": {
"cssnano": "^7.1.0",
"cssnano-preset-advanced": "^7.0.8",
"esbuild": "^0.25.8",
"cssnano": "^7.0.7",
"cssnano-preset-advanced": "^7.0.7",
"esbuild": "^0.25.6",
"playwright": "^1.52.0",
"postcss-cli": "^11.0.1",
"postcss-import": "^16.1.1",