Browse Source

feat(dns): DNS-over-HTTPS resolver for mobile networks

pull/151/head
samosvalishe 2 months ago
parent
commit
8d02e5daa0
  1. 601
      client/doh.go
  2. 197
      client/doh_test.go
  3. 83
      client/main.go
  4. 16
      client/manual_captcha.go
  5. 7
      go.mod
  6. 12
      go.sum

601
client/doh.go

@ -0,0 +1,601 @@
// DNS-over-HTTPS resolver for mobile networks where UDP/53 is blocked or
// spoofed.
package main
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"sort"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
// Embedded Mozilla CA roots for CGO_ENABLED=0 builds (Android).
_ "golang.org/x/crypto/x509roots/fallback"
)
const (
dohQueryTimeout = 6 * time.Second
dohCacheMinTTL = 10 * time.Second
dohCacheMaxTTL = 1 * time.Hour
dohMaxResponseBytes = 64 * 1024
dohContentType = "application/dns-message"
dohDialerTimeout = 5 * time.Second
dohDialerKeepAlive = 30 * time.Second
appDialerTimeout = 20 * time.Second
appDialerKeepAlive = 30 * time.Second
forwarderUDPBufSize = 4096
forwarderTCPReadDL = 30 * time.Second
forwarderTCPWriteDL = 10 * time.Second
autoUDPBudget = 1500 * time.Millisecond
)
// DohEndpoint describes a single DNS-over-HTTPS server together with the IPs
// we bootstrap to — so that resolving the endpoint hostname does not itself
// require DNS.
type DohEndpoint struct {
URL string
Hostname string
BootstrapIPs []string
}
// Yandex is tried first because it tends to stay reachable on RU mobile
// operators even when international resolvers get blocked; Google and
// Cloudflare follow as fallbacks.
var defaultDohEndpoints = []DohEndpoint{
{"https://common.dot.dns.yandex.net/dns-query", "common.dot.dns.yandex.net", []string{"77.88.8.8", "77.88.8.1"}},
{"https://secure.dot.dns.yandex.net/dns-query", "secure.dot.dns.yandex.net", []string{"77.88.8.88", "77.88.8.2"}},
{"https://family.dot.dns.yandex.net/dns-query", "family.dot.dns.yandex.net", []string{"77.88.8.7", "77.88.8.3"}},
{"https://dns.google/dns-query", "dns.google", []string{"8.8.8.8", "8.8.4.4"}},
{"https://cloudflare-dns.com/dns-query", "cloudflare-dns.com", []string{"1.1.1.1", "1.0.0.1"}},
}
// DohResolver resolves hostnames to IPs via DNS-over-HTTPS (RFC 8484).
type DohResolver struct {
endpoints []DohEndpoint
client *http.Client
cache *dohCache
}
// NewDohResolver constructs a resolver using defaultDohEndpoints if endpoints
// is nil. Endpoint hostnames are dialed by IP using BootstrapIPs, so the DoH
// transport never depends on the system resolver.
func NewDohResolver(endpoints []DohEndpoint) *DohResolver {
if len(endpoints) == 0 {
endpoints = defaultDohEndpoints
}
return &DohResolver{
endpoints: endpoints,
client: &http.Client{Timeout: dohQueryTimeout, Transport: newBootstrapTransport(endpoints)},
cache: newDohCache(),
}
}
// newDohResolverWithClient is a test hook that skips the bootstrap transport.
func newDohResolverWithClient(endpoints []DohEndpoint, client *http.Client) *DohResolver {
return &DohResolver{endpoints: endpoints, client: client, cache: newDohCache()}
}
// newBootstrapTransport returns an http.Transport whose DialContext only
// knows how to reach the configured DoH endpoint hostnames, by mapping each
// to its BootstrapIPs.
func newBootstrapTransport(endpoints []DohEndpoint) *http.Transport {
bootstrap := make(map[string][]string, len(endpoints))
for _, ep := range endpoints {
bootstrap[ep.Hostname] = ep.BootstrapIPs
}
dialer := &net.Dialer{Timeout: dohDialerTimeout, KeepAlive: dohDialerKeepAlive}
return &http.Transport{
MaxIdleConns: 8,
MaxIdleConnsPerHost: 2,
IdleConnTimeout: 90 * time.Second,
ForceAttemptHTTP2: true,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, ok := bootstrap[host]
if !ok {
return nil, fmt.Errorf("doh: no bootstrap IPs for %q", host)
}
var lastErr error
for _, ip := range ips {
conn, derr := dialer.DialContext(ctx, network, net.JoinHostPort(ip, port))
if derr == nil {
return conn, nil
}
lastErr = derr
}
return nil, lastErr
},
}
}
// LookupIPAddr resolves host to a combined list of A+AAAA IPs (IPv4 first).
// Cached results bypass the network entirely.
func (r *DohResolver) LookupIPAddr(ctx context.Context, host string) ([]net.IP, error) {
if ip := net.ParseIP(host); ip != nil {
return []net.IP{ip}, nil
}
if ips, ok := r.cache.get(host); ok {
return ips, nil
}
type res struct {
ips []net.IP
ttl time.Duration
err error
}
results := make(chan res, 2)
for _, qt := range [...]uint16{dns.TypeA, dns.TypeAAAA} {
go func(qtype uint16) {
ips, ttl, err := r.queryIPs(ctx, host, qtype)
results <- res{ips, ttl, err}
}(qt)
}
var (
all []net.IP
lastErr error
minTTL = dohCacheMaxTTL
)
for range 2 {
rr := <-results
if rr.err != nil {
lastErr = rr.err
continue
}
all = append(all, rr.ips...)
if rr.ttl > 0 && rr.ttl < minTTL {
minTTL = rr.ttl
}
}
if len(all) == 0 {
if lastErr == nil {
lastErr = fmt.Errorf("doh: no records for %s", host)
}
return nil, lastErr
}
// IPv4 before IPv6 — better compat with mobile IPv4-only CGNAT.
sort.SliceStable(all, func(i, j int) bool {
return (all[i].To4() != nil) && (all[j].To4() == nil)
})
if minTTL < dohCacheMinTTL {
minTTL = dohCacheMinTTL
}
r.cache.set(host, all, minTTL)
return all, nil
}
// queryIPs issues one DoH query for qtype, walking endpoints until one
// succeeds, and parses the wire reply into IPs + min TTL.
func (r *DohResolver) queryIPs(ctx context.Context, host string, qtype uint16) ([]net.IP, time.Duration, error) {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(host), qtype)
m.Id = 0 // RFC 8484 §4.1 — zero ID is cache-friendly on shared caches.
m.RecursionDesired = true
wire, err := m.Pack()
if err != nil {
return nil, 0, fmt.Errorf("doh: pack query: %w", err)
}
body, ep, err := r.forwardRaw(ctx, wire)
if err != nil {
return nil, 0, err
}
ips, ttl, err := parseAnswer(body)
if err != nil {
return nil, 0, fmt.Errorf("doh: parse %s: %w", ep.Hostname, err)
}
log.Printf("[DoH] %s %s via %s → %d IPs (ttl %s)", host, dns.TypeToString[qtype], ep.Hostname, len(ips), ttl)
return ips, ttl, nil
}
// parseAnswer decodes a DNS wire reply into A/AAAA records and the minimum TTL.
func parseAnswer(body []byte) ([]net.IP, time.Duration, error) {
reply := new(dns.Msg)
if err := reply.Unpack(body); err != nil {
return nil, 0, fmt.Errorf("unpack: %w", err)
}
if reply.Rcode != dns.RcodeSuccess {
return nil, 0, fmt.Errorf("rcode %s", dns.RcodeToString[reply.Rcode])
}
var (
ips []net.IP
minTTL uint32
)
updateTTL := func(ttl uint32) {
if minTTL == 0 || ttl < minTTL {
minTTL = ttl
}
}
for _, ans := range reply.Answer {
switch a := ans.(type) {
case *dns.A:
ips = append(ips, a.A)
updateTTL(a.Hdr.Ttl)
case *dns.AAAA:
ips = append(ips, a.AAAA)
updateTTL(a.Hdr.Ttl)
}
}
return ips, time.Duration(minTTL) * time.Second, nil
}
// forwardRaw POSTs an opaque DNS-wire query to the configured DoH endpoints
// in order and returns the first successful raw response together with the
// endpoint that produced it. No parsing — useful for the local forwarder
// which needs to pass through whatever the upstream resolver answers
// (RESINFO/HTTPS/SVCB/EDNS options/…).
func (r *DohResolver) forwardRaw(ctx context.Context, query []byte) ([]byte, DohEndpoint, error) {
if len(r.endpoints) == 0 {
return nil, DohEndpoint{}, errors.New("doh: no endpoints configured")
}
var lastErr error
for _, ep := range r.endpoints {
body, err := r.postWire(ctx, ep, query)
if err != nil {
log.Printf("[DoH] %s: %v", ep.Hostname, err)
lastErr = err
continue
}
return body, ep, nil
}
return nil, DohEndpoint{}, lastErr
}
// postWire performs a single application/dns-message POST to one endpoint.
func (r *DohResolver) postWire(ctx context.Context, ep DohEndpoint, query []byte) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(query))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", dohContentType)
req.Header.Set("Accept", dohContentType)
resp, err := r.client.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, dohMaxResponseBytes))
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
return body, nil
}
type dohCacheEntry struct {
ips []net.IP
expiry time.Time
}
type dohCache struct {
mu sync.RWMutex
m map[string]dohCacheEntry
}
func newDohCache() *dohCache {
return &dohCache{m: make(map[string]dohCacheEntry)}
}
func (c *dohCache) get(host string) ([]net.IP, bool) {
c.mu.RLock()
e, ok := c.m[host]
c.mu.RUnlock()
if !ok || time.Now().After(e.expiry) {
return nil, false
}
out := make([]net.IP, len(e.ips))
copy(out, e.ips)
return out, true
}
func (c *dohCache) set(host string, ips []net.IP, ttl time.Duration) {
if ttl <= 0 {
return
}
if ttl > dohCacheMaxTTL {
ttl = dohCacheMaxTTL
}
cp := make([]net.IP, len(ips))
copy(cp, ips)
c.mu.Lock()
c.m[host] = dohCacheEntry{ips: cp, expiry: time.Now().Add(ttl)}
c.mu.Unlock()
}
// Go's net.Resolver dials this stub like a regular nameserver, which avoids
// the many edge cases of a fake-net.Conn approach (RESINFO probes, EDNS
// handshakes, truncation, …). Whatever it reads on UDP/TCP is sent verbatim
// to a DoH endpoint and the wire response is sent back to the client.
type dohForwarder struct {
udpAddr string
tcpAddr string
}
var (
dohForwarderOnce sync.Once
dohForwarderInst *dohForwarder
dohForwarderErr error
)
// sharedDohForwarder lazily starts a process-wide forwarder bound to the
// supplied resolver. The first caller wins; subsequent callers reuse the
// same forwarder regardless of what they pass in.
func sharedDohForwarder(r *DohResolver) (*dohForwarder, error) {
dohForwarderOnce.Do(func() {
dohForwarderInst, dohForwarderErr = startDohForwarder(r)
})
return dohForwarderInst, dohForwarderErr
}
func startDohForwarder(r *DohResolver) (_ *dohForwarder, err error) {
udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
if err != nil {
return nil, fmt.Errorf("doh forwarder: listen UDP: %w", err)
}
defer func() {
if err != nil {
_ = udpConn.Close()
}
}()
tcpLn, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
if err != nil {
return nil, fmt.Errorf("doh forwarder: listen TCP: %w", err)
}
defer func() {
if err != nil {
_ = tcpLn.Close()
}
}()
fwd := &dohForwarder{
udpAddr: udpConn.LocalAddr().String(),
tcpAddr: tcpLn.Addr().String(),
}
log.Printf("[DoH] forwarder listening udp=%s tcp=%s", fwd.udpAddr, fwd.tcpAddr)
go fwd.serveUDP(udpConn, r)
go fwd.serveTCP(tcpLn, r)
return fwd, nil
}
func (f *dohForwarder) serveUDP(conn *net.UDPConn, r *DohResolver) {
defer func() { _ = conn.Close() }()
buf := make([]byte, forwarderUDPBufSize)
for {
n, client, err := conn.ReadFromUDP(buf)
if err != nil {
log.Printf("[DoH] udp read: %v", err)
return
}
query := append([]byte(nil), buf[:n]...)
go func(q []byte, c *net.UDPAddr) {
ctx, cancel := context.WithTimeout(context.Background(), dohQueryTimeout)
defer cancel()
resp, _, err := r.forwardRaw(ctx, q)
if err != nil {
log.Printf("[DoH] udp forward failed: %v", err)
return
}
if _, err := conn.WriteToUDP(resp, c); err != nil {
log.Printf("[DoH] udp write: %v", err)
}
}(query, client)
}
}
func (f *dohForwarder) serveTCP(ln *net.TCPListener, r *DohResolver) {
defer func() { _ = ln.Close() }()
for {
conn, err := ln.Accept()
if err != nil {
log.Printf("[DoH] tcp accept: %v", err)
return
}
go handleDohForwarderTCP(conn, r)
}
}
func handleDohForwarderTCP(conn net.Conn, r *DohResolver) {
defer func() { _ = conn.Close() }()
for {
_ = conn.SetReadDeadline(time.Now().Add(forwarderTCPReadDL))
var lenBuf [2]byte
if _, err := io.ReadFull(conn, lenBuf[:]); err != nil {
return
}
qlen := int(lenBuf[0])<<8 | int(lenBuf[1])
if qlen == 0 || qlen > forwarderUDPBufSize {
return
}
query := make([]byte, qlen)
if _, err := io.ReadFull(conn, query); err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), dohQueryTimeout)
resp, _, err := r.forwardRaw(ctx, query)
cancel()
if err != nil {
log.Printf("[DoH] tcp forward failed: %v", err)
return
}
out := make([]byte, 2+len(resp))
out[0] = byte(len(resp) >> 8)
out[1] = byte(len(resp))
copy(out[2:], resp)
_ = conn.SetWriteDeadline(time.Now().Add(forwarderTCPWriteDL))
if _, err := conn.Write(out); err != nil {
return
}
}
}
// dohForwarderDial returns a Resolver.Dial that connects to the local DoH
// forwarder over UDP or TCP (whichever the resolver asked for).
func dohForwarderDial(r *DohResolver) dialFunc {
return func(ctx context.Context, network, _ string) (net.Conn, error) {
fwd, err := sharedDohForwarder(r)
if err != nil {
return nil, err
}
var d net.Dialer
switch network {
case "tcp", "tcp4", "tcp6":
return d.DialContext(ctx, "tcp", fwd.tcpAddr)
default:
return d.DialContext(ctx, "udp", fwd.udpAddr)
}
}
}
const (
DNSModeUDP = "udp"
DNSModeDoH = "doh"
DNSModeAuto = "auto"
)
var udpDNSServers = []string{
"77.88.8.8:53", "77.88.8.1:53",
"8.8.8.8:53", "8.8.4.4:53",
"1.1.1.1:53", "1.0.0.1:53",
}
type dialFunc = func(context.Context, string, string) (net.Conn, error)
// buildDialer returns a net.Dialer whose internal Go resolver uses the
// chosen DNS transport. In "auto" mode the first total-failure of UDP/53
// sticks the process onto DoH for the rest of its lifetime.
func buildDialer(mode string, r *DohResolver) net.Dialer {
switch mode {
case DNSModeUDP:
return newAppDialer(udpDNSDial)
case DNSModeDoH:
return newAppDialer(dohForwarderDial(r))
case DNSModeAuto:
return newAppDialer(autoDial(r))
default:
log.Panicf("unknown DNS mode %q", mode)
return net.Dialer{}
}
}
// newAppDialer wraps a Resolver.Dial with the timeouts used everywhere in
// the app for outbound TCP/HTTP connections.
func newAppDialer(dial dialFunc) net.Dialer {
return net.Dialer{
Timeout: appDialerTimeout,
KeepAlive: appDialerKeepAlive,
Resolver: &net.Resolver{PreferGo: true, Dial: dial},
}
}
// udpDNSDial picks the first reachable UDP/53 resolver from udpDNSServers.
func udpDNSDial(ctx context.Context, _ string, _ string) (net.Conn, error) {
var (
d net.Dialer
lastErr error
)
for _, s := range udpDNSServers {
conn, err := d.DialContext(ctx, "udp", s)
if err == nil {
return conn, nil
}
lastErr = err
}
if lastErr == nil {
lastErr = errors.New("no UDP DNS servers available")
}
return nil, lastErr
}
// autoDial returns a Dial that probes UDP/53 once with a real DNS round-trip;
// if the probe fails it latches onto DoH for the rest of the process. Built
// for Android, where the network can flip between Wi-Fi (UDP/53 works) and
// mobile (UDP/53 blocked).
//
// A simple dial-timeout doesn't work for UDP because UDP "dial" is
// connectionless and always succeeds instantly. The only way to know whether
// UDP/53 actually works is to send a real query and wait for a response.
func autoDial(r *DohResolver) dialFunc {
var (
probed sync.Once
useDoH atomic.Bool
doh = dohForwarderDial(r)
)
return func(ctx context.Context, network, addr string) (net.Conn, error) {
probed.Do(func() {
if udpProbe(autoUDPBudget) {
log.Printf("[DNS] UDP/53 probe OK, using UDP")
} else {
log.Printf("[DNS] UDP/53 unreachable; sticky-switching to DoH")
useDoH.Store(true)
}
})
if useDoH.Load() {
return doh(ctx, network, addr)
}
return udpDNSDial(ctx, network, addr)
}
}
// udpProbe sends a real DNS A query for a well-known domain via UDP and
// checks whether any response arrives within the deadline. We try the first
// two servers from udpDNSServers under a shared deadline — if neither
// responds, UDP/53 is blocked.
func udpProbe(timeout time.Duration) bool {
m := new(dns.Msg)
m.SetQuestion("dns.google.", dns.TypeA)
m.RecursionDesired = true
wire, err := m.Pack()
if err != nil {
return false
}
deadline := time.Now().Add(timeout)
buf := make([]byte, 512)
limit := min(len(udpDNSServers), 2)
for _, server := range udpDNSServers[:limit] {
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
conn, err := net.DialTimeout("udp", server, remaining)
if err != nil {
continue
}
_ = conn.SetDeadline(deadline)
_, _ = conn.Write(wire)
n, err := conn.Read(buf)
_ = conn.Close()
if err == nil && n > 12 {
return true
}
}
return false
}

