mirror of
https://github.com/TecharoHQ/anubis.git
synced 2025-09-10 13:17:31 -04:00
feat(osiris): add TCP and TLS fingerprinting
Signed-off-by: Xe Iaso <me@xeiaso.net>
This commit is contained in:
parent
1eafebedbc
commit
d9c4e37978
@ -2,6 +2,7 @@ package entrypoint
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
@ -12,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/TecharoHQ/anubis/cmd/osiris/internal/config"
|
"github.com/TecharoHQ/anubis/cmd/osiris/internal/config"
|
||||||
"github.com/TecharoHQ/anubis/internal"
|
"github.com/TecharoHQ/anubis/internal"
|
||||||
|
"github.com/TecharoHQ/anubis/internal/fingerprint"
|
||||||
"github.com/hashicorp/hcl/v2/hclsimple"
|
"github.com/hashicorp/hcl/v2/hclsimple"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
@ -57,7 +59,7 @@ func Main(opts Options) error {
|
|||||||
ln.Close()
|
ln.Close()
|
||||||
}(gCtx)
|
}(gCtx)
|
||||||
|
|
||||||
slog.Info("listening for HTTP", "bind", cfg.Bind.HTTP)
|
slog.Info("listening", "for", "http", "bind", cfg.Bind.HTTP)
|
||||||
|
|
||||||
srv := http.Server{Handler: rtr, ErrorLog: internal.GetFilteredHTTPLogger()}
|
srv := http.Server{Handler: rtr, ErrorLog: internal.GetFilteredHTTPLogger()}
|
||||||
|
|
||||||
@ -65,6 +67,35 @@ func Main(opts Options) error {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// HTTPS
|
// 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()
|
||||||
|
}(gCtx)
|
||||||
|
|
||||||
|
tc := &tls.Config{
|
||||||
|
GetCertificate: rtr.GetCertificate,
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: cfg.Bind.HTTPS,
|
||||||
|
Handler: rtr,
|
||||||
|
ErrorLog: internal.GetFilteredHTTPLogger(),
|
||||||
|
TLSConfig: tc,
|
||||||
|
}
|
||||||
|
|
||||||
|
fingerprint.ApplyTLSFingerprinter(srv)
|
||||||
|
|
||||||
|
slog.Info("listening", "for", "https", "bind", cfg.Bind.HTTPS)
|
||||||
|
|
||||||
|
return srv.ServeTLS(ln, "", "")
|
||||||
|
})
|
||||||
|
|
||||||
// Metrics
|
// Metrics
|
||||||
g.Go(func() error {
|
g.Go(func() error {
|
||||||
@ -101,12 +132,18 @@ func Main(opts Options) error {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
slog.Info("listening for Metrics", "bind", cfg.Bind.Metrics)
|
slog.Info("listening", "for", "metrics", "bind", cfg.Bind.Metrics)
|
||||||
|
|
||||||
srv := http.Server{Handler: mux, ErrorLog: internal.GetFilteredHTTPLogger()}
|
srv := http.Server{
|
||||||
|
Addr: cfg.Bind.Metrics,
|
||||||
|
Handler: mux,
|
||||||
|
ErrorLog: internal.GetFilteredHTTPLogger(),
|
||||||
|
}
|
||||||
|
|
||||||
return srv.Serve(ln)
|
return srv.Serve(ln)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
internal.SetHealth("osiris", healthv1.HealthCheckResponse_SERVING)
|
||||||
|
|
||||||
return g.Wait()
|
return g.Wait()
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ package entrypoint
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@ -13,6 +14,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/TecharoHQ/anubis/cmd/osiris/internal/config"
|
"github.com/TecharoHQ/anubis/cmd/osiris/internal/config"
|
||||||
|
"github.com/TecharoHQ/anubis/internal/fingerprint"
|
||||||
"github.com/lum8rjack/go-ja4h"
|
"github.com/lum8rjack/go-ja4h"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
@ -21,6 +23,8 @@ import (
|
|||||||
var (
|
var (
|
||||||
ErrTargetInvalid = errors.New("[unexpected] target invalid")
|
ErrTargetInvalid = errors.New("[unexpected] target invalid")
|
||||||
ErrNoHandler = errors.New("[unexpected] no handler for domain")
|
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{
|
requestsPerDomain = promauto.NewGaugeVec(prometheus.GaugeOpts{
|
||||||
Namespace: "techaro",
|
Namespace: "techaro",
|
||||||
@ -38,11 +42,13 @@ var (
|
|||||||
type Router struct {
|
type Router struct {
|
||||||
lock sync.RWMutex
|
lock sync.RWMutex
|
||||||
routes map[string]http.Handler
|
routes map[string]http.Handler
|
||||||
|
tlsCerts map[string]*tls.Certificate
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rtr *Router) setConfig(c config.Toplevel) error {
|
func (rtr *Router) setConfig(c config.Toplevel) error {
|
||||||
var errs []error
|
var errs []error
|
||||||
newMap := map[string]http.Handler{}
|
newMap := map[string]http.Handler{}
|
||||||
|
newCerts := map[string]*tls.Certificate{}
|
||||||
|
|
||||||
for _, d := range c.Domains {
|
for _, d := range c.Domains {
|
||||||
var domainErrs []error
|
var domainErrs []error
|
||||||
@ -75,6 +81,13 @@ func (rtr *Router) setConfig(c config.Toplevel) error {
|
|||||||
|
|
||||||
newMap[d.Name] = h
|
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 {
|
if len(domainErrs) != 0 {
|
||||||
errs = append(errs, fmt.Errorf("invalid domain %s: %w", d.Name, errors.Join(domainErrs...)))
|
errs = append(errs, fmt.Errorf("invalid domain %s: %w", d.Name, errors.Join(domainErrs...)))
|
||||||
}
|
}
|
||||||
@ -86,11 +99,24 @@ func (rtr *Router) setConfig(c config.Toplevel) error {
|
|||||||
|
|
||||||
rtr.lock.Lock()
|
rtr.lock.Lock()
|
||||||
rtr.routes = newMap
|
rtr.routes = newMap
|
||||||
|
rtr.tlsCerts = newCerts
|
||||||
rtr.lock.Unlock()
|
rtr.lock.Unlock()
|
||||||
|
|
||||||
return nil
|
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 NewRouter(c config.Toplevel) (*Router, error) {
|
func NewRouter(c config.Toplevel) (*Router, error) {
|
||||||
result := &Router{
|
result := &Router{
|
||||||
routes: map[string]http.Handler{},
|
routes: map[string]http.Handler{},
|
||||||
@ -104,17 +130,23 @@ func NewRouter(c config.Toplevel) (*Router, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (rtr *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (rtr *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
requestsPerDomain.WithLabelValues(r.Host).Inc()
|
var host = r.Host
|
||||||
|
|
||||||
|
if strings.Contains(host, ":") {
|
||||||
|
host, _, _ = net.SplitHostPort(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestsPerDomain.WithLabelValues(host).Inc()
|
||||||
|
|
||||||
var h http.Handler
|
var h http.Handler
|
||||||
var ok bool
|
var ok bool
|
||||||
|
|
||||||
ja4hFP := ja4h.JA4H(r)
|
ja4hFP := ja4h.JA4H(r)
|
||||||
|
|
||||||
slog.Info("got request", "method", r.Method, "host", r.Host, "path", r.URL.Path)
|
slog.Info("got request", "method", r.Method, "host", host, "path", r.URL.Path)
|
||||||
|
|
||||||
rtr.lock.RLock()
|
rtr.lock.RLock()
|
||||||
h, ok = rtr.routes[r.Host]
|
h, ok = rtr.routes[host]
|
||||||
rtr.lock.RUnlock()
|
rtr.lock.RUnlock()
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
@ -125,5 +157,18 @@ func (rtr *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
r.Header.Set("X-Http-Ja4h-Fingerprint", ja4hFP)
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
h.ServeHTTP(w, r)
|
h.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@ bind {
|
|||||||
metrics = ":9091"
|
metrics = ":9091"
|
||||||
}
|
}
|
||||||
|
|
||||||
domain "anubis.techaro.lol" {
|
domain "osiris.local.cetacean.club" {
|
||||||
tls {
|
tls {
|
||||||
cert = "./internal/config/testdata/tls/selfsigned.crt"
|
cert = "./internal/config/testdata/tls/selfsigned.crt"
|
||||||
key = "./internal/config/testdata/tls/selfsigned.key"
|
key = "./internal/config/testdata/tls/selfsigned.key"
|
||||||
|
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[:])
|
||||||
|
}
|
178
internal/fingerprint/ja4.go
Normal file
178
internal/fingerprint/ja4.go
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
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.VersionSSL30:
|
||||||
|
buf = append(buf, 's', '3')
|
||||||
|
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
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user