Merge pull request #120 from Snawoot/ca_improvements

CA roots improvements
This commit is contained in:
Vladislav Yarmak
2026-01-08 23:25:45 +02:00
committed by GitHub
8 changed files with 65 additions and 73 deletions
+1 -7
View File
@@ -4,15 +4,9 @@ WORKDIR /go/src/github.com/Snawoot/opera-proxy
COPY . . COPY . .
ARG TARGETOS TARGETARCH ARG TARGETOS TARGETARCH
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -a -tags netgo -ldflags '-s -w -extldflags "-static"' RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -a -tags netgo -ldflags '-s -w -extldflags "-static"'
ADD https://curl.haxx.se/ca/cacert.pem /certs.crt
RUN chmod 0644 /certs.crt
FROM scratch AS arrange
COPY --from=build /go/src/github.com/Snawoot/opera-proxy/opera-proxy /
COPY --from=build /certs.crt /etc/ssl/certs/ca-certificates.crt
FROM scratch FROM scratch
COPY --from=arrange / / COPY --from=build /go/src/github.com/Snawoot/opera-proxy/opera-proxy /
USER 9999:9999 USER 9999:9999
EXPOSE 18080/tcp EXPOSE 18080/tcp
ENTRYPOINT ["/opera-proxy", "-bind-address", "0.0.0.0:18080"] ENTRYPOINT ["/opera-proxy", "-bind-address", "0.0.0.0:18080"]
-1
View File
@@ -99,7 +99,6 @@ eu3.sec-tunnel.com,77.111.244.22,443
| bind-address | String | proxy listen address (default "127.0.0.1:18080") | | bind-address | String | proxy listen address (default "127.0.0.1:18080") |
| bootstrap-dns | String | Comma-separated list of DNS/DoH/DoT resolvers for initial discovery of SurfEasy API address. Supported schemes are: `dns://`, `https://`, `tls://`, `tcp://`. Examples: `https://1.1.1.1/dns-query`, `tls://9.9.9.9:853` (default `https://1.1.1.3/dns-query,https://8.8.8.8/dns-query,https://dns.google/dns-query,https://security.cloudflare-dns.com/dns-query,https://fidelity.vm-0.com/q,https://wikimedia-dns.org/dns-query,https://dns.adguard-dns.com/dns-query,https://dns.quad9.net/dns-query,https://doh.cleanbrowsing.org/doh/adult-filter/`) | | bootstrap-dns | String | Comma-separated list of DNS/DoH/DoT resolvers for initial discovery of SurfEasy API address. Supported schemes are: `dns://`, `https://`, `tls://`, `tcp://`. Examples: `https://1.1.1.1/dns-query`, `tls://9.9.9.9:853` (default `https://1.1.1.3/dns-query,https://8.8.8.8/dns-query,https://dns.google/dns-query,https://security.cloudflare-dns.com/dns-query,https://fidelity.vm-0.com/q,https://wikimedia-dns.org/dns-query,https://dns.adguard-dns.com/dns-query,https://dns.quad9.net/dns-query,https://doh.cleanbrowsing.org/doh/adult-filter/`) |
| cafile | String | use custom CA certificate bundle file | | cafile | String | use custom CA certificate bundle file |
| certchain-workaround | Boolean | add bundled cross-signed intermediate cert to certchain to make it check out on old systems (default true) |
| config | String | read configuration from file with space-separated keys and values | | config | String | read configuration from file with space-separated keys and values |
| country | String | desired proxy location (default "EU") | | country | String | desired proxy location (default "EU") |
| dp-export | - | export configuration for dumbproxy | | dp-export | - | export configuration for dumbproxy |
+13 -52
View File
@@ -7,7 +7,6 @@ import (
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
"encoding/pem"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -22,35 +21,8 @@ const (
PROXY_CONNECT_METHOD = "CONNECT" PROXY_CONNECT_METHOD = "CONNECT"
PROXY_HOST_HEADER = "Host" PROXY_HOST_HEADER = "Host"
PROXY_AUTHORIZATION_HEADER = "Proxy-Authorization" PROXY_AUTHORIZATION_HEADER = "Proxy-Authorization"
MISSING_CHAIN_CERT = `-----BEGIN CERTIFICATE-----
MIID0zCCArugAwIBAgIQVmcdBOpPmUxvEIFHWdJ1lDANBgkqhkiG9w0BAQwFADB7
MQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD
VQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE
AwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTE5MDMxMjAwMDAwMFoXDTI4
MTIzMTIzNTk1OVowgYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpOZXcgSmVyc2V5
MRQwEgYDVQQHEwtKZXJzZXkgQ2l0eTEeMBwGA1UEChMVVGhlIFVTRVJUUlVTVCBO
ZXR3b3JrMS4wLAYDVQQDEyVVU0VSVHJ1c3QgRUNDIENlcnRpZmljYXRpb24gQXV0
aG9yaXR5MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEGqxUWqn5aCPnetUkb1PGWthL
q8bVttHmc3Gu3ZzWDGH926CJA7gFFOxXzu5dP+Ihs8731Ip54KODfi2X0GHE8Znc
JZFjq38wo7Rw4sehM5zzvy5cU7Ffs30yf4o043l5o4HyMIHvMB8GA1UdIwQYMBaA
FKARCiM+lvEH7OKvKe+CpX/QMKS0MB0GA1UdDgQWBBQ64QmG1M8ZwpZ2dEl23OA1
xmNjmjAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zARBgNVHSAECjAI
MAYGBFUdIAAwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5j
b20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQG
CCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQEM
BQADggEBABns652JLCALBIAdGN5CmXKZFjK9Dpx1WywV4ilAbe7/ctvbq5AfjJXy
ij0IckKJUAfiORVsAYfZFhr1wHUrxeZWEQff2Ji8fJ8ZOd+LygBkc7xGEJuTI42+
FsMuCIKchjN0djsoTI0DQoWz4rIjQtUfenVqGtF8qmchxDM6OW1TyaLtYiKou+JV
bJlsQ2uRl9EMC5MCHdK8aXdJ5htN978UeAOwproLtOGFfy/cQjutdAFI3tZs4RmY
CV4Ks2dH/hzg1cEo70qLRDEmBDeNiXQ2Lu+lIg+DdEmSx/cQwgwp+7e9un/jX9Wf
8qn0dNW44bOwgeThpWOjzOoEeJBuv/c=
-----END CERTIFICATE-----
`
) )
var missingLinkDER, _ = pem.Decode([]byte(MISSING_CHAIN_CERT))
var missingLink, _ = x509.ParseCertificate(missingLinkDER.Bytes)
type stringCb = func() (string, error) type stringCb = func() (string, error)
type Dialer interface { type Dialer interface {
@@ -63,24 +35,22 @@ type ContextDialer interface {
} }
type ProxyDialer struct { type ProxyDialer struct {
address stringCb address stringCb
tlsServerName stringCb tlsServerName stringCb
fakeSNI stringCb fakeSNI stringCb
auth stringCb auth stringCb
next ContextDialer next ContextDialer
intermediateWorkaround bool caPool *x509.CertPool
caPool *x509.CertPool
} }
func NewProxyDialer(address, tlsServerName, fakeSNI, auth stringCb, intermediateWorkaround bool, caPool *x509.CertPool, nextDialer ContextDialer) *ProxyDialer { func NewProxyDialer(address, tlsServerName, fakeSNI, auth stringCb, caPool *x509.CertPool, nextDialer ContextDialer) *ProxyDialer {
return &ProxyDialer{ return &ProxyDialer{
address: address, address: address,
tlsServerName: tlsServerName, tlsServerName: tlsServerName,
fakeSNI: fakeSNI, fakeSNI: fakeSNI,
auth: auth, auth: auth,
next: nextDialer, next: nextDialer,
intermediateWorkaround: intermediateWorkaround, caPool: caPool,
caPool: caPool,
} }
} }
@@ -116,7 +86,6 @@ func ProxyDialerFromURL(u *url.URL, next ContextDialer) (*ProxyDialer, error) {
WrapStringToCb(tlsServerName), WrapStringToCb(tlsServerName),
WrapStringToCb(tlsServerName), WrapStringToCb(tlsServerName),
auth, auth,
false,
nil, nil,
next), nil next), nil
} }
@@ -158,16 +127,8 @@ func (d *ProxyDialer) DialContext(ctx context.Context, network, address string)
Intermediates: x509.NewCertPool(), Intermediates: x509.NewCertPool(),
Roots: d.caPool, Roots: d.caPool,
} }
waRequired := false
for _, cert := range cs.PeerCertificates[1:] { for _, cert := range cs.PeerCertificates[1:] {
opts.Intermediates.AddCert(cert) opts.Intermediates.AddCert(cert)
if d.intermediateWorkaround && !waRequired &&
bytes.Compare(cert.AuthorityKeyId, missingLink.SubjectKeyId) == 0 {
waRequired = true
}
}
if waRequired {
opts.Intermediates.AddCert(missingLink)
} }
_, err := cs.PeerCertificates[0].Verify(opts) _, err := cs.PeerCertificates[0].Verify(opts)
return err return err
+4 -1
View File
@@ -12,4 +12,7 @@ require (
golang.org/x/net v0.48.0 golang.org/x/net v0.48.0
) )
require github.com/hashicorp/errwrap v1.1.0 // indirect require (
github.com/hashicorp/errwrap v1.1.0 // indirect
golang.org/x/crypto/x509roots/fallback v0.0.0-20251210140736-7dacc380ba00 // indirect
)
+3
View File
@@ -15,6 +15,9 @@ github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQ
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/things-go/go-socks5 v0.1.0 h1:4f5dz0iMQ6cA4wseFmyLmCHmg3SWJTW92ndrKS6oERg= github.com/things-go/go-socks5 v0.1.0 h1:4f5dz0iMQ6cA4wseFmyLmCHmg3SWJTW92ndrKS6oERg=
github.com/things-go/go-socks5 v0.1.0/go.mod h1:Riabiyu52kLsla0YmJqunt1c1JEl6iXSr4bRd7swFEA= github.com/things-go/go-socks5 v0.1.0/go.mod h1:Riabiyu52kLsla0YmJqunt1c1JEl6iXSr4bRd7swFEA=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto/x509roots/fallback v0.0.0-20251210140736-7dacc380ba00 h1:qObov2/X4yIpr98j5t6samg3mMF12Rl4taUJd1rWj+c=
golang.org/x/crypto/x509roots/fallback v0.0.0-20251210140736-7dacc380ba00/go.mod h1:MEIPiCnxvQEjA4astfaKItNwEVZA5Ki+3+nyGbJ5N18=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+18 -7
View File
@@ -29,6 +29,9 @@ import (
clog "github.com/Snawoot/opera-proxy/log" clog "github.com/Snawoot/opera-proxy/log"
"github.com/Snawoot/opera-proxy/resolver" "github.com/Snawoot/opera-proxy/resolver"
se "github.com/Snawoot/opera-proxy/seclient" se "github.com/Snawoot/opera-proxy/seclient"
_ "golang.org/x/crypto/x509roots/fallback"
"golang.org/x/crypto/x509roots/fallback/bundle"
) )
const ( const (
@@ -119,7 +122,6 @@ type CLIArgs struct {
refreshRetry time.Duration refreshRetry time.Duration
initRetries int initRetries int
initRetryInterval time.Duration initRetryInterval time.Duration
certChainWorkaround bool
caFile string caFile string
fakeSNI string fakeSNI string
overrideProxyAddress string overrideProxyAddress string
@@ -174,8 +176,6 @@ func parse_args() *CLIArgs {
flag.DurationVar(&args.refreshRetry, "refresh-retry", 5*time.Second, "login refresh retry interval") flag.DurationVar(&args.refreshRetry, "refresh-retry", 5*time.Second, "login refresh retry interval")
flag.IntVar(&args.initRetries, "init-retries", 0, "number of attempts for initialization steps, zero for unlimited retry") flag.IntVar(&args.initRetries, "init-retries", 0, "number of attempts for initialization steps, zero for unlimited retry")
flag.DurationVar(&args.initRetryInterval, "init-retry-interval", 5*time.Second, "delay between initialization retries") flag.DurationVar(&args.initRetryInterval, "init-retry-interval", 5*time.Second, "delay between initialization retries")
flag.BoolVar(&args.certChainWorkaround, "certchain-workaround", true,
"add bundled cross-signed intermediate cert to certchain to make it check out on old systems")
flag.StringVar(&args.caFile, "cafile", "", "use custom CA certificate bundle file") flag.StringVar(&args.caFile, "cafile", "", "use custom CA certificate bundle file")
flag.StringVar(&args.fakeSNI, "fake-SNI", "", "domain name to use as SNI in communications with servers") flag.StringVar(&args.fakeSNI, "fake-SNI", "", "domain name to use as SNI in communications with servers")
flag.StringVar(&args.overrideProxyAddress, "override-proxy-address", "", "use fixed proxy address instead of server address returned by SurfEasy API") flag.StringVar(&args.overrideProxyAddress, "override-proxy-address", "", "use fixed proxy address instead of server address returned by SurfEasy API")
@@ -230,9 +230,8 @@ func run() int {
KeepAlive: 30 * time.Second, KeepAlive: 30 * time.Second,
} }
var caPool *x509.CertPool caPool := x509.NewCertPool()
if args.caFile != "" { if args.caFile != "" {
caPool = x509.NewCertPool()
certs, err := ioutil.ReadFile(args.caFile) certs, err := ioutil.ReadFile(args.caFile)
if err != nil { if err != nil {
mainLogger.Error("Can't load CA file: %v", err) mainLogger.Error("Can't load CA file: %v", err)
@@ -242,6 +241,19 @@ func run() int {
mainLogger.Error("Can't load certificates from CA file") mainLogger.Error("Can't load certificates from CA file")
return 15 return 15
} }
} else {
for c := range bundle.Roots() {
cert, err := x509.ParseCertificate(c.Certificate)
if err != nil {
mainLogger.Error("Unable to parse bundled certificate: %v", err)
return 15
}
if c.Constraint == nil {
caPool.AddCert(cert)
} else {
caPool.AddCertWithConstraint(cert, c.Constraint)
}
}
} }
xproxy.RegisterDialerType("http", proxyFromURLWrapper) xproxy.RegisterDialerType("http", proxyFromURLWrapper)
@@ -278,7 +290,7 @@ func run() int {
mainLogger.Info("Using fixed API host address = %s", args.apiAddress) mainLogger.Info("Using fixed API host address = %s", args.apiAddress)
seclientDialer = dialer.NewFixedDialer(args.apiAddress, seclientDialer) seclientDialer = dialer.NewFixedDialer(args.apiAddress, seclientDialer)
} else if len(args.bootstrapDNS.values) > 0 { } else if len(args.bootstrapDNS.values) > 0 {
resolver, err := resolver.FastFromURLs(args.bootstrapDNS.values...) resolver, err := resolver.FastFromURLs(caPool, args.bootstrapDNS.values...)
if err != nil { if err != nil {
mainLogger.Critical("Unable to instantiate DNS resolver: %v", err) mainLogger.Critical("Unable to instantiate DNS resolver: %v", err)
return 4 return 4
@@ -372,7 +384,6 @@ func run() int {
func() (string, error) { func() (string, error) {
return dialer.BasicAuthHeader(seclient.GetProxyCredentials()), nil return dialer.BasicAuthHeader(seclient.GetProxyCredentials()), nil
}, },
args.certChainWorkaround,
caPool, caPool,
d) d)
} }
+23 -3
View File
@@ -1,15 +1,19 @@
package resolver package resolver
import ( import (
"crypto/tls"
"crypto/x509"
"errors" "errors"
"net" "net"
"net/http"
"net/url" "net/url"
"strings" "strings"
"time"
"github.com/ncruces/go-dns" "github.com/ncruces/go-dns"
) )
func FromURL(u string) (*net.Resolver, error) { func FromURL(u string, caPool *x509.CertPool) (*net.Resolver, error) {
begin: begin:
parsed, err := url.Parse(u) parsed, err := url.Parse(u)
if err != nil { if err != nil {
@@ -48,13 +52,29 @@ begin:
parsed.Scheme = "https" parsed.Scheme = "https"
u = parsed.String() u = parsed.String()
} }
return dns.NewDoHResolver(u, dns.DoHAddresses(net.JoinHostPort(host, port))) return dns.NewDoHResolver(u,
dns.DoHAddresses(net.JoinHostPort(host, port)),
dns.DoHTransport(&http.Transport{
MaxIdleConns: http.DefaultMaxIdleConnsPerHost,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ForceAttemptHTTP2: true,
TLSClientConfig: &tls.Config{
RootCAs: caPool,
},
}),
)
case "tls", "dot": case "tls", "dot":
if port == "" { if port == "" {
port = "853" port = "853"
} }
hp := net.JoinHostPort(host, port) hp := net.JoinHostPort(host, port)
return dns.NewDoTResolver(hp, dns.DoTAddresses(hp)) return dns.NewDoTResolver(hp,
dns.DoTAddresses(hp),
dns.DoTConfig(&tls.Config{
RootCAs: caPool,
}),
)
default: default:
return nil, errors.New("not implemented") return nil, errors.New("not implemented")
} }
+3 -2
View File
@@ -2,6 +2,7 @@ package resolver
import ( import (
"context" "context"
"crypto/x509"
"fmt" "fmt"
"net/netip" "net/netip"
@@ -16,10 +17,10 @@ type FastResolver struct {
upstreams []LookupNetIPer upstreams []LookupNetIPer
} }
func FastFromURLs(urls ...string) (LookupNetIPer, error) { func FastFromURLs(caPool *x509.CertPool, urls ...string) (LookupNetIPer, error) {
resolvers := make([]LookupNetIPer, 0, len(urls)) resolvers := make([]LookupNetIPer, 0, len(urls))
for i, u := range urls { for i, u := range urls {
res, err := FromURL(u) res, err := FromURL(u, caPool)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to construct resolver #%d (%q): %w", i, u, err) return nil, fmt.Errorf("unable to construct resolver #%d (%q): %w", i, u, err)
} }