197
client/doh_test.go

@ -0,0 +1,197 @@
package main
import (
"context"
"io"
"net"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/miekg/dns"
)
// dohAnswer builds a wire-format DNS reply for a single question with one
// answer of the matching type (A or AAAA). TTL is returned as-is.
func dohAnswer(t *testing.T, query []byte, ip net.IP, ttl uint32) []byte {
t.Helper()
req := new(dns.Msg)
if err := req.Unpack(query); err != nil {
t.Fatalf("unpack query: %v", err)
}
reply := new(dns.Msg)
reply.SetReply(req)
if len(req.Question) != 1 {
t.Fatalf("expected 1 question, got %d", len(req.Question))
}
q := req.Question[0]
switch q.Qtype {
case dns.TypeA:
if v4 := ip.To4(); v4 != nil {
reply.Answer = append(reply.Answer, &dns.A{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl},
A: v4,
})
}
case dns.TypeAAAA:
if ip.To4() == nil {
reply.Answer = append(reply.Answer, &dns.AAAA{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: ttl},
AAAA: ip,
})
}
}
out, err := reply.Pack()
if err != nil {
t.Fatalf("pack reply: %v", err)
}
return out
}
func readWire(t *testing.T, r io.Reader) []byte {
t.Helper()
b, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read body: %v", err)
}
return b
}
func TestDohResolver_LookupIPAddr_Success(t *testing.T) {
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
if ct := r.Header.Get("Content-Type"); ct != "application/dns-message" {
t.Errorf("wrong Content-Type: %q", ct)
}
body := readWire(t, r.Body)
w.Header().Set("Content-Type", "application/dns-message")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(dohAnswer(t, body, net.ParseIP("93.184.216.34"), 300))
}))
defer srv.Close()
r := newDohResolverWithClient(
[]DohEndpoint{{URL: srv.URL, Hostname: "mock", BootstrapIPs: []string{"127.0.0.1"}}},
srv.Client(),
)
ips, err := r.LookupIPAddr(context.Background(), "example.com")
if err != nil {
t.Fatalf("lookup: %v", err)
}
if len(ips) == 0 {
t.Fatalf("no ips returned")
}
if ips[0].String() != "93.184.216.34" {
t.Fatalf("unexpected ip %s", ips[0])
}
// Two concurrent queries fire (A + AAAA), so we expect 2 hits.
if got := hits.Load(); got != 2 {
t.Fatalf("expected 2 HTTP hits, got %d", got)
}
}
func TestDohResolver_Fallback(t *testing.T) {
var firstHits, secondHits atomic.Int32
first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
firstHits.Add(1)
w.WriteHeader(http.StatusInternalServerError)
}))
defer first.Close()
second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
secondHits.Add(1)
body := readWire(t, r.Body)
w.Header().Set("Content-Type", "application/dns-message")
_, _ = w.Write(dohAnswer(t, body, net.ParseIP("1.2.3.4"), 300))
}))
defer second.Close()
r := newDohResolverWithClient(
[]DohEndpoint{
{URL: first.URL, Hostname: "first", BootstrapIPs: []string{"127.0.0.1"}},
{URL: second.URL, Hostname: "second", BootstrapIPs: []string{"127.0.0.1"}},
},
first.Client(),
)
ips, err := r.LookupIPAddr(context.Background(), "example.com")
if err != nil {
t.Fatalf("lookup: %v", err)
}
if len(ips) != 1 || ips[0].String() != "1.2.3.4" {
t.Fatalf("unexpected ips: %v", ips)
}
if firstHits.Load() == 0 || secondHits.Load() == 0 {
t.Fatalf("fallback did not probe both endpoints: first=%d second=%d", firstHits.Load(), secondHits.Load())
}
}
func TestDohResolver_Cache(t *testing.T) {
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
body := readWire(t, r.Body)
w.Header().Set("Content-Type", "application/dns-message")
_, _ = w.Write(dohAnswer(t, body, net.ParseIP("5.6.7.8"), 300))
}))
defer srv.Close()
r := newDohResolverWithClient(
[]DohEndpoint{{URL: srv.URL, Hostname: "mock", BootstrapIPs: []string{"127.0.0.1"}}},
srv.Client(),
)
if _, err := r.LookupIPAddr(context.Background(), "example.com"); err != nil {
t.Fatalf("first lookup: %v", err)
}
firstHits := hits.Load()
if _, err := r.LookupIPAddr(context.Background(), "example.com"); err != nil {
t.Fatalf("second lookup: %v", err)
}
if hits.Load() != firstHits {
t.Fatalf("cache miss: expected %d HTTP hits, got %d", firstHits, hits.Load())
}
}
func TestAutoDial_StickyAfterUDPFailure(t *testing.T) {
// DoH backend: always responds with a valid wire-format reply.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := readWire(t, r.Body)
w.Header().Set("Content-Type", "application/dns-message")
_, _ = w.Write(dohAnswer(t, body, net.ParseIP("9.9.9.9"), 300))
}))
defer srv.Close()
resolver := newDohResolverWithClient(
[]DohEndpoint{{URL: srv.URL, Hostname: "mock", BootstrapIPs: []string{"127.0.0.1"}}},
srv.Client(),
)
dial := autoDial(resolver)
// Poison udpDNSServers so that udpProbe (real DNS round-trip) fails
// immediately — net.DialTimeout rejects the malformed address.
old := udpDNSServers
udpDNSServers = []string{"not-a-valid-host-port"}
defer func() { udpDNSServers = old }()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn1, err := dial(ctx, "udp", "unused")
if err != nil {
t.Fatalf("first dial: %v", err)
}
_ = conn1.Close()
// Second call must skip UDP entirely. We assert this by poisoning
// udpDNSServers with a value that would fail parsing — if the dialer
// touches UDP again the call errors loudly.
udpDNSServers = []string{"still-not-a-valid-host-port"}
conn2, err := dial(ctx, "udp", "unused")
if err != nil {
t.Fatalf("second dial: %v", err)
}
_ = conn2.Close()
}

83
client/main.go

@ -32,7 +32,6 @@ import (
tlsclient "github.com/bogdanfinn/tls-client"
"github.com/bogdanfinn/tls-client/profiles"
"github.com/bschaatsbergen/dnsdialer"
"github.com/cacggghp/vk-turn-proxy/tcputil"
"github.com/cbeuw/connutil"
"github.com/google/uuid"
@ -247,27 +246,26 @@ func generateFakeCursor() string {
return "[" + strings.Join(points, ",") + "]"
}
func getCustomNetDialer() net.Dialer {
return net.Dialer{
Timeout: 20 * time.Second,
KeepAlive: 30 * time.Second,
Resolver: &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
var d net.Dialer
dnsServers := []string{"77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"}
var lastErr error
for _, dns := range dnsServers {
conn, err := d.DialContext(ctx, "udp", dns)
if err == nil {
return conn, nil
}
lastErr = err
}
return nil, lastErr
},
},
}
// dnsMode is set in main() from the -dns flag and consumed by appDialer().
var dnsMode = DNSModeAuto
// dohResolverSingleton is shared across all callers of appDialer().
var (
dohResolverOnce sync.Once
dohResolverInstance *DohResolver
)
func sharedDohResolver() *DohResolver {
dohResolverOnce.Do(func() {
dohResolverInstance = NewDohResolver(nil)
})
return dohResolverInstance
}
// appDialer returns the net.Dialer used by tls-client and other HTTP callers.
// DNS transport is selected by the -dns flag (udp | doh | auto).
func appDialer() net.Dialer {
return buildDialer(dnsMode, sharedDohResolver())
}
// endregion
@ -710,7 +708,7 @@ func (c *StreamCredentialsCache) invalidate(streamID int) {
log.Printf("[STREAM %d] [VK Auth] Credentials cache invalidated", streamID)
}
func getVkCredsCached(ctx context.Context, link string, streamID int, dialer *dnsdialer.Dialer) (string, string, string, error) {
func getVkCredsCached(ctx context.Context, link string, streamID int) (string, string, string, error) {
cache := getStreamCache(streamID)
cacheID := getCacheID(streamID)
@ -734,7 +732,7 @@ func getVkCredsCached(ctx context.Context, link string, streamID int, dialer *dn
return cache.creds.Username, cache.creds.Password, cache.creds.ServerAddr, nil
}
user, pass, addr, err := fetchVkCredsSerialized(ctx, link, streamID, dialer)
user, pass, addr, err := fetchVkCredsSerialized(ctx, link, streamID)
if err != nil {
return "", "", "", err
}
@ -748,7 +746,7 @@ var (
globalLastVkFetchTime time.Time
)
func fetchVkCredsSerialized(ctx context.Context, link string, streamID int, dialer *dnsdialer.Dialer) (string, string, string, error) {
func fetchVkCredsSerialized(ctx context.Context, link string, streamID int) (string, string, string, error) {
vkRequestMu.Lock()
defer vkRequestMu.Unlock()
@ -770,10 +768,10 @@ func fetchVkCredsSerialized(ctx context.Context, link string, streamID int, dial
globalLastVkFetchTime = time.Now()
}()
return fetchVkCreds(ctx, link, streamID, dialer)
return fetchVkCreds(ctx, link, streamID)
}
func fetchVkCreds(ctx context.Context, link string, streamID int, dialer *dnsdialer.Dialer) (string, string, string, error) {
func fetchVkCreds(ctx context.Context, link string, streamID int) (string, string, string, error) {
// Check Global Lockout to prevent API bans
if time.Now().Unix() < globalCaptchaLockout.Load() {
return "", "", "", fmt.Errorf("CAPTCHA_WAIT_REQUIRED: global lockout active")
@ -785,7 +783,7 @@ func fetchVkCreds(ctx context.Context, link string, streamID int, dialer *dnsdia
for _, creds := range vkCredentialsList {
log.Printf("[STREAM %d] [VK Auth] Trying credentials: client_id=%s", streamID, creds.ClientID)
user, pass, addr, err := getTokenChain(ctx, link, streamID, creds, dialer, jar)
user, pass, addr, err := getTokenChain(ctx, link, streamID, creds, jar)
if err == nil {
log.Printf("[STREAM %d] [VK Auth] Success with client_id=%s", streamID, creds.ClientID)
@ -808,7 +806,7 @@ func fetchVkCreds(ctx context.Context, link string, streamID int, dialer *dnsdia
return "", "", "", fmt.Errorf("all VK credentials failed: %w", lastErr)
}
func getTokenChain(ctx context.Context, link string, streamID int, creds VKCredentials, dialer *dnsdialer.Dialer, jar tlsclient.CookieJar) (string, string, string, error) {
func getTokenChain(ctx context.Context, link string, streamID int, creds VKCredentials, jar tlsclient.CookieJar) (string, string, string, error) {
profile := Profile{
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
SecChUa: `"Not(A:Brand";v="99", "Google Chrome";v="146", "Chromium";v="146"`,
@ -820,7 +818,7 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede
tlsclient.WithTimeoutSeconds(20),
tlsclient.WithClientProfile(profiles.Chrome_146),
tlsclient.WithCookieJar(jar),
tlsclient.WithDialer(getCustomNetDialer()),
tlsclient.WithDialer(appDialer()),
)
if err != nil {
return "", "", "", fmt.Errorf("failed to initialize tls_client: %w", err)
@ -969,7 +967,7 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede
var t, k string
var e error
if captchaErr.RedirectURI != "" {
t, e = solveCaptchaViaProxy(captchaErr.RedirectURI, dialer)
t, e = solveCaptchaViaProxy(captchaErr.RedirectURI)
} else if captchaErr.CaptchaImg != "" {
k, e = solveCaptchaViaHTTP(captchaErr.CaptchaImg)
} else {
@ -1077,6 +1075,7 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede
if !ok || len(urlsRaw) == 0 {
return "", "", "", fmt.Errorf("missing or empty urls in turn_server")
}
log.Printf("[STREAM %d] [VK Auth] turn_server urls: %v", streamID, urlsRaw)
urlStr, ok := urlsRaw[0].(string)
if !ok {
return "", "", "", fmt.Errorf("turn server url is not a string")
@ -1209,10 +1208,12 @@ func getYandexCreds(link string) (string, string, string, error) {
}
endpoint := "https://" + telemostConfHost + telemostConfPath
appD := appDialer()
tr := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
DialContext: appD.DialContext,
}
client := &http.Client{
Timeout: 20 * time.Second,
@ -1264,7 +1265,8 @@ func getYandexCreds(link string) (string, string, string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
dialer := websocket.Dialer{}
wsAppD := appDialer()
dialer := websocket.Dialer{NetDialContext: wsAppD.DialContext}
var conn *websocket.Conn
conn, resp, err = dialer.DialContext(ctx, data.Wss, h)
if err != nil {
@ -1553,6 +1555,7 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD
}
var turnServerAddr string
turnServerAddr = net.JoinHostPort(urlhost, urlport)
log.Printf("[STREAM %d] [TURN] dialing %s (udp=%v)", streamID, turnServerAddr, turnParams.udp)
turnServerUDPAddr, err1 := net.ResolveUDPAddr("udp", turnServerAddr)
if err1 != nil {
err = fmt.Errorf("failed to resolve TURN server address: %s", err1)
@ -1812,7 +1815,15 @@ func main() {
vlessMode := flag.Bool("vless", false, "VLESS mode: forward TCP connections (for VLESS) instead of UDP packets")
debugFlag := flag.Bool("debug", false, "enable debug logging")
manualCaptchaFlag := flag.Bool("manual-captcha", false, "skip auto captcha solving, use manual mode immediately")
dnsFlag := flag.String("dns", DNSModeAuto, "DNS resolution mode: udp | doh | auto (auto tries UDP/53 first, sticky-fallback to DoH on total failure)")
flag.Parse()
switch *dnsFlag {
case DNSModeUDP, DNSModeDoH, DNSModeAuto:
dnsMode = *dnsFlag
default:
log.Panicf("invalid -dns value %q (expected udp|doh|auto)", *dnsFlag)
}
log.Printf("[DNS] mode=%s", dnsMode)
if *peerAddr == "" {
log.Panicf("Need peer address!")
}
@ -1834,14 +1845,8 @@ func main() {
parts := strings.Split(*vklink, "join/")
link = parts[len(parts)-1]
dialer := dnsdialer.New(
dnsdialer.WithResolvers("77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"),
dnsdialer.WithStrategy(dnsdialer.Fallback{}),
dnsdialer.WithCache(100, 10*time.Hour, 10*time.Hour),
)
getCreds = func(ctx context.Context, s string, streamID int) (string, string, string, error) {
return getVkCredsCached(ctx, s, streamID, dialer)
return getVkCredsCached(ctx, s, streamID)
}
if *n <= 0 {
*n = 10

16
client/manual_captcha.go

@ -17,8 +17,6 @@ import (
"runtime"
"strings"
"time"
"github.com/bschaatsbergen/dnsdialer"
)
const captchaListenPort = "8765"
@ -335,19 +333,17 @@ func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string {
}
}
func newCaptchaProxyTransport(dialer *dnsdialer.Dialer) *http.Transport {
transport := &http.Transport{
func newCaptchaProxyTransport() *http.Transport {
d := appDialer()
return &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ForceAttemptHTTP2: false,
DialContext: d.DialContext,
}
if dialer != nil {
transport.DialContext = dialer.DialContext
}
return transport
}
func startCaptchaServer(srv *http.Server, logPrefix string) error {
@ -443,7 +439,7 @@ button{font-size:24px;padding:12px 32px;margin-top:12px;cursor:pointer}</style>
return runCaptchaServerAndWait(mux, localCaptchaOrigin(), keyCh, "captcha HTTP server error")
}
func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string, error) {
func solveCaptchaViaProxy(redirectURI string) (string, error) {
keyCh := make(chan string, 1)
targetURL, err := neturl.Parse(redirectURI)
@ -451,7 +447,7 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string,
return "", fmt.Errorf("invalid redirect URI: %v", err)
}
transport := newCaptchaProxyTransport(dialer)
transport := newCaptchaProxyTransport()
proxy := &httputil.ReverseProxy{
Transport: transport,

7
go.mod

@ -5,16 +5,17 @@ go 1.25.5
require (
github.com/bogdanfinn/fhttp v0.6.8
github.com/bogdanfinn/tls-client v1.14.0
github.com/bschaatsbergen/dnsdialer v0.0.0-20251225104348-3e7610e8ea45
github.com/cbeuw/connutil v1.0.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/miekg/dns v1.1.72
github.com/pion/dtls/v3 v3.1.2
github.com/pion/logging v0.2.4
github.com/pion/transport/v4 v4.0.1
github.com/pion/turn/v5 v5.0.3
github.com/xtaci/kcp-go/v5 v5.6.18
github.com/xtaci/smux v1.5.34
golang.org/x/crypto/x509roots/fallback v0.0.0-20260413170323-a8e9237a216b
)
require (
@ -25,11 +26,9 @@ require (
github.com/bogdanfinn/utls v1.7.7-barnius // indirect
github.com/bogdanfinn/websocket v1.5.5-barnius // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/klauspost/reedsolomon v1.12.4 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/stun/v3 v3.1.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
@ -46,6 +45,4 @@ require (
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.43.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
google.golang.org/grpc v1.80.0 // indirect
)

12
go.sum

@ -16,8 +16,6 @@ github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5Zl
github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg=
github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI=
github.com/bogdanfinn/websocket v1.5.5-barnius/go.mod h1:gvvEw6pTKHb7yOiFvIfAFTStQWyrm25BMVCTj5wRSsI=
github.com/bschaatsbergen/dnsdialer v0.0.0-20251225104348-3e7610e8ea45 h1:0b2i5TvZm8FVcuHP1288k+DEu1XM26DtRjcidOxpGXs=
github.com/bschaatsbergen/dnsdialer v0.0.0-20251225104348-3e7610e8ea45/go.mod h1:NU7MdmhQD8Ounc0760w90fL6nxI2lxjlnIaN6qWzNIU=
github.com/cbeuw/connutil v1.0.1 h1:LWuNYjwm7JEDYG/ISAO1TfU4G+q2dA5NhR97eq2roCA=
github.com/cbeuw/connutil v1.0.1/go.mod h1:lKofNtrW7Atmosgp1eNnTt2j2NjA2IkifapgLVI1QtA=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@ -49,8 +47,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
@ -105,6 +101,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260413170323-a8e9237a216b h1:ZG2SxTKsx1w3pUpOMD9dliRYnhWC5R5jmL6UDPCbYj4=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260413170323-a8e9237a216b/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@ -154,22 +152,16 @@ google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9Ywl
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

Loading…
Cancel
Save