├── .dockerignore ├── main.go ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml ├── workflows │ ├── test.yml │ ├── stale.yml │ ├── linter.yml │ ├── qlty-coverage.yml │ ├── codeql-analysis.yml │ ├── release.yml │ └── docker.yml ├── dependabot.yml └── CODE_OF_CONDUCT.md ├── internal └── version │ ├── module.go │ └── version.go ├── ipscanner ├── model │ ├── ping.go │ ├── queue.go │ └── statute.go ├── ping │ ├── ping.go │ └── warp.go ├── ipgenerator │ ├── ipgenerator_test.go │ └── ipgenerator.go ├── engine │ ├── engine.go │ └── queue.go └── scanner.go ├── core ├── config.go ├── datadir │ └── datadir.go ├── wireguard.go ├── scanner.go ├── cache │ ├── cache_test.go │ └── cache.go └── engine.go ├── docker └── entrypoint.sh ├── docker-compose.yml ├── log ├── zap.go ├── level.go ├── sugar.go └── log.go ├── cloudflare ├── model │ ├── config.go │ ├── account.go │ ├── utils.go │ └── identity.go ├── network │ ├── endpoint_test.go │ ├── endpoint.go │ └── tls.go ├── crypto │ ├── key_test.go │ └── key.go ├── identity_test.go ├── identity.go └── api.go ├── utils ├── utils.go └── iputils.go ├── .gitignore ├── .golangci.yaml ├── Dockerfile ├── cmd ├── generate.go ├── status.go ├── shared.go ├── update.go ├── root.go ├── scanner.go └── run.go ├── LICENSE ├── go.mod ├── Makefile ├── README.md └── go.sum /.dockerignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | .gitignore 3 | .golangci.yaml 4 | 5 | # Other 6 | docs/ 7 | build/ 8 | tests/ 9 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/shahradelahi/cloudflare-warp/cmd" 5 | ) 6 | 7 | func main() { 8 | cmd.Execute() 9 | } 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | 3 | contact_links: 4 | - name: GitHub Discussions 5 | url: https://github.com/shahradelahi/cloudflare-warp/discussions 6 | about: Ask questions and get help on GitHub Discussions 7 | -------------------------------------------------------------------------------- /internal/version/module.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "runtime/debug" 5 | ) 6 | 7 | // Info returns project dependencies as []*debug.Module. 8 | func Info() []*debug.Module { 9 | bi, _ := debug.ReadBuildInfo() 10 | return bi.Deps 11 | } 12 | -------------------------------------------------------------------------------- /ipscanner/model/ping.go: -------------------------------------------------------------------------------- 1 | package statute 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | type IPingResult interface { 9 | Result() IPInfo 10 | Error() error 11 | fmt.Stringer 12 | } 13 | 14 | type IPing interface { 15 | Ping() IPingResult 16 | PingContext(context.Context) IPingResult 17 | } 18 | -------------------------------------------------------------------------------- /core/config.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "net/netip" 5 | ) 6 | 7 | // Config holds the configuration for the WARP engine. 8 | type Config struct { 9 | SocksBindAddress *netip.AddrPort 10 | HttpBindAddress *netip.AddrPort 11 | Endpoints []string 12 | DnsAddr netip.Addr 13 | Scan *ScanOptions 14 | UserProvidedEndpoint bool 15 | } 16 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | LOGLEVEL="${LOGLEVEL:-info}" 4 | DATA_DIR="${DATA_DIR:-/var/lib/cloudflare-warp}" 5 | 6 | run() { 7 | # execute extra commands 8 | if [ -n "$EXTRA_COMMANDS" ]; then 9 | sh -c "$EXTRA_COMMANDS" 10 | fi 11 | 12 | exec warp run \ 13 | --data-dir "$DATA_DIR" \ 14 | --loglevel "$LOGLEVEL" \ 15 | "$@" 16 | } 17 | 18 | run "$@" || exit 1 19 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | cloudflare-warp: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | # image: ghcr.io/shahradelahi/cloudflare-warp:latest 7 | container_name: cloudflare-warp 8 | restart: unless-stopped 9 | volumes: 10 | - ./warp-data:/var/lib/cloudflare-warp 11 | ports: 12 | - "4000:4000" 13 | command: --socks-addr 0.0.0.0:4000 14 | -------------------------------------------------------------------------------- /log/zap.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "go.uber.org/zap" 5 | ) 6 | 7 | // Must is an alias for zap.Must. 8 | var Must = zap.Must 9 | 10 | // logger aliases for zap.Logger and zap.SugaredLogger. 11 | type ( 12 | Logger = zap.Logger 13 | SugaredLogger = zap.SugaredLogger 14 | ) 15 | 16 | type ( 17 | // Option is an alias for zap.Option. 18 | Option = zap.Option 19 | ) 20 | 21 | // pkgCallerSkip skips the pkg wrapper code as the caller. 22 | var pkgCallerSkip = zap.AddCallerSkip(2) 23 | -------------------------------------------------------------------------------- /cloudflare/model/config.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // RegFile represents the structure of reg.json 4 | type RegFile struct { 5 | RegistrationID string `json:"registration_id"` 6 | Token string `json:"token"` 7 | PrivateKey string `json:"private_key"` // This will be the WireGuard private key 8 | } 9 | 10 | // ConfFile represents the structure of conf.json 11 | type ConfFile struct { 12 | Account IdentityAccount `json:"account"` 13 | Config IdentityConfig `json:"config"` 14 | } 15 | 16 | // SettingsFile represents the structure of settings.json 17 | type SettingsFile struct { 18 | OperationMode string `json:"operation_mode"` 19 | } 20 | -------------------------------------------------------------------------------- /utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/binary" 6 | "fmt" 7 | "math/big" 8 | ) 9 | 10 | func Uint32ToBytes(n uint32) []byte { 11 | b := make([]byte, 4) 12 | binary.LittleEndian.PutUint32(b, n) 13 | return b 14 | } 15 | 16 | func RandomInt(min, max uint64) (uint64, error) { 17 | if min > max { 18 | return 0, fmt.Errorf("min cannot be greater than max") 19 | } 20 | if min == max { 21 | return min, nil 22 | } 23 | rangeVal := max - min + 1 24 | 25 | n, err := rand.Int(rand.Reader, big.NewInt(int64(rangeVal))) 26 | if err != nil { 27 | return 0, err 28 | } 29 | 30 | return min + n.Uint64(), nil 31 | } 32 | -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "strings" 7 | ) 8 | 9 | const Name = "warp" 10 | 11 | var ( 12 | // Version can be set at link time by executing 13 | // the command: `git describe --abbrev=0 --tags HEAD` 14 | Version string 15 | 16 | // GitCommit can be set at link time by executing 17 | // the command: `git rev-parse --short HEAD` 18 | GitCommit string 19 | ) 20 | 21 | func String() string { 22 | return fmt.Sprintf("%s-%s", Name, strings.TrimPrefix(Version, "v")) 23 | } 24 | 25 | func BuildString() string { 26 | return fmt.Sprintf("%s/%s, %s, %s", runtime.GOOS, runtime.GOARCH, runtime.Version(), GitCommit) 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | concurrency: 4 | group: test-${{ github.event_name }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: 10 | - 'main' 11 | pull_request: 12 | 13 | jobs: 14 | build-test: 15 | name: Build Test 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v5 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Setup Go 24 | uses: actions/setup-go@v5 25 | with: 26 | check-latest: true 27 | go-version-file: 'go.mod' 28 | 29 | - name: Run test 30 | run: | 31 | go test -v ./... 32 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | commit-message: 8 | prefix: "fix" 9 | include: "scope" 10 | open-pull-requests-limit: 20 11 | 12 | - package-ecosystem: "docker" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | commit-message: 17 | prefix: "fix" 18 | include: "scope" 19 | open-pull-requests-limit: 20 20 | 21 | - package-ecosystem: "github-actions" 22 | directory: "/" 23 | schedule: 24 | interval: "daily" 25 | commit-message: 26 | prefix: "fix" 27 | include: "scope" 28 | open-pull-requests-limit: 20 29 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Mark stale issues and pull requests 2 | 3 | permissions: 4 | contents: write 5 | issues: write 6 | pull-requests: write 7 | 8 | on: 9 | schedule: 10 | - cron: "0 10 * * *" 11 | 12 | jobs: 13 | stale: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/stale@v9 17 | with: 18 | stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days' 19 | exempt-issue-labels: 'question,bug,enhancement,help wanted' 20 | exempt-pr-labels: 'pending,WIP,help wanted' 21 | days-before-stale: 60 22 | days-before-close: 7 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Go template 2 | # If you prefer the allow list template instead of the deny list, see community template: 3 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 4 | # 5 | # Binaries for programs and plugins 6 | *.exe 7 | *.exe~ 8 | *.dll 9 | *.so 10 | *.dylib 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | 21 | # Go workspace file 22 | go.work 23 | go.work.sum 24 | 25 | # Build directory 26 | build/ 27 | 28 | # IDE 29 | .idea/ 30 | .vscode/ 31 | 32 | # Misc 33 | .DS_Store 34 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | name: Linter 2 | 3 | concurrency: 4 | group: linter-${{ github.event_name }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: 10 | - 'main' 11 | pull_request: 12 | 13 | jobs: 14 | linter: 15 | name: Linter 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v5 20 | 21 | - name: Setup Go 22 | uses: actions/setup-go@v5 23 | with: 24 | check-latest: true 25 | go-version-file: 'go.mod' 26 | 27 | - name: GolangCI lint 28 | uses: golangci/golangci-lint-action@v8 29 | with: 30 | version: latest 31 | args: -v 32 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | run: 4 | timeout: 5m 5 | 6 | linters: 7 | default: none 8 | enable: 9 | - cyclop 10 | - govet 11 | - ineffassign 12 | - misspell 13 | - staticcheck 14 | - unconvert 15 | - unused 16 | - usestdlibvars 17 | 18 | settings: 19 | cyclop: 20 | max-complexity: 20 21 | package-average: 10.0 22 | 23 | exclusions: 24 | rules: 25 | - path: _test\.go$ 26 | linters: 27 | - cyclop 28 | - errcheck 29 | 30 | formatters: 31 | enable: 32 | - gci 33 | - gofmt 34 | settings: 35 | gci: 36 | custom-order: true 37 | sections: 38 | - standard 39 | - default 40 | - prefix(github.com/shahradelahi/cloudflare-warp) 41 | -------------------------------------------------------------------------------- /.github/workflows/qlty-coverage.yml: -------------------------------------------------------------------------------- 1 | name: Qlty Coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | upload-coverage: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v5 16 | 17 | - name: Set up Ruby 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | bundler-cache: true 21 | 22 | - name: Install dependencies 23 | run: bundle install 24 | 25 | - name: Generate coverage report 26 | run: bundle exec rake 27 | env: 28 | COVERAGE: true 29 | ISOLATED: true 30 | 31 | - uses: qltysh/qlty-action/coverage@v2 32 | with: 33 | token: ${{ secrets.QLTY_COVERAGE_TOKEN }} 34 | files: coverage/.resultset.json 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea or improvement 3 | title: "[Feature] " 4 | body: 5 | - type: textarea 6 | id: description 7 | attributes: 8 | label: Description 9 | placeholder: A clear description of the feature or enhancement. 10 | validations: 11 | required: true 12 | 13 | - type: textarea 14 | id: related 15 | attributes: 16 | label: Is this feature related to a specific bug? 17 | description: Please include a bug references if yes. 18 | 19 | - type: textarea 20 | id: solution 21 | attributes: 22 | label: Do you have a specific solution in mind? 23 | description: > 24 | Please include any details about a solution that you have in mind, 25 | including any alternatives considered. 26 | -------------------------------------------------------------------------------- /log/level.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "go.uber.org/zap/zapcore" 5 | ) 6 | 7 | // Level is an alias for zapcore.Level. 8 | type Level = zapcore.Level 9 | 10 | // Levels are aliases for Level. 11 | const ( 12 | DebugLevel = zapcore.DebugLevel 13 | InfoLevel = zapcore.InfoLevel 14 | WarnLevel = zapcore.WarnLevel 15 | ErrorLevel = zapcore.ErrorLevel 16 | DPanicLevel = zapcore.DPanicLevel 17 | PanicLevel = zapcore.PanicLevel 18 | FatalLevel = zapcore.FatalLevel 19 | InvalidLevel = zapcore.InvalidLevel 20 | SilentLevel = InvalidLevel + 1 21 | ) 22 | 23 | // ParseLevel is a thin wrapper for zapcore.ParseLevel. 24 | func ParseLevel(text string) (Level, error) { 25 | switch text { 26 | case "silent", "SILENT": 27 | return SilentLevel, nil 28 | default: 29 | return zapcore.ParseLevel(text) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ipscanner/model/queue.go: -------------------------------------------------------------------------------- 1 | package statute 2 | 3 | import ( 4 | "sort" 5 | "time" 6 | ) 7 | 8 | type IPInfQueue struct { 9 | items []IPInfo 10 | } 11 | 12 | // Enqueue adds an item and then sorts the queue. 13 | func (q *IPInfQueue) Enqueue(item IPInfo) { 14 | q.items = append(q.items, item) 15 | sort.Slice(q.items, func(i, j int) bool { 16 | return q.items[i].RTT < q.items[j].RTT 17 | }) 18 | } 19 | 20 | // Dequeue removes and returns the item with the lowest RTT. 21 | func (q *IPInfQueue) Dequeue() IPInfo { 22 | if len(q.items) == 0 { 23 | return IPInfo{} // Returning an empty IPInfo when the queue is empty. 24 | } 25 | item := q.items[0] 26 | q.items = q.items[1:] 27 | item.CreatedAt = time.Now() 28 | return item 29 | } 30 | 31 | // Size returns the number of items in the queue. 32 | func (q *IPInfQueue) Size() int { 33 | return len(q.items) 34 | } 35 | -------------------------------------------------------------------------------- /cloudflare/model/account.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type IdentityAccount struct { 4 | Created string `json:"created"` 5 | Updated string `json:"updated"` 6 | License string `json:"license"` 7 | PremiumData int64 `json:"premium_data"` 8 | WarpPlus bool `json:"warp_plus"` 9 | AccountType string `json:"account_type"` 10 | ReferralRenewalCountdown int64 `json:"referral_renewal_countdown"` 11 | Role string `json:"role"` 12 | ID string `json:"id"` 13 | Quota int64 `json:"quota"` 14 | Usage int64 `json:"usage"` 15 | ReferralCount int64 `json:"referral_count"` 16 | TTL string `json:"ttl"` 17 | } 18 | 19 | type License struct { 20 | License string `json:"license"` 21 | } 22 | -------------------------------------------------------------------------------- /core/datadir/datadir.go: -------------------------------------------------------------------------------- 1 | package datadir 2 | 3 | import ( 4 | "os" 5 | "path" 6 | ) 7 | 8 | var dataDir string 9 | 10 | // SetDataDir sets the data directory path. 11 | func SetDataDir(dir string) { 12 | if _, err := os.Stat(dir); os.IsNotExist(err) { 13 | if err := os.MkdirAll(dir, 0660); err != nil { 14 | panic(err) 15 | } 16 | } 17 | 18 | dataDir = dir 19 | } 20 | 21 | // GetDataDir returns the data directory path. 22 | func GetDataDir() string { 23 | return dataDir 24 | } 25 | 26 | // GetDataDirOrPath determines the data directory path. 27 | // It checks the provided dataDir, then the HOME environment variable, 28 | // and finally defaults to ".cloudflare-warp". 29 | func GetDataDirOrPath(dir string) string { 30 | switch { 31 | case dir != "": 32 | return dir 33 | case os.Getenv("HOME") != "": 34 | return path.Join(os.Getenv("HOME"), ".cloudflare-warp") 35 | default: 36 | return ".cloudflare-warp" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ipscanner/ping/ping.go: -------------------------------------------------------------------------------- 1 | package ping 2 | 3 | import ( 4 | "context" 5 | "net/netip" 6 | 7 | "github.com/shahradelahi/cloudflare-warp/ipscanner/model" 8 | ) 9 | 10 | type Ping struct { 11 | options *statute.ScannerOptions 12 | } 13 | 14 | // NewPinger creates a new Ping instance. 15 | func NewPinger(opts *statute.ScannerOptions) *Ping { 16 | return &Ping{ 17 | options: opts, 18 | } 19 | } 20 | 21 | // DoPing performs a ping on the given IP address. 22 | func (p *Ping) DoPing(ctx context.Context, ip netip.Addr) (statute.IPInfo, error) { 23 | res, err := p.calc(ctx, NewWarpPing(ip, p.options)) 24 | if err != nil { 25 | return statute.IPInfo{}, err 26 | } 27 | 28 | return res, nil 29 | } 30 | 31 | func (p *Ping) calc(ctx context.Context, tp statute.IPing) (statute.IPInfo, error) { 32 | pr := tp.PingContext(ctx) 33 | err := pr.Error() 34 | if err != nil { 35 | return statute.IPInfo{}, err 36 | } 37 | return pr.Result(), nil 38 | } 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ALPINE_VERSION=latest 2 | 3 | FROM --platform=$BUILDPLATFORM alpine:${ALPINE_VERSION} AS alpine 4 | ENV TZ=Etc/UTC 5 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ >/etc/timezone 6 | RUN apk update \ 7 | && apk add -U --no-cache \ 8 | ca-certificates \ 9 | && rm -rf /var/cache/apk/* 10 | 11 | FROM golang:alpine AS builder 12 | 13 | WORKDIR /src 14 | COPY . /src 15 | 16 | ENV GOCACHE=/gocache 17 | RUN --mount=type=cache,target="/gocache" apk add --update --no-cache make git \ 18 | && make cloudflare-warp 19 | 20 | FROM alpine 21 | LABEL org.opencontainers.image.source="https://github.com/shahradelahi/cloudflare-warp" 22 | 23 | # Create and set permissions for the data directory 24 | RUN mkdir -p /var/lib/cloudflare-warp 25 | 26 | COPY docker/entrypoint.sh /entrypoint.sh 27 | COPY --from=builder /src/build/warp /usr/bin/warp 28 | 29 | RUN apk add --update --no-cache iptables iproute2 tzdata \ 30 | && chmod +x /entrypoint.sh 31 | 32 | ENTRYPOINT ["/entrypoint.sh"] 33 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | concurrency: 4 | group: codeql-${{ github.event_name }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: [ main ] 10 | pull_request: 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | language: [ 'go' ] 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v5 25 | 26 | - name: Setup Go 27 | uses: actions/setup-go@v5 28 | with: 29 | check-latest: true 30 | go-version-file: 'go.mod' 31 | 32 | - name: Initialize CodeQL 33 | uses: github/codeql-action/init@v3 34 | with: 35 | languages: ${{ matrix.language }} 36 | 37 | - name: Autobuild 38 | uses: github/codeql-action/autobuild@v3 39 | 40 | - name: Perform CodeQL Analysis 41 | uses: github/codeql-action/analyze@v3 42 | -------------------------------------------------------------------------------- /cmd/generate.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | "go.uber.org/zap" 8 | 9 | "github.com/shahradelahi/cloudflare-warp/cloudflare" 10 | "github.com/shahradelahi/cloudflare-warp/core" 11 | "github.com/shahradelahi/cloudflare-warp/log" 12 | ) 13 | 14 | var GenerateCmd = &cobra.Command{ 15 | Use: "generate", 16 | Short: "Generates and prints the WireGuard configuration", 17 | Long: `This command generates and prints the WireGuard configuration based on your WARP identity. The output can be redirected to a file to create a WireGuard configuration file.`, 18 | Run: generate, 19 | } 20 | 21 | func init() {} 22 | 23 | func generate(cmd *cobra.Command, args []string) { 24 | ident, err := cloudflare.LoadOrCreateIdentity() 25 | if err != nil { 26 | log.Fatalw("Failed to generate primary identity", zap.Error(err)) 27 | } 28 | 29 | wgConf := core.GenerateWireguardConfig(ident) 30 | 31 | confStr, err := wgConf.String() 32 | if err != nil { 33 | log.Fatalw("Failed to generate WireGuard configuration", zap.Error(err)) 34 | } 35 | 36 | fmt.Println(confStr) 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Shahrad Elahi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cloudflare/model/utils.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/shahradelahi/cloudflare-warp/core/datadir" 9 | ) 10 | 11 | func GetRegPath() string { 12 | return filepath.Join(datadir.GetDataDir(), "reg.json") 13 | } 14 | 15 | func GetConfPath() string { 16 | return filepath.Join(datadir.GetDataDir(), "conf.json") 17 | } 18 | 19 | func (a *Identity) SaveIdentity() error { 20 | 21 | regPath := GetRegPath() 22 | confPath := GetConfPath() 23 | 24 | // Save reg.json 25 | regFileContent := RegFile{ 26 | RegistrationID: a.ID, 27 | Token: a.Token, 28 | PrivateKey: a.PrivateKey, 29 | } 30 | regData, err := json.MarshalIndent(regFileContent, "", " ") 31 | if err != nil { 32 | return err 33 | } 34 | if err := os.WriteFile(regPath, regData, 0600); err != nil { 35 | return err 36 | } 37 | 38 | // Save conf.json 39 | confFileContent := ConfFile{ 40 | Account: a.Account, 41 | Config: a.Config, 42 | } 43 | confData, err := json.MarshalIndent(confFileContent, "", " ") 44 | if err != nil { 45 | return err 46 | } 47 | 48 | return os.WriteFile(confPath, confData, 0600) 49 | } 50 | -------------------------------------------------------------------------------- /cmd/status.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/shahradelahi/cloudflare-warp/cloudflare" 12 | ) 13 | 14 | var statusShortMsg = "Prints the status of the current Cloudflare Warp device" 15 | 16 | var StatusCmd = &cobra.Command{ 17 | Use: "status", 18 | Short: statusShortMsg, 19 | Long: FormatMessage(statusShortMsg, ``), 20 | Run: func(cmd *cobra.Command, args []string) { 21 | if err := status(); err != nil { 22 | log.Fatal(err) 23 | } 24 | }, 25 | } 26 | 27 | func status() error { 28 | identity, err := cloudflare.LoadIdentity() 29 | if err != nil { 30 | if os.IsNotExist(err) || errors.Is(err, errors.New("identity contains 0 peers")) { 31 | return fmt.Errorf("WARP identity not found. Please run 'warp generate' to create one") 32 | } 33 | return err 34 | } 35 | 36 | warpAPI := cloudflare.NewWarpAPI() 37 | 38 | thisDevice, err := warpAPI.GetSourceDevice(identity.Token, identity.ID) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | boundDevice, err := warpAPI.GetSourceBoundDevice(identity.Token, identity.ID) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | PrintDeviceData(&thisDevice, boundDevice) 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /core/wireguard.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "net/netip" 5 | 6 | "github.com/shahradelahi/wiresocks" 7 | 8 | "github.com/shahradelahi/cloudflare-warp/cloudflare/model" 9 | ) 10 | 11 | func GenerateWireguardConfig(i *model.Identity) wiresocks.Configuration { 12 | priv, _ := wiresocks.EncodeBase64ToHex(i.PrivateKey) 13 | pub, _ := wiresocks.EncodeBase64ToHex(i.Config.Peers[0].PublicKey) 14 | 15 | var dnsAddrs []netip.Addr 16 | for _, dns := range []string{"1.1.1.1", "1.0.0.1", "2606:4700:4700::1112", "2606:4700:4700::1112"} { 17 | addr := netip.MustParseAddr(dns) 18 | dnsAddrs = append(dnsAddrs, addr) 19 | } 20 | 21 | return wiresocks.Configuration{ 22 | Interface: &wiresocks.InterfaceConfig{ 23 | PrivateKey: priv, 24 | Addresses: []netip.Prefix{ 25 | wiresocks.MustParsePrefixOrAddr(i.Config.Interface.Addresses.V4), 26 | wiresocks.MustParsePrefixOrAddr(i.Config.Interface.Addresses.V6), 27 | }, 28 | MTU: 1280, 29 | DNS: dnsAddrs, 30 | }, 31 | Peers: []wiresocks.PeerConfig{{ 32 | PublicKey: pub, 33 | PreSharedKey: "0000000000000000000000000000000000000000000000000000000000000000", 34 | AllowedIPs: []netip.Prefix{ 35 | netip.MustParsePrefix("0.0.0.0/0"), 36 | netip.MustParsePrefix("::/0"), 37 | }, 38 | KeepAlive: 25, 39 | Endpoint: i.Config.Peers[0].Endpoint.Host, 40 | }}, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /log/sugar.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | func logf(lvl Level, template string, args ...interface{}) { 4 | _globalMu.RLock() 5 | s := _globalS 6 | _globalMu.RUnlock() 7 | s.Logf(lvl, template, args...) 8 | } 9 | 10 | func Debugf(template string, args ...interface{}) { 11 | logf(DebugLevel, template, args...) 12 | } 13 | 14 | func Infof(template string, args ...interface{}) { 15 | logf(InfoLevel, template, args...) 16 | } 17 | 18 | func Warnf(template string, args ...interface{}) { 19 | logf(WarnLevel, template, args...) 20 | } 21 | 22 | func Errorf(template string, args ...interface{}) { 23 | logf(ErrorLevel, template, args...) 24 | } 25 | 26 | func Fatalf(template string, args ...interface{}) { 27 | logf(FatalLevel, template, args...) 28 | } 29 | 30 | func logw(lvl Level, msg string, keysAndValues ...interface{}) { 31 | _globalMu.RLock() 32 | s := _globalS 33 | _globalMu.RUnlock() 34 | s.Logw(lvl, msg, keysAndValues...) 35 | } 36 | 37 | func Debugw(msg string, keysAndValues ...interface{}) { 38 | logw(DebugLevel, msg, keysAndValues...) 39 | } 40 | 41 | func Infow(msg string, keysAndValues ...interface{}) { 42 | logw(InfoLevel, msg, keysAndValues...) 43 | } 44 | 45 | func Warnw(msg string, keysAndValues ...interface{}) { 46 | logw(WarnLevel, msg, keysAndValues...) 47 | } 48 | 49 | func Errorw(msg string, keysAndValues ...interface{}) { 50 | logw(ErrorLevel, msg, keysAndValues...) 51 | } 52 | 53 | func Fatalw(msg string, keysAndValues ...interface{}) { 54 | logw(FatalLevel, msg, keysAndValues...) 55 | } 56 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us improve 3 | title: "[Bug] " 4 | body: 5 | - type: checkboxes 6 | id: ensure 7 | attributes: 8 | label: Verify steps 9 | description: Please verify that you've followed these steps 10 | options: 11 | - label: Is this something you can **debug and fix**? Send a pull request! Bug fixes and documentation fixes are welcome. 12 | required: true 13 | 14 | - label: I have searched on the [issue tracker](……/) for a related issue. 15 | required: true 16 | 17 | - type: input 18 | attributes: 19 | label: Version 20 | validations: 21 | required: true 22 | 23 | - type: dropdown 24 | id: os 25 | attributes: 26 | label: What OS are you seeing the problem on? 27 | multiple: true 28 | options: 29 | - Windows 30 | - Linux 31 | - macOS 32 | - OpenBSD/FreeBSD 33 | - Other 34 | 35 | - type: textarea 36 | attributes: 37 | label: Description 38 | validations: 39 | required: true 40 | 41 | - type: textarea 42 | attributes: 43 | label: CLI or Config 44 | description: Paste the command line parameters or configuration below. 45 | 46 | - type: textarea 47 | attributes: 48 | render: shell 49 | label: Logs 50 | description: Paste the logs below with the log level set to `DEBUG`. 51 | 52 | - type: textarea 53 | attributes: 54 | label: How to Reproduce 55 | description: Steps to reproduce the behavior, if any. 56 | -------------------------------------------------------------------------------- /cloudflare/network/endpoint_test.go: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestRandomScannerPrefix(t *testing.T) { 10 | // Test IPv4 11 | prefixV4 := RandomScannerPrefix(true, false) 12 | assert.True(t, prefixV4.Addr().Is4(), "Expected an IPv4 prefix") 13 | assert.False(t, prefixV4.Addr().Is6(), "Expected an IPv4 prefix, but got IPv6") 14 | 15 | // Test IPv6 16 | prefixV6 := RandomScannerPrefix(false, true) 17 | assert.True(t, prefixV6.Addr().Is6(), "Expected an IPv6 prefix") 18 | assert.False(t, prefixV6.Addr().Is4(), "Expected an IPv6 prefix, but got IPv4") 19 | 20 | // Test both 21 | prefixBoth := RandomScannerPrefix(true, true) 22 | assert.True(t, prefixBoth.Addr().Is4() || prefixBoth.Addr().Is6(), "Expected either an IPv4 or IPv6 prefix") 23 | } 24 | 25 | func TestRandomScannerPort(t *testing.T) { 26 | port := RandomScannerPort() 27 | assert.Contains(t, ScannerPorts(), port, "RandomScannerPort should return a port from the ScannerPorts list") 28 | } 29 | 30 | func TestRandomScannerEndpoint(t *testing.T) { 31 | // Test IPv4 32 | endpointV4, err := RandomScannerEndpoint(true, false) 33 | assert.NoError(t, err) 34 | assert.True(t, endpointV4.Addr().Is4(), "Expected an IPv4 endpoint") 35 | assert.Contains(t, ScannerPorts(), endpointV4.Port(), "Endpoint port should be in the ScannerPorts list") 36 | 37 | // Test IPv6 38 | endpointV6, err := RandomScannerEndpoint(false, true) 39 | assert.NoError(t, err) 40 | assert.True(t, endpointV6.Addr().Is6(), "Expected an IPv6 endpoint") 41 | assert.Contains(t, ScannerPorts(), endpointV6.Port(), "Endpoint port should be in the ScannerPorts list") 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Go Releases 2 | 3 | concurrency: 4 | group: release-${{ github.event_name }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | tags: 10 | - '*' 11 | 12 | jobs: 13 | release: 14 | name: Release 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v5 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v5 24 | with: 25 | check-latest: true 26 | go-version-file: 'go.mod' 27 | 28 | - name: Cache go module 29 | uses: actions/cache@v4 30 | with: 31 | path: | 32 | ~/go/pkg/mod 33 | ~/.cache/go-build 34 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 35 | restore-keys: | 36 | ${{ runner.os }}-go- 37 | 38 | - name: Set prerelease flag 39 | if: startsWith(github.ref, 'refs/tags/') 40 | id: pre 41 | run: | 42 | TAG="$GITHUB_REF_NAME" 43 | if [[ "$TAG" =~ -(beta|alpha|rc) ]]; then 44 | echo "is_prerelease=true" >> $GITHUB_OUTPUT 45 | else 46 | echo "is_prerelease=false" >> $GITHUB_OUTPUT 47 | fi 48 | 49 | - name: Build 50 | if: startsWith(github.ref, 'refs/tags/') 51 | run: make -j releases 52 | 53 | - name: Upload Releases 54 | uses: softprops/action-gh-release@v2 55 | if: startsWith(github.ref, 'refs/tags/') 56 | with: 57 | files: build/* 58 | draft: true 59 | prerelease: ${{ steps.pre.outputs.is_prerelease }} 60 | -------------------------------------------------------------------------------- /cmd/shared.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math" 7 | "strings" 8 | 9 | "github.com/shahradelahi/cloudflare-warp/cloudflare/model" 10 | ) 11 | 12 | func FormatMessage(shortMessage string, longMessage string) string { 13 | if longMessage != "" { 14 | longMessage = strings.TrimPrefix(longMessage, "\n") 15 | longMessage = strings.ReplaceAll(longMessage, "\n", " ") 16 | 17 | } 18 | if shortMessage != "" && longMessage != "" { 19 | return shortMessage + ". " + longMessage 20 | } else if shortMessage != "" { 21 | return shortMessage 22 | } else { 23 | return longMessage 24 | } 25 | } 26 | 27 | func F32ToHumanReadable(number float32) string { 28 | for i := 8; i >= 0; i-- { 29 | humanReadable := number / float32(math.Pow(1024, float64(i))) 30 | if humanReadable >= 1 && humanReadable < 1024 { 31 | return fmt.Sprintf("%.2f %ciB", humanReadable, "KMGTPEZY"[i-1]) 32 | } 33 | } 34 | return fmt.Sprintf("%.2f B", number) 35 | } 36 | 37 | func PrintDeviceData(thisDevice *model.Identity, boundDevice *model.IdentityDevice) { 38 | log.Println("=======================================") 39 | log.Printf("% -13s : %s\n", "Device name", boundDevice.Name) 40 | log.Printf("% -13s : %s\n", "Device model", thisDevice.Model) 41 | log.Printf("% -13s : %t\n", "Device active", boundDevice.Active) 42 | log.Printf("% -13s : %s\n", "Account type", thisDevice.Account.AccountType) 43 | log.Printf("% -13s : %s\n", "Role", thisDevice.Account.Role) 44 | log.Printf("% -13s : %s\n", "Premium data", F32ToHumanReadable(float32(thisDevice.Account.PremiumData))) 45 | log.Printf("% -13s : %s\n", "Quota", F32ToHumanReadable(float32(thisDevice.Account.Quota))) 46 | log.Println("=======================================") 47 | } 48 | -------------------------------------------------------------------------------- /log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "go.uber.org/zap" 8 | ) 9 | 10 | // global Logger and SugaredLogger. 11 | var ( 12 | _globalMu sync.RWMutex 13 | _globalL *Logger 14 | _globalS *SugaredLogger 15 | ) 16 | 17 | func init() { 18 | SetLogger(zap.Must(zap.NewProduction())) 19 | } 20 | 21 | func NewLeveled(l Level, options ...Option) (*Logger, error) { 22 | switch l { 23 | case SilentLevel: 24 | return zap.NewNop(), nil 25 | case DebugLevel: 26 | return zap.NewDevelopment(options...) 27 | case InfoLevel, WarnLevel, ErrorLevel, DPanicLevel, PanicLevel, FatalLevel: 28 | cfg := zap.NewProductionConfig() 29 | cfg.Level.SetLevel(l) 30 | return cfg.Build(options...) 31 | default: 32 | return nil, fmt.Errorf("invalid level: %s", l) 33 | } 34 | } 35 | 36 | // SetLogger sets the global Logger and SugaredLogger. 37 | func SetLogger(logger *Logger) { 38 | _globalMu.Lock() 39 | defer _globalMu.Unlock() 40 | // apply pkgCallerSkip to global loggers. 41 | _globalL = logger.WithOptions(pkgCallerSkip) 42 | _globalS = _globalL.Sugar() 43 | } 44 | 45 | // GetLogger returns the global logger. 46 | func GetLogger() *Logger { 47 | _globalMu.RLock() 48 | defer _globalMu.RUnlock() 49 | return _globalL 50 | } 51 | 52 | func log(lvl Level, args ...interface{}) { 53 | _globalMu.RLock() 54 | s := _globalS 55 | _globalMu.RUnlock() 56 | s.Log(lvl, args...) 57 | } 58 | 59 | func Debug(args ...interface{}) { 60 | log(DebugLevel, args...) 61 | } 62 | 63 | func Info(args ...interface{}) { 64 | log(InfoLevel, args...) 65 | } 66 | 67 | func Warn(args ...interface{}) { 68 | log(WarnLevel, args...) 69 | } 70 | 71 | func Error(args ...interface{}) { 72 | log(ErrorLevel, args...) 73 | } 74 | 75 | func Fatal(args ...interface{}) { 76 | log(FatalLevel, args...) 77 | } 78 | -------------------------------------------------------------------------------- /cloudflare/crypto/key_test.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGeneratePrivateKey(t *testing.T) { 10 | privateKey, err := GeneratePrivateKey() 11 | assert.NoError(t, err, "GeneratePrivateKey should not return an error") 12 | assert.NotNil(t, privateKey, "Generated private key should not be nil") 13 | 14 | // Check key length 15 | assert.Equal(t, KeyLen, len(privateKey), "Private key should have the correct length") 16 | 17 | // Check if the key is properly clamped, according to Curve25519 spec. 18 | // https://cr.yp.to/ecdh.html 19 | assert.Equal(t, byte(0), privateKey[0]&0b111, "The 3 least significant bits of the first byte should be 0") 20 | assert.Equal(t, byte(0), privateKey[31]&0b10000000, "The most significant bit of the last byte should be 0") 21 | assert.NotEqual(t, byte(0), privateKey[31]&0b01000000, "The second most significant bit of the last byte should be 1") 22 | } 23 | 24 | func TestPublicKey(t *testing.T) { 25 | privateKey, err := GeneratePrivateKey() 26 | assert.NoError(t, err) 27 | 28 | publicKey := privateKey.PublicKey() 29 | assert.NotNil(t, publicKey, "Generated public key should not be nil") 30 | 31 | // Check key length 32 | assert.Equal(t, KeyLen, len(publicKey), "Public key should have the correct length") 33 | 34 | // A public key should not be equal to its private key 35 | assert.NotEqual(t, privateKey, publicKey, "Public key should be different from the private key") 36 | } 37 | 38 | func TestNewKey(t *testing.T) { 39 | // Test with a valid key 40 | validBytes := make([]byte, KeyLen) 41 | _, err := NewKey(validBytes) 42 | assert.NoError(t, err, "NewKey should not return an error for a valid key") 43 | 44 | // Test with an invalid key (wrong length) 45 | invalidBytes := make([]byte, KeyLen-1) 46 | _, err = NewKey(invalidBytes) 47 | assert.Error(t, err, "NewKey should return an error for a key with incorrect length") 48 | } 49 | -------------------------------------------------------------------------------- /ipscanner/model/statute.go: -------------------------------------------------------------------------------- 1 | package statute 2 | 3 | import ( 4 | "net/netip" 5 | "time" 6 | 7 | "github.com/shahradelahi/cloudflare-warp/core/cache" 8 | ) 9 | 10 | type EndpointEventHandler interface { 11 | IncrementFailure(address string) 12 | } 13 | 14 | type IPInfo struct { 15 | AddrPort netip.AddrPort 16 | RTT time.Duration 17 | CreatedAt time.Time 18 | } 19 | 20 | type ScannerOptions struct { 21 | UseIPv4 bool 22 | UseIPv6 bool 23 | CidrList []netip.Prefix // CIDR ranges to scan 24 | WarpPrivateKey string 25 | WarpPeerPublicKey string 26 | WarpPresharedKey string 27 | IPQueueSize int 28 | IPQueueTTL time.Duration 29 | MaxDesirableRTT time.Duration 30 | EventsHandler EndpointEventHandler 31 | Cache *cache.Cache 32 | Obfuscation bool 33 | } 34 | 35 | func DefaultCFRanges() []netip.Prefix { 36 | return []netip.Prefix{ 37 | netip.MustParsePrefix("173.245.48.0/20"), 38 | netip.MustParsePrefix("103.21.244.0/22"), 39 | netip.MustParsePrefix("103.22.200.0/22"), 40 | netip.MustParsePrefix("103.31.4.0/22"), 41 | netip.MustParsePrefix("141.101.64.0/18"), 42 | netip.MustParsePrefix("108.162.192.0/18"), 43 | netip.MustParsePrefix("190.93.240.0/20"), 44 | netip.MustParsePrefix("188.114.96.0/20"), 45 | netip.MustParsePrefix("197.234.240.0/22"), 46 | netip.MustParsePrefix("198.41.128.0/17"), 47 | netip.MustParsePrefix("162.158.0.0/15"), 48 | netip.MustParsePrefix("104.16.0.0/13"), 49 | netip.MustParsePrefix("104.24.0.0/14"), 50 | netip.MustParsePrefix("172.64.0.0/13"), 51 | netip.MustParsePrefix("131.0.72.0/22"), 52 | netip.MustParsePrefix("2400:cb00::/32"), 53 | netip.MustParsePrefix("2606:4700::/32"), 54 | netip.MustParsePrefix("2803:f800::/32"), 55 | netip.MustParsePrefix("2405:b500::/32"), 56 | netip.MustParsePrefix("2405:8100::/32"), 57 | netip.MustParsePrefix("2a06:98c0::/29"), 58 | netip.MustParsePrefix("2c0f:f248::/32"), 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /cloudflare/identity_test.go: -------------------------------------------------------------------------------- 1 | package cloudflare 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/shahradelahi/cloudflare-warp/cloudflare/model" 10 | "github.com/shahradelahi/cloudflare-warp/core/datadir" 11 | ) 12 | 13 | func TestLoadOrCreateIdentity(t *testing.T) { 14 | // Skip this test in CI environments as it requires a live connection 15 | // and might be flaky. 16 | if os.Getenv("CI") != "" { 17 | t.Skip("Skipping test that requires live API connection in CI environment") 18 | } 19 | 20 | // Create a temporary directory for testing 21 | tempDir, err := os.MkdirTemp("", "test-datadir") 22 | assert.NoError(t, err) 23 | defer os.RemoveAll(tempDir) 24 | 25 | datadir.SetDataDir(tempDir) 26 | 27 | // Test creating a new identity with the provided license key 28 | // Using a real license key makes this an integration test. 29 | licenseKey := "5m3o6Qq4-495D2Kpk-egrG8326" 30 | identity, err := CreateOrUpdateIdentity(licenseKey) 31 | assert.NoError(t, err) 32 | assert.NotNil(t, identity) 33 | assert.NotEmpty(t, identity.ID, "Identity ID should not be empty") 34 | assert.NotEmpty(t, identity.Token, "Identity Token should not be empty") 35 | assert.Equal(t, licenseKey, identity.Account.License) 36 | assert.True(t, identity.Account.WarpPlus, "WarpPlus should be enabled with a valid license") 37 | 38 | // Verify that the configuration files were created 39 | regPath := model.GetRegPath() 40 | confPath := model.GetConfPath() 41 | 42 | _, err = os.Stat(regPath) 43 | assert.NoError(t, err, "reg.json should be created") 44 | _, err = os.Stat(confPath) 45 | assert.NoError(t, err, "conf.json should be created") 46 | 47 | // Test loading the created identity from the files 48 | loadedIdentity, err := LoadIdentity() 49 | assert.NoError(t, err) 50 | assert.NotNil(t, loadedIdentity) 51 | assert.Equal(t, identity.ID, loadedIdentity.ID) 52 | assert.Equal(t, identity.Token, loadedIdentity.Token) 53 | assert.Equal(t, identity.Account.License, loadedIdentity.Account.License) 54 | } 55 | -------------------------------------------------------------------------------- /core/scanner.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "go.uber.org/zap" 9 | 10 | "github.com/shahradelahi/cloudflare-warp/cloudflare/network" 11 | "github.com/shahradelahi/cloudflare-warp/ipscanner" 12 | "github.com/shahradelahi/cloudflare-warp/log" 13 | ) 14 | 15 | type ScanOptions struct { 16 | V4 bool 17 | V6 bool 18 | MaxRTT time.Duration 19 | PrivateKey string 20 | PublicKey string 21 | } 22 | 23 | func RunScan(ctx context.Context, opts ScanOptions) (result []ipscanner.IPInfo, err error) { 24 | ctx, cancel := context.WithTimeout(ctx, 1*time.Minute) 25 | defer cancel() 26 | 27 | scanner := ipscanner.NewScanner( 28 | ipscanner.WithWarpPrivateKey(opts.PrivateKey), 29 | ipscanner.WithWarpPeerPublicKey(opts.PublicKey), 30 | ipscanner.WithUseIPv4(opts.V4), 31 | ipscanner.WithUseIPv6(opts.V6), 32 | ipscanner.WithMaxDesirableRTT(opts.MaxRTT), 33 | ipscanner.WithCidrList(network.ScannerPrefixes()), 34 | ) 35 | 36 | go func() { 37 | if err := scanner.Run(); err != nil { 38 | log.Errorw("IP scanner encountered a fatal error during execution", zap.Error(err)) 39 | } 40 | }() 41 | defer scanner.Stop() 42 | 43 | startTime := time.Now() 44 | log.Info("Initiating IP scan process...") 45 | 46 | progressTicker := time.NewTicker(5 * time.Second) 47 | defer progressTicker.Stop() 48 | 49 | checkTicker := time.NewTicker(250 * time.Millisecond) 50 | defer checkTicker.Stop() 51 | 52 | for { 53 | select { 54 | case <-ctx.Done(): 55 | return nil, errors.New("scan canceled or timed out") 56 | case <-progressTicker.C: 57 | elapsed := time.Since(startTime).Round(time.Second) 58 | log.Infow("IP scan in progress", zap.Duration("elapsed_time", elapsed)) 59 | case <-checkTicker.C: 60 | ipList := scanner.GetAvailableIPs() 61 | if len(ipList) > 1 { 62 | result = ipList[:2] 63 | elapsed := time.Since(startTime).Round(time.Second) 64 | log.Infow("IP scan completed successfully", zap.Int("endpoints_found", len(result)), zap.Duration("duration", elapsed)) 65 | log.Debugw("Found endpoints", "endpoints", result) 66 | return result, nil 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/shahradelahi/cloudflare-warp 2 | 3 | go 1.24.4 4 | 5 | toolchain go1.24.6 6 | 7 | require ( 8 | github.com/avast/retry-go v3.0.0+incompatible 9 | github.com/fatih/color v1.18.0 10 | github.com/flynn/noise v1.1.0 11 | github.com/go-ini/ini v1.67.0 12 | github.com/noql-net/certpool v0.0.0-20250713011742-73291c48ecc1 13 | github.com/refraction-networking/utls v1.8.0 14 | github.com/rodaine/table v1.3.0 15 | github.com/sagernet/sing v0.7.5 16 | github.com/spf13/cobra v1.9.1 17 | github.com/spf13/viper v1.20.1 18 | github.com/stretchr/testify v1.10.0 19 | github.com/tevino/abool v1.2.0 20 | go.uber.org/atomic v1.11.0 21 | go.uber.org/zap v1.27.0 22 | golang.org/x/crypto v0.41.0 23 | golang.org/x/net v0.43.0 24 | golang.org/x/sys v0.35.0 25 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 26 | gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c 27 | ) 28 | 29 | require ( 30 | github.com/amnezia-vpn/amneziawg-go v0.2.13 // indirect 31 | github.com/andybalholm/brotli v1.0.6 // indirect 32 | github.com/davecgh/go-spew v1.1.1 // indirect 33 | github.com/fsnotify/fsnotify v1.8.0 // indirect 34 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 35 | github.com/google/btree v1.1.3 // indirect 36 | github.com/google/go-cmp v0.7.0 // indirect 37 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 38 | github.com/klauspost/compress v1.17.4 // indirect 39 | github.com/mattn/go-colorable v0.1.13 // indirect 40 | github.com/mattn/go-isatty v0.0.20 // indirect 41 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 42 | github.com/pmezard/go-difflib v1.0.0 // indirect 43 | github.com/sagikazarmark/locafero v0.7.0 // indirect 44 | github.com/shahradelahi/wiresocks v0.0.0-20250819105937-eada7aea2058 // indirect 45 | github.com/sourcegraph/conc v0.3.0 // indirect 46 | github.com/spf13/afero v1.12.0 // indirect 47 | github.com/spf13/cast v1.7.1 // indirect 48 | github.com/spf13/pflag v1.0.6 // indirect 49 | github.com/subosito/gotenv v1.6.0 // indirect 50 | go.uber.org/multierr v1.10.0 // indirect 51 | golang.org/x/text v0.28.0 // indirect 52 | golang.org/x/time v0.12.0 // indirect 53 | gopkg.in/yaml.v3 v3.0.1 // indirect 54 | ) 55 | -------------------------------------------------------------------------------- /cloudflare/crypto/key.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | "fmt" 7 | 8 | "golang.org/x/crypto/curve25519" 9 | ) 10 | 11 | // KeyLen is the expected key length for a WireGuard key. 12 | const KeyLen = 32 // wgh.KeyLen 13 | 14 | // A Key is a public, private, or pre-shared secret key. The Key constructor 15 | // functions in this package can be used to create Keys suitable for each of 16 | // these applications. 17 | type Key [KeyLen]byte 18 | 19 | // GenerateKey generates a Key suitable for use as a pre-shared secret key from 20 | // a cryptographically safe source. 21 | // 22 | // The output Key should not be used as a private key; use GeneratePrivateKey 23 | // instead. 24 | func GenerateKey() (Key, error) { 25 | b := make([]byte, KeyLen) 26 | if _, err := rand.Read(b); err != nil { 27 | return Key{}, fmt.Errorf("wgtypes: failed to read random bytes: %w", err) 28 | } 29 | 30 | return NewKey(b) 31 | } 32 | 33 | // GeneratePrivateKey generates a Key suitable for use as a private key from a 34 | // cryptographically safe source. 35 | func GeneratePrivateKey() (Key, error) { 36 | key, err := GenerateKey() 37 | if err != nil { 38 | return Key{}, err 39 | } 40 | 41 | // Modify random bytes using algorithm described at: 42 | // https://cr.yp.to/ecdh.html. 43 | key[0] &= 248 44 | key[31] &= 127 45 | key[31] |= 64 46 | 47 | return key, nil 48 | } 49 | 50 | // NewKey creates a Key from an existing byte slice. The byte slice must be 51 | // exactly 32 bytes in length. 52 | func NewKey(b []byte) (Key, error) { 53 | if len(b) != KeyLen { 54 | return Key{}, fmt.Errorf("wgtypes: incorrect key size: %d", len(b)) 55 | } 56 | 57 | var k Key 58 | copy(k[:], b) 59 | 60 | return k, nil 61 | } 62 | 63 | // PublicKey computes a public key from the private key k. 64 | // 65 | // PublicKey should only be called when k is a private key. 66 | func (k Key) PublicKey() Key { 67 | var ( 68 | pub [KeyLen]byte 69 | priv = [KeyLen]byte(k) 70 | ) 71 | 72 | // ScalarBaseMult uses the correct base value per https://cr.yp.to/ecdh.html, 73 | // so no need to specify it. 74 | curve25519.ScalarBaseMult(&pub, &priv) 75 | 76 | return Key(pub) 77 | } 78 | 79 | // String returns the base64-encoded string representation of a Key. 80 | // 81 | // ParseKey can be used to produce a new Key from this string. 82 | func (k Key) String() string { 83 | return base64.StdEncoding.EncodeToString(k[:]) 84 | } 85 | -------------------------------------------------------------------------------- /cloudflare/model/identity.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type IdentityConfigPeerEndpoint struct { 4 | V4 string `json:"v4"` 5 | V6 string `json:"v6"` 6 | Host string `json:"host"` 7 | Ports []uint16 `json:"ports"` 8 | } 9 | 10 | type IdentityConfigPeer struct { 11 | PublicKey string `json:"public_key"` 12 | Endpoint IdentityConfigPeerEndpoint `json:"endpoint"` 13 | } 14 | 15 | type IdentityConfigInterfaceAddresses struct { 16 | V4 string `json:"v4"` 17 | V6 string `json:"v6"` 18 | } 19 | 20 | type IdentityConfigInterface struct { 21 | Addresses IdentityConfigInterfaceAddresses `json:"addresses"` 22 | } 23 | type IdentityConfigServices struct { 24 | HTTPProxy string `json:"http_proxy"` 25 | } 26 | 27 | type IdentityConfig struct { 28 | Peers []IdentityConfigPeer `json:"peers"` 29 | Interface IdentityConfigInterface `json:"interface"` 30 | Services IdentityConfigServices `json:"services"` 31 | ClientID string `json:"client_id"` 32 | } 33 | 34 | type Identity struct { 35 | Version string `json:"version,omitempty"` 36 | PrivateKey string `json:"private_key"` 37 | Key string `json:"key"` 38 | Account IdentityAccount `json:"account"` 39 | Place int64 `json:"place"` 40 | FCMToken string `json:"fcm_token"` 41 | Name string `json:"name"` 42 | TOS string `json:"tos"` 43 | Locale string `json:"locale"` 44 | InstallID string `json:"install_id"` 45 | WarpEnabled bool `json:"warp_enabled"` 46 | Type string `json:"type"` 47 | Model string `json:"model"` 48 | Config IdentityConfig `json:"config"` 49 | Token string `json:"token"` 50 | Enabled bool `json:"enabled"` 51 | ID string `json:"id"` 52 | Created string `json:"created"` 53 | Updated string `json:"updated"` 54 | WaitlistEnabled bool `json:"waitlist_enabled"` 55 | } 56 | 57 | type IdentityDevice struct { 58 | ID string `json:"id"` 59 | Name string `json:"name"` 60 | Type string `json:"type"` 61 | Model string `json:"model"` 62 | Created string `json:"created"` 63 | Activated string `json:"updated"` 64 | Active bool `json:"active"` 65 | Role string `json:"role"` 66 | } 67 | -------------------------------------------------------------------------------- /cloudflare/network/endpoint.go: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import ( 4 | "math/rand" 5 | "net/netip" 6 | "time" 7 | 8 | "github.com/shahradelahi/cloudflare-warp/utils" 9 | ) 10 | 11 | func ScannerPrefixes() []netip.Prefix { 12 | return []netip.Prefix{ 13 | netip.MustParsePrefix("162.159.192.0/24"), 14 | netip.MustParsePrefix("162.159.195.0/24"), 15 | netip.MustParsePrefix("188.114.96.0/24"), 16 | netip.MustParsePrefix("188.114.97.0/24"), 17 | netip.MustParsePrefix("188.114.98.0/24"), 18 | netip.MustParsePrefix("188.114.99.0/24"), 19 | netip.MustParsePrefix("2606:4700:d0::/48"), 20 | netip.MustParsePrefix("2606:4700:d1::/48"), 21 | } 22 | } 23 | 24 | func RandomScannerPrefix(v4, v6 bool) netip.Prefix { 25 | if !v4 && !v6 { 26 | panic("Must choose a IP version for RandomScannerPrefix") 27 | } 28 | 29 | cidrs := ScannerPrefixes() 30 | rng := rand.New(rand.NewSource(time.Now().UnixNano())) 31 | for { 32 | cidr := cidrs[rng.Intn(len(cidrs))] 33 | 34 | if v4 && cidr.Addr().Is4() { 35 | return cidr 36 | } 37 | 38 | if v6 && cidr.Addr().Is6() { 39 | return cidr 40 | } 41 | } 42 | } 43 | 44 | func ScannerPorts() []uint16 { 45 | return []uint16{ 46 | 443, 47 | 500, 48 | 854, 49 | 859, 50 | 864, 51 | 878, 52 | 880, 53 | 890, 54 | 891, 55 | 894, 56 | 903, 57 | 908, 58 | 928, 59 | 934, 60 | 939, 61 | 942, 62 | 943, 63 | 945, 64 | 946, 65 | 955, 66 | 968, 67 | 987, 68 | 988, 69 | 1002, 70 | 1010, 71 | 1014, 72 | 1018, 73 | 1070, 74 | 1074, 75 | 1180, 76 | 1387, 77 | 1701, 78 | 1843, 79 | 2371, 80 | 2408, 81 | 2506, 82 | 3138, 83 | 3476, 84 | 3581, 85 | 3854, 86 | 4177, 87 | 4198, 88 | 4233, 89 | 4443, 90 | 4500, 91 | 5279, 92 | 5956, 93 | 7103, 94 | 7152, 95 | 7156, 96 | 7281, 97 | 7559, 98 | 8095, 99 | 8319, 100 | 8443, 101 | 8742, 102 | 8854, 103 | 8886, 104 | } 105 | } 106 | 107 | func RandomScannerPort() uint16 { 108 | ports := ScannerPorts() 109 | rng := rand.New(rand.NewSource(time.Now().UnixNano())) 110 | return ports[rng.Intn(len(ports))] 111 | } 112 | 113 | func RandomScannerEndpoint(v4, v6 bool) (netip.AddrPort, error) { 114 | randomIP, err := utils.RandomIPFromPrefix(RandomScannerPrefix(v4, v6)) 115 | if err != nil { 116 | return netip.AddrPort{}, err 117 | } 118 | 119 | return netip.AddrPortFrom(randomIP, RandomScannerPort()), nil 120 | } 121 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker Image 2 | 3 | concurrency: 4 | group: docker-${{ github.event_name }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: 10 | - 'main' 11 | tags: 12 | - '*' 13 | 14 | jobs: 15 | docker: 16 | name: Docker 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v5 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Set up QEMU 25 | uses: docker/setup-qemu-action@v3 26 | with: 27 | platforms: all 28 | 29 | - name: Set up Docker Buildx 30 | uses: docker/setup-buildx-action@v3 31 | with: 32 | version: latest 33 | 34 | - name: Login to DockerHub 35 | uses: docker/login-action@v3 36 | with: 37 | username: ${{ secrets.DOCKER_USERNAME }} 38 | password: ${{ secrets.DOCKER_PASSWORD }} 39 | 40 | - name: Login to GitHub Container Registry 41 | uses: docker/login-action@v3 42 | with: 43 | registry: ghcr.io 44 | username: ${{ github.repository_owner }} 45 | password: ${{ secrets.GITHUB_TOKEN }} 46 | 47 | - name: Get Version 48 | id: shell 49 | run: | 50 | echo "version=$(git describe --tags --abbrev=0)" >> $GITHUB_OUTPUT 51 | 52 | - name: Build and Push (dev) 53 | if: github.ref == 'refs/heads/main' 54 | uses: docker/build-push-action@v6 55 | with: 56 | context: . 57 | push: true 58 | platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 59 | tags: | 60 | shahradel/cloudflare-warp:dev 61 | ghcr.io/shahradelahi/cloudflare-warp:dev 62 | cache-from: type=gha 63 | cache-to: type=gha,mode=max 64 | 65 | - name: Build and Push (latest) 66 | if: startsWith(github.ref, 'refs/tags/') 67 | uses: docker/build-push-action@v6 68 | with: 69 | context: . 70 | push: true 71 | platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 72 | tags: | 73 | shahradel/cloudflare-warp:latest 74 | shahradel/cloudflare-warp:${{ steps.shell.outputs.version }} 75 | ghcr.io/shahradelahi/cloudflare-warp:latest 76 | ghcr.io/shahradelahi/cloudflare-warp:${{ steps.shell.outputs.version }} 77 | cache-from: type=gha 78 | cache-to: type=gha,mode=max 79 | -------------------------------------------------------------------------------- /cmd/update.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/viper" 10 | "go.uber.org/zap" 11 | 12 | "github.com/shahradelahi/cloudflare-warp/cloudflare" 13 | "github.com/shahradelahi/cloudflare-warp/log" 14 | ) 15 | 16 | var updateShortMsg = "Updates the Cloudflare Warp device configuration" 17 | 18 | var UpdateCmd = &cobra.Command{ 19 | Use: "update", 20 | Short: updateShortMsg, 21 | Long: FormatMessage(updateShortMsg, `Updates device name and/or license key.`), 22 | Run: func(cmd *cobra.Command, args []string) { 23 | if err := runUpdate(); err != nil { 24 | fmt.Println(err) 25 | } 26 | }, 27 | } 28 | 29 | func init() { 30 | UpdateCmd.Flags().StringP("name", "n", "", "The new device name") 31 | UpdateCmd.Flags().StringP("license", "k", "", "The new license key") 32 | viper.BindPFlag("update.name", UpdateCmd.Flags().Lookup("name")) 33 | viper.BindPFlag("update.license", UpdateCmd.Flags().Lookup("license")) 34 | } 35 | 36 | func runUpdate() error { 37 | name := viper.GetString("update.name") 38 | license := viper.GetString("update.license") 39 | 40 | if name == "" && license == "" { 41 | return fmt.Errorf("at least one of --name or --license must be provided") 42 | } 43 | 44 | identity, err := cloudflare.LoadIdentity() 45 | if err != nil { 46 | if os.IsNotExist(err) || errors.Is(err, errors.New("identity contains 0 peers")) { 47 | return fmt.Errorf("WARP identity not found. Please run 'warp generate' to create one") 48 | } 49 | return err 50 | } 51 | 52 | warpAPI := cloudflare.NewWarpAPI() 53 | updated := false 54 | 55 | // Update device name if provided 56 | if name != "" { 57 | _, err = warpAPI.UpdateBoundDevice(identity.Token, identity.ID, identity.ID, name, true) 58 | if err != nil { 59 | return fmt.Errorf("failed to update device name: %w", err) 60 | } 61 | fmt.Println("Device name updated successfully.") 62 | updated = true 63 | } 64 | 65 | // Update license if provided 66 | if license != "" { 67 | // Generate configs 68 | identity, err = cloudflare.CreateOrUpdateIdentity(license) 69 | if err != nil { 70 | log.Fatalw("Failed to generate primary identity", zap.Error(err)) 71 | } 72 | 73 | fmt.Println("License updated successfully.") 74 | updated = true 75 | } 76 | 77 | // Save the updated identity to conf.json 78 | if updated { 79 | if err := identity.SaveIdentity(); err != nil { 80 | return fmt.Errorf("failed to save updated configuration: %w", err) 81 | } 82 | fmt.Println("Local configuration files updated.") 83 | } 84 | 85 | return nil 86 | } 87 | -------------------------------------------------------------------------------- /ipscanner/ipgenerator/ipgenerator_test.go: -------------------------------------------------------------------------------- 1 | package ipgenerator 2 | 3 | import ( 4 | "net/netip" 5 | "testing" 6 | ) 7 | 8 | func TestIpGenerator_SingleCIDR(t *testing.T) { 9 | cidr, _ := netip.ParsePrefix("192.168.1.0/29") // 8 IPs 10 | gen, err := NewIpGenerator([]netip.Prefix{cidr}) 11 | if err != nil { 12 | t.Fatalf("failed to create generator: %v", err) 13 | } 14 | 15 | var count int 16 | ips := make(map[netip.Addr]bool) 17 | for { 18 | ip, ok := gen.Next() 19 | if !ok { 20 | break 21 | } 22 | if !cidr.Contains(ip) { 23 | t.Errorf("generated IP %s not in CIDR %s", ip, cidr) 24 | } 25 | if ips[ip] { 26 | t.Errorf("duplicate IP generated: %s", ip) 27 | } 28 | ips[ip] = true 29 | count++ 30 | } 31 | 32 | if count != 8 { 33 | t.Errorf("expected 8 IPs, got %d", count) 34 | } 35 | } 36 | 37 | func TestIpGenerator_MultipleCIDRs(t *testing.T) { 38 | cidr1, _ := netip.ParsePrefix("10.0.0.0/30") // 4 IPs 39 | cidr2, _ := netip.ParsePrefix("10.0.1.0/30") // 4 IPs 40 | gen, err := NewIpGenerator([]netip.Prefix{cidr1, cidr2}) 41 | if err != nil { 42 | t.Fatalf("failed to create generator: %v", err) 43 | } 44 | 45 | var count int 46 | ips := make(map[netip.Addr]bool) 47 | for { 48 | ip, ok := gen.Next() 49 | if !ok { 50 | break 51 | } 52 | if !cidr1.Contains(ip) && !cidr2.Contains(ip) { 53 | t.Errorf("generated IP %s not in any CIDR", ip) 54 | } 55 | if ips[ip] { 56 | t.Errorf("duplicate IP generated: %s", ip) 57 | } 58 | ips[ip] = true 59 | count++ 60 | } 61 | 62 | if count != 8 { 63 | t.Errorf("expected 8 IPs, got %d", count) 64 | } 65 | } 66 | 67 | func TestIpGenerator_NoCIDRs(t *testing.T) { 68 | gen, err := NewIpGenerator([]netip.Prefix{}) 69 | if err != nil { 70 | t.Fatalf("failed to create generator: %v", err) 71 | } 72 | 73 | _, ok := gen.Next() 74 | if ok { 75 | t.Error("expected no IP from an empty generator") 76 | } 77 | } 78 | 79 | func TestIpGenerator_IPv6(t *testing.T) { 80 | cidr, _ := netip.ParsePrefix("2001:db8::/126") // 4 IPs 81 | gen, err := NewIpGenerator([]netip.Prefix{cidr}) 82 | if err != nil { 83 | t.Fatalf("failed to create generator: %v", err) 84 | } 85 | 86 | var count int 87 | ips := make(map[netip.Addr]bool) 88 | for { 89 | ip, ok := gen.Next() 90 | if !ok { 91 | break 92 | } 93 | if !cidr.Contains(ip) { 94 | t.Errorf("generated IP %s not in CIDR %s", ip, cidr) 95 | } 96 | if ips[ip] { 97 | t.Errorf("duplicate IP generated: %s", ip) 98 | } 99 | ips[ip] = true 100 | count++ 101 | } 102 | 103 | if count != 4 { 104 | t.Errorf("expected 4 IPs, got %d", count) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/viper" 10 | 11 | "github.com/shahradelahi/cloudflare-warp/core/cache" 12 | "github.com/shahradelahi/cloudflare-warp/core/datadir" 13 | "github.com/shahradelahi/cloudflare-warp/internal/version" 14 | "github.com/shahradelahi/cloudflare-warp/log" 15 | ) 16 | 17 | var rootCmd = &cobra.Command{ 18 | Use: "warp", 19 | Short: "Warp is an open-source implementation of Cloudflare's Warp.", 20 | Long: `Warp is an open-source implementation of Cloudflare's Warp client that allows you to route your internet traffic through Cloudflare's network, enhancing privacy and security. 21 | It can operate as a Socks5 or HTTP proxy.`, 22 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 23 | logLevel, err := log.ParseLevel(viper.GetString("loglevel")) 24 | if err != nil { 25 | panic(err) 26 | } 27 | log.SetLogger(log.Must(log.NewLeveled(logLevel))) 28 | 29 | // Initialize data directory 30 | dir := datadir.GetDataDirOrPath(viper.GetString("data-dir")) 31 | if err := os.MkdirAll(dir, 0700); err != nil { 32 | fmt.Fprintf(os.Stderr, "failed to create data directory: %v\n", err) 33 | os.Exit(1) 34 | } 35 | datadir.SetDataDir(dir) 36 | 37 | // Load cache 38 | c := cache.NewCache() 39 | if err := c.LoadCache(); err != nil { 40 | fmt.Fprintf(os.Stderr, "failed to load existing endpoint cache; starting with an empty cache: %v\n", err) 41 | } 42 | }, 43 | Run: func(cmd *cobra.Command, args []string) { 44 | if viper.GetBool("version") { 45 | fmt.Println(version.String()) 46 | fmt.Println(version.BuildString()) 47 | os.Exit(0) 48 | } 49 | cmd.Help() 50 | }, 51 | } 52 | 53 | func Execute() { 54 | if err := rootCmd.Execute(); err != nil { 55 | os.Exit(1) 56 | } 57 | } 58 | 59 | func init() { 60 | cobra.OnInitialize(initConfig) 61 | 62 | rootCmd.PersistentFlags().String("data-dir", "", "Directory to store generated profiles and identity files.") 63 | rootCmd.PersistentFlags().String("loglevel", "info", "SetDataDir the logging level (debug, info, warn, error, silent).") 64 | rootCmd.Flags().Bool("version", false, "Display version number.") 65 | 66 | viper.BindPFlag("data-dir", rootCmd.PersistentFlags().Lookup("data-dir")) 67 | viper.BindPFlag("loglevel", rootCmd.PersistentFlags().Lookup("loglevel")) 68 | viper.BindPFlag("version", rootCmd.Flags().Lookup("version")) 69 | 70 | // Add subcommands 71 | rootCmd.AddCommand(RunCmd) 72 | rootCmd.AddCommand(ScannerCmd) 73 | rootCmd.AddCommand(GenerateCmd) 74 | rootCmd.AddCommand(StatusCmd) 75 | rootCmd.AddCommand(UpdateCmd) 76 | } 77 | 78 | func initConfig() { 79 | viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) 80 | viper.AutomaticEnv() 81 | 82 | if err := viper.ReadInConfig(); err != nil { 83 | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { 84 | fmt.Fprintln(os.Stderr, "Error reading config file:", err) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /ipscanner/engine/engine.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/netip" 7 | "sync" 8 | "time" 9 | 10 | "go.uber.org/zap" 11 | 12 | "github.com/shahradelahi/cloudflare-warp/ipscanner/ipgenerator" 13 | "github.com/shahradelahi/cloudflare-warp/ipscanner/model" 14 | "github.com/shahradelahi/cloudflare-warp/ipscanner/ping" 15 | "github.com/shahradelahi/cloudflare-warp/log" 16 | ) 17 | 18 | type Engine struct { 19 | options *statute.ScannerOptions 20 | generators []*ipgenerator.IpGenerator 21 | 22 | ipQueue *IPQueue 23 | ping *ping.Ping 24 | 25 | ctx context.Context 26 | cancel context.CancelFunc 27 | } 28 | 29 | func NewScannerEngine(ctx context.Context, opts *statute.ScannerOptions) (*Engine, error) { 30 | var generators []*ipgenerator.IpGenerator 31 | for _, cidr := range opts.CidrList { 32 | if !opts.UseIPv6 && cidr.Addr().Is6() { 33 | continue 34 | } 35 | if !opts.UseIPv4 && cidr.Addr().Is4() { 36 | continue 37 | } 38 | 39 | gen, err := ipgenerator.NewIpGenerator([]netip.Prefix{cidr}) 40 | if err != nil { 41 | return nil, errors.New("failed to create IP generator") 42 | } 43 | generators = append(generators, gen) 44 | } 45 | 46 | childCtx, cancel := context.WithCancel(ctx) 47 | 48 | return &Engine{ 49 | options: opts, 50 | generators: generators, 51 | 52 | ipQueue: NewIPQueue(opts), 53 | 54 | ping: ping.NewPinger(opts), 55 | ctx: childCtx, 56 | cancel: cancel, 57 | }, nil 58 | } 59 | 60 | func (e *Engine) Run() { 61 | e.ipQueue.Init() 62 | 63 | processedIPs := 0 64 | progressTicker := time.NewTicker(5 * time.Second) 65 | 66 | defer progressTicker.Stop() 67 | 68 | for { 69 | select { 70 | case <-e.ctx.Done(): 71 | log.Info("Scanner Done") 72 | return 73 | case <-progressTicker.C: 74 | log.Infow("Scanning progress", zap.Int("processed_ips", processedIPs), zap.Int("found_ips", e.ipQueue.Size())) 75 | default: 76 | var wg sync.WaitGroup 77 | for _, generator := range e.generators { 78 | wg.Add(1) 79 | go func() { 80 | defer wg.Done() 81 | ip, ok := generator.Next() 82 | if ok { 83 | e.pingAddr(ip) 84 | } 85 | processedIPs++ 86 | }() 87 | } 88 | wg.Wait() 89 | } 90 | } 91 | } 92 | 93 | func (e *Engine) pingAddr(addr netip.Addr) { 94 | log.Debugw("Pinging IP", zap.String("ip", addr.String())) 95 | 96 | info, err := e.ping.DoPing(e.ctx, addr) 97 | if err != nil { 98 | log.Debugw("Ping failed", zap.String("ip", addr.String()), zap.Error(err)) 99 | return 100 | } 101 | 102 | if e.options.Cache != nil { 103 | e.options.Cache.SaveEndpoint(info.AddrPort.String(), info.RTT) 104 | } 105 | 106 | if info.RTT < e.options.MaxDesirableRTT { 107 | e.ipQueue.Enqueue(info) 108 | log.Infow("Found desirable IP", zap.String("ip", info.AddrPort.String()), zap.Duration("rtt", info.RTT)) 109 | } else { 110 | log.Debugw("IP pinged but RTT is too high", zap.String("ip", info.AddrPort.String()), zap.Duration("rtt", info.RTT)) 111 | } 112 | } 113 | 114 | func (e *Engine) GetAvailableIPs(desc bool) []statute.IPInfo { 115 | if e.ipQueue != nil { 116 | return e.ipQueue.AvailableIPs(desc) 117 | } 118 | return nil 119 | } 120 | 121 | func (e *Engine) Shutdown() { 122 | e.cancel() 123 | e.ctx.Done() 124 | } 125 | -------------------------------------------------------------------------------- /cmd/scanner.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/fatih/color" 13 | "github.com/rodaine/table" 14 | "github.com/spf13/cobra" 15 | "github.com/spf13/viper" 16 | 17 | "github.com/shahradelahi/cloudflare-warp/cloudflare" 18 | "github.com/shahradelahi/cloudflare-warp/cloudflare/network" 19 | "github.com/shahradelahi/cloudflare-warp/ipscanner" 20 | "github.com/shahradelahi/cloudflare-warp/log" 21 | ) 22 | 23 | var ScannerCmd = &cobra.Command{ 24 | Use: "scanner", 25 | Short: "Scan for the best Cloudflare WARP IP", 26 | Long: `Scans for the best Cloudflare WARP IP addresses by testing a list of known CIDRs. 27 | It measures the Round-Trip Time (RTT) and displays a list of the fastest available endpoints. 28 | This is useful for finding optimal endpoints to use with the 'run' command for better performance.`, 29 | Run: runScanner, 30 | } 31 | 32 | func init() { 33 | ScannerCmd.Flags().BoolP("ipv4", "4", false, "Only scan for IPv4 WARP endpoints.") 34 | ScannerCmd.Flags().BoolP("ipv6", "6", false, "Only scan for IPv6 WARP endpoints.") 35 | ScannerCmd.Flags().Duration("rtt", 1000*time.Millisecond, "Maximum RTT (Round-Trip Time) for scanned IPs (e.g., 1000ms).") 36 | 37 | viper.BindPFlag("scanner.ipv4", ScannerCmd.Flags().Lookup("ipv4")) 38 | viper.BindPFlag("scanner.ipv6", ScannerCmd.Flags().Lookup("ipv6")) 39 | viper.BindPFlag("scanner.rtt", ScannerCmd.Flags().Lookup("rtt")) 40 | } 41 | 42 | func runScanner(cmd *cobra.Command, args []string) { 43 | v4, _ := cmd.Flags().GetBool("ipv4") 44 | v6, _ := cmd.Flags().GetBool("ipv6") 45 | rtt, _ := cmd.Flags().GetDuration("rtt") 46 | 47 | identity, err := cloudflare.LoadIdentity() 48 | if err != nil { 49 | fatal(fmt.Errorf("failed to load identity: %w", err)) 50 | } 51 | 52 | // Essentially doing XNOR to make sure that if they are both false 53 | // or both true, just set them both true. 54 | if v4 == v6 { 55 | v4, v6 = true, true 56 | } 57 | 58 | // Create a context that is cancelled when an interrupt signal is received 59 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 60 | defer cancel() 61 | 62 | go func() { 63 | <-ctx.Done() 64 | log.Info("Interrupt signal received, stopping scanner...") 65 | }() 66 | 67 | log.Info("Starting IP scanning...") 68 | log.Info("Press CTRL+C to stop the scanner at any time.") 69 | 70 | // new scanner 71 | scanner := ipscanner.NewScanner( 72 | ipscanner.WithWarpPrivateKey(identity.PrivateKey), 73 | ipscanner.WithWarpPeerPublicKey(identity.Config.Peers[0].PublicKey), 74 | ipscanner.WithUseIPv4(v4), 75 | ipscanner.WithUseIPv6(v6), 76 | ipscanner.WithMaxDesirableRTT(rtt), 77 | ipscanner.WithCidrList(network.ScannerPrefixes()), 78 | ipscanner.WithIPQueueSize(0xffff), 79 | ipscanner.WithContext(ctx), 80 | ) 81 | 82 | if err := scanner.Run(); err != nil { 83 | if !errors.Is(err, context.Canceled) { 84 | fatal(err) 85 | } 86 | } 87 | 88 | log.Info("IP scanning process completed.") 89 | 90 | ipList := scanner.GetAvailableIPs() 91 | 92 | if len(ipList) == 0 { 93 | log.Info("No desirable IP endpoints were found during the scan.") 94 | return 95 | } 96 | 97 | headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() 98 | columnFmt := color.New(color.FgYellow).SprintfFunc() 99 | 100 | tbl := table.New("Address", "RTT (ping)", "Time") 101 | tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) 102 | 103 | for _, info := range ipList { 104 | tbl.AddRow(info.AddrPort, info.RTT, info.CreatedAt.Format(time.DateTime)) 105 | } 106 | 107 | tbl.Print() 108 | } 109 | -------------------------------------------------------------------------------- /cloudflare/identity.go: -------------------------------------------------------------------------------- 1 | package cloudflare 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "os" 7 | 8 | "go.uber.org/zap" 9 | 10 | "github.com/shahradelahi/cloudflare-warp/cloudflare/crypto" 11 | "github.com/shahradelahi/cloudflare-warp/cloudflare/model" 12 | "github.com/shahradelahi/cloudflare-warp/log" 13 | ) 14 | 15 | func CreateOrUpdateIdentity(license string) (*model.Identity, error) { 16 | warpAPI := NewWarpAPI() 17 | 18 | identity, err := LoadIdentity() 19 | if err != nil { 20 | log.Warnw("Failed to load existing WARP identity; attempting to create a new one", zap.Error(err)) 21 | 22 | log.Info("Initiating creation of a new WARP identity...") 23 | newIdentity, err := CreateIdentity(warpAPI, license) 24 | if err != nil { 25 | return nil, err 26 | } 27 | return &newIdentity, nil 28 | } 29 | 30 | if license != "" && identity.Account.License != license { 31 | log.Info("Attempting to update WARP account license key...") 32 | _, err := warpAPI.UpdateAccount(identity.Token, identity.ID, license) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | iAcc, err := warpAPI.GetAccount(identity.Token, identity.ID) 38 | if err != nil { 39 | return nil, err 40 | } 41 | identity.Account = iAcc 42 | } 43 | 44 | return identity, nil 45 | } 46 | 47 | func LoadOrCreateIdentity() (*model.Identity, error) { 48 | identity, err := CreateOrUpdateIdentity("") 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | if err = identity.SaveIdentity(); err != nil { 54 | return nil, err 55 | } 56 | 57 | log.Debug("Successfully loaded WARP identity.") 58 | return identity, nil 59 | } 60 | 61 | func LoadIdentity() (*model.Identity, error) { 62 | regPath := model.GetRegPath() 63 | confPath := model.GetConfPath() 64 | 65 | if _, err := os.Stat(regPath); os.IsNotExist(err) { 66 | return nil, err 67 | } 68 | if _, err := os.Stat(confPath); os.IsNotExist(err) { 69 | return nil, err 70 | } 71 | 72 | regBytes, err := os.ReadFile(regPath) 73 | if err != nil { 74 | return nil, err 75 | } 76 | confBytes, err := os.ReadFile(confPath) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | var regFile model.RegFile 82 | if err := json.Unmarshal(regBytes, ®File); err != nil { 83 | return nil, err 84 | } 85 | 86 | var confFile model.ConfFile 87 | if err := json.Unmarshal(confBytes, &confFile); err != nil { 88 | return nil, err 89 | } 90 | 91 | identity := model.Identity{ 92 | ID: regFile.RegistrationID, 93 | Token: regFile.Token, 94 | PrivateKey: regFile.PrivateKey, 95 | Account: confFile.Account, 96 | Config: confFile.Config, 97 | Version: "v2", // new version 98 | } 99 | 100 | if len(identity.Config.Peers) < 1 { 101 | return nil, errors.New("identity contains 0 peers") 102 | } 103 | 104 | return &identity, nil 105 | } 106 | 107 | func CreateIdentity(warpAPI *WarpAPI, license string) (model.Identity, error) { 108 | priv, err := crypto.GeneratePrivateKey() 109 | if err != nil { 110 | return model.Identity{}, err 111 | } 112 | 113 | privateKey, publicKey := priv.String(), priv.PublicKey().String() 114 | 115 | i, err := warpAPI.Register(publicKey) 116 | if err != nil { 117 | return model.Identity{}, err 118 | } 119 | 120 | if license != "" { 121 | log.Info("Attempting to update WARP account license key...") 122 | _, err := warpAPI.UpdateAccount(i.Token, i.ID, license) 123 | if err != nil { 124 | return model.Identity{}, err 125 | } 126 | 127 | ac, err := warpAPI.GetAccount(i.Token, i.ID) 128 | if err != nil { 129 | return model.Identity{}, err 130 | } 131 | i.Account = ac 132 | } 133 | 134 | i.PrivateKey = privateKey 135 | i.Version = "v2" 136 | 137 | return i, nil 138 | } 139 | -------------------------------------------------------------------------------- /utils/iputils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "math/big" 8 | "math/rand" 9 | "net" 10 | "net/netip" 11 | "strconv" 12 | "time" 13 | ) 14 | 15 | // RandomIPFromPrefix returns a random IP from the provided CIDR prefix. 16 | // Supports IPv4 and IPv6. Does not support mapped inputs. 17 | func RandomIPFromPrefix(cidr netip.Prefix) (netip.Addr, error) { 18 | startingAddress := cidr.Masked().Addr() 19 | if startingAddress.Is4In6() { 20 | return netip.Addr{}, errors.New("mapped v4 addresses not supported") 21 | } 22 | 23 | prefixLen := cidr.Bits() 24 | if prefixLen == -1 { 25 | return netip.Addr{}, fmt.Errorf("invalid cidr: %s", cidr) 26 | } 27 | 28 | // Initialise rand number generator 29 | rng := rand.New(rand.NewSource(time.Now().UnixNano())) 30 | 31 | // Find the bit length of the Host portion of the provided CIDR 32 | // prefix 33 | hostLen := big.NewInt(int64(startingAddress.BitLen() - prefixLen)) 34 | 35 | // Find the max value for our random number 36 | max := new(big.Int).Exp(big.NewInt(2), hostLen, nil) 37 | 38 | // Generate the random number 39 | randInt := new(big.Int).Rand(rng, max) 40 | 41 | // Get the first address in the CIDR prefix in 16-bytes form 42 | startingAddress16 := startingAddress.As16() 43 | 44 | // Convert the first address into a decimal number 45 | startingAddressInt := new(big.Int).SetBytes(startingAddress16[:]) 46 | 47 | // Add the random number to the decimal form of the starting address 48 | // to get a random address in the desired range 49 | randomAddressInt := new(big.Int).Add(startingAddressInt, randInt) 50 | 51 | // Convert the random address from decimal form back into netip.Addr 52 | randomAddress, ok := netip.AddrFromSlice(randomAddressInt.FillBytes(make([]byte, 16))) 53 | if !ok { 54 | return netip.Addr{}, fmt.Errorf("failed to generate random IP from CIDR: %s", cidr) 55 | } 56 | 57 | // Unmap any mapped v4 addresses before return 58 | return randomAddress.Unmap(), nil 59 | } 60 | 61 | func ParseResolveAddressPort(hostname string, includev6 bool, dnsServer string) (netip.AddrPort, error) { 62 | // Attempt to split the hostname into a host and port 63 | host, port, err := net.SplitHostPort(hostname) 64 | if err != nil { 65 | return netip.AddrPort{}, fmt.Errorf("can't parse provided hostname into host and port: %w", err) 66 | } 67 | 68 | // Convert the string port to a uint16 69 | portInt, err := strconv.Atoi(port) 70 | if err != nil { 71 | return netip.AddrPort{}, fmt.Errorf("error parsing port: %w", err) 72 | } 73 | 74 | if portInt < 1 || portInt > 65535 { 75 | return netip.AddrPort{}, fmt.Errorf("port number %d is out of range", portInt) 76 | } 77 | 78 | // Attempt to parse the host into an IP. Return on success. 79 | addr, err := netip.ParseAddr(host) 80 | if err == nil { 81 | return netip.AddrPortFrom(addr.Unmap(), uint16(portInt)), nil 82 | } 83 | 84 | // Use Go's built-in DNS resolver 85 | resolver := &net.Resolver{ 86 | PreferGo: true, 87 | Dial: func(ctx context.Context, network, address string) (net.Conn, error) { 88 | return net.Dial("udp", net.JoinHostPort(dnsServer, "53")) 89 | }, 90 | } 91 | 92 | // If the host wasn't an IP, perform a lookup 93 | ips, err := resolver.LookupIP(context.Background(), "ip", host) 94 | if err != nil { 95 | return netip.AddrPort{}, fmt.Errorf("hostname lookup failed: %w", err) 96 | } 97 | 98 | for _, ip := range ips { 99 | // Take the first IP and then return it 100 | addr, ok := netip.AddrFromSlice(ip) 101 | if !ok { 102 | continue 103 | } 104 | 105 | if addr.Unmap().Is4() { 106 | return netip.AddrPortFrom(addr.Unmap(), uint16(portInt)), nil 107 | } else if includev6 { 108 | return netip.AddrPortFrom(addr.Unmap(), uint16(portInt)), nil 109 | } 110 | } 111 | 112 | return netip.AddrPort{}, errors.New("no valid IP addresses found") 113 | } 114 | -------------------------------------------------------------------------------- /core/cache/cache_test.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/shahradelahi/cloudflare-warp/core/datadir" 11 | ) 12 | 13 | func setupTestCache(t *testing.T) (*Cache, func()) { 14 | tempDir, err := os.MkdirTemp("", "cache-test") 15 | assert.NoError(t, err) 16 | datadir.SetDataDir(tempDir) 17 | 18 | // Create a new cache for each test to ensure isolation 19 | c := &Cache{ 20 | Endpoints: make([]Endpoint, 0), 21 | } 22 | // SetDataDir the singleton instance for any code that might still rely on it. 23 | instance = c 24 | 25 | return c, func() { 26 | os.RemoveAll(tempDir) 27 | } 28 | } 29 | 30 | func TestSaveEndpoint(t *testing.T) { 31 | c, cleanup := setupTestCache(t) 32 | defer cleanup() 33 | 34 | c.SaveEndpoint("1.1.1.1:2408", 100*time.Millisecond) 35 | assert.Equal(t, 1, len(c.Endpoints)) 36 | assert.Equal(t, "1.1.1.1:2408", c.Endpoints[0].Address) 37 | assert.Equal(t, 100*time.Millisecond, c.Endpoints[0].RTT) 38 | assert.Equal(t, 0, c.Endpoints[0].Failures) 39 | 40 | // Test updating an existing endpoint 41 | c.SaveEndpoint("1.1.1.1:2408", 50*time.Millisecond) 42 | assert.Equal(t, 1, len(c.Endpoints)) 43 | assert.Equal(t, 50*time.Millisecond, c.Endpoints[0].RTT) 44 | } 45 | 46 | func TestRecordFailureAndSuccess(t *testing.T) { 47 | c, cleanup := setupTestCache(t) 48 | defer cleanup() 49 | 50 | c.SaveEndpoint("1.1.1.1:2408", 100*time.Millisecond) 51 | 52 | // Record failures 53 | c.RecordFailure("1.1.1.1:2408") 54 | assert.Equal(t, 1, c.Endpoints[0].Failures) 55 | 56 | c.RecordFailure("1.1.1.1:2408") 57 | assert.Equal(t, 2, c.Endpoints[0].Failures) 58 | 59 | // Record success, should reset failures 60 | c.RecordSuccess("1.1.1.1:2408") 61 | assert.Equal(t, 0, c.Endpoints[0].Failures) 62 | } 63 | 64 | func TestEndpointRemoval(t *testing.T) { 65 | c, cleanup := setupTestCache(t) 66 | defer cleanup() 67 | 68 | c.SaveEndpoint("1.1.1.1:2408", 100*time.Millisecond) 69 | 70 | // Fail endpoint until it's removed 71 | for i := 0; i < maxFailures; i++ { 72 | c.RecordFailure("1.1.1.1:2408") 73 | } 74 | 75 | assert.Equal(t, 0, len(c.Endpoints), "Endpoint should be removed after maxFailures") 76 | } 77 | 78 | func TestGetEndpoints(t *testing.T) { 79 | c, cleanup := setupTestCache(t) 80 | defer cleanup() 81 | 82 | c.SaveEndpoint("1.1.1.1:2408", 100*time.Millisecond) 83 | c.SaveEndpoint("2.2.2.2:2408", 200*time.Millisecond) 84 | c.SaveEndpoint("3.3.3.3:2408", 50*time.Millisecond) 85 | 86 | c.RecordFailure("2.2.2.2:2408") 87 | 88 | // Test GetBestEndpoint 89 | best, err := c.GetBestEndpoint() 90 | assert.NoError(t, err) 91 | assert.Equal(t, "3.3.3.3:2408", best.Address, "Best endpoint should have the lowest RTT") 92 | 93 | // Test GetAllEndpoints 94 | all := c.GetAllEndpoints() 95 | assert.Equal(t, 3, len(all)) 96 | assert.Equal(t, "1.1.1.1:2408", all[0].Address, "Endpoints should be sorted by failures") 97 | assert.Equal(t, "3.3.3.3:2408", all[1].Address) 98 | assert.Equal(t, "2.2.2.2:2408", all[2].Address) 99 | 100 | // Test GetRandomEndpoint 101 | random, err := c.GetRandomEndpoint() 102 | assert.NoError(t, err) 103 | assert.NotNil(t, random) 104 | } 105 | 106 | func TestLoadAndSaveCache(t *testing.T) { 107 | c, cleanup := setupTestCache(t) 108 | defer cleanup() 109 | 110 | c.SaveEndpoint("1.1.1.1:2408", 100*time.Millisecond) 111 | c.Endpoints[0].Timestamp = time.Time{} // Zero out timestamp for consistent comparison 112 | err := c.SaveCache() 113 | assert.NoError(t, err) 114 | 115 | // Create a new cache instance to load into 116 | c2 := &Cache{ 117 | Endpoints: make([]Endpoint, 0), 118 | } 119 | err = c2.LoadCache() 120 | assert.NoError(t, err) 121 | 122 | assert.Equal(t, 1, len(c2.Endpoints)) 123 | c2.Endpoints[0].Timestamp = time.Time{} // Zero out timestamp for consistent comparison 124 | assert.Equal(t, c.Endpoints, c2.Endpoints) 125 | } 126 | -------------------------------------------------------------------------------- /ipscanner/scanner.go: -------------------------------------------------------------------------------- 1 | package ipscanner 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/netip" 7 | "time" 8 | 9 | "go.uber.org/zap" 10 | 11 | "github.com/shahradelahi/cloudflare-warp/core/cache" 12 | "github.com/shahradelahi/cloudflare-warp/ipscanner/engine" 13 | "github.com/shahradelahi/cloudflare-warp/ipscanner/model" 14 | "github.com/shahradelahi/cloudflare-warp/log" 15 | ) 16 | 17 | type IPScanner struct { 18 | options statute.ScannerOptions 19 | engine *engine.Engine 20 | ctx context.Context 21 | } 22 | 23 | func NewScanner(options ...Option) *IPScanner { 24 | // Create a new cache instance 25 | c := cache.NewCache() 26 | 27 | // Load the cache from a file 28 | if err := c.LoadCache(); err != nil { 29 | log.Warnw("Failed to load existing IP scan cache; starting with an empty cache", zap.Error(err)) 30 | } 31 | 32 | p := &IPScanner{ 33 | options: statute.ScannerOptions{ 34 | UseIPv4: true, 35 | UseIPv6: true, 36 | CidrList: statute.DefaultCFRanges(), 37 | WarpPresharedKey: "", 38 | WarpPeerPublicKey: "", 39 | WarpPrivateKey: "", 40 | IPQueueSize: 8, 41 | MaxDesirableRTT: 400 * time.Millisecond, 42 | IPQueueTTL: 30 * time.Second, 43 | Cache: c, 44 | }, 45 | ctx: context.Background(), 46 | } 47 | 48 | for _, option := range options { 49 | option(p) 50 | } 51 | 52 | return p 53 | } 54 | 55 | type Option func(*IPScanner) 56 | 57 | func WithContext(ctx context.Context) Option { 58 | return func(i *IPScanner) { 59 | i.ctx = ctx 60 | } 61 | } 62 | 63 | func WithUseIPv4(useIPv4 bool) Option { 64 | return func(i *IPScanner) { 65 | i.options.UseIPv4 = useIPv4 66 | } 67 | } 68 | 69 | func WithUseIPv6(useIPv6 bool) Option { 70 | return func(i *IPScanner) { 71 | i.options.UseIPv6 = useIPv6 72 | } 73 | } 74 | 75 | func WithCidrList(cidrList []netip.Prefix) Option { 76 | return func(i *IPScanner) { 77 | i.options.CidrList = cidrList 78 | } 79 | } 80 | 81 | func WithIPQueueSize(size int) Option { 82 | return func(i *IPScanner) { 83 | i.options.IPQueueSize = size 84 | } 85 | } 86 | 87 | func WithMaxDesirableRTT(threshold time.Duration) Option { 88 | return func(i *IPScanner) { 89 | i.options.MaxDesirableRTT = threshold 90 | } 91 | } 92 | 93 | func WithIPQueueTTL(ttl time.Duration) Option { 94 | return func(i *IPScanner) { 95 | i.options.IPQueueTTL = ttl 96 | } 97 | } 98 | 99 | func WithWarpPrivateKey(privateKey string) Option { 100 | return func(i *IPScanner) { 101 | i.options.WarpPrivateKey = privateKey 102 | } 103 | } 104 | 105 | func WithWarpPeerPublicKey(peerPublicKey string) Option { 106 | return func(i *IPScanner) { 107 | i.options.WarpPeerPublicKey = peerPublicKey 108 | } 109 | } 110 | 111 | func WithWarpPreSharedKey(presharedKey string) Option { 112 | return func(i *IPScanner) { 113 | i.options.WarpPresharedKey = presharedKey 114 | } 115 | } 116 | 117 | func WithCache(c *cache.Cache) Option { 118 | return func(i *IPScanner) { 119 | i.options.Cache = c 120 | } 121 | } 122 | 123 | // run engine and in case of new event call onChange callback also if it gets canceled with context 124 | // cancel all operations 125 | 126 | func (i *IPScanner) Run() error { 127 | if !i.options.UseIPv4 && !i.options.UseIPv6 { 128 | log.Fatal("Invalid configuration: Both IPv4 and IPv6 scanning are disabled. Please enable at least one to proceed.") 129 | return nil 130 | } 131 | 132 | eng, err := engine.NewScannerEngine(i.ctx, &i.options) 133 | if err != nil { 134 | return errors.New("failed to create scanner engine") 135 | } 136 | 137 | i.engine = eng 138 | i.engine.Run() 139 | 140 | if i.options.Cache != nil { 141 | // Save the cache to a file 142 | if err := i.options.Cache.SaveCache(); err != nil { 143 | log.Warnw("Failed to save IP scan results to cache file", zap.Error(err)) 144 | } 145 | } 146 | 147 | return nil 148 | } 149 | 150 | func (i *IPScanner) Stop() { 151 | if i.engine != nil { 152 | i.engine.Shutdown() 153 | } 154 | } 155 | 156 | func (i *IPScanner) GetAvailableIPs() []statute.IPInfo { 157 | if i.engine != nil { 158 | return i.engine.GetAvailableIPs(false) 159 | } 160 | return nil 161 | } 162 | 163 | type IPInfo = statute.IPInfo 164 | -------------------------------------------------------------------------------- /ipscanner/ipgenerator/ipgenerator.go: -------------------------------------------------------------------------------- 1 | package ipgenerator 2 | 3 | import ( 4 | "math/big" 5 | "net/netip" 6 | ) 7 | 8 | // IpGenerator is a new IP address generator. 9 | type IpGenerator struct { 10 | ipRanges []IPRange 11 | currentRange int 12 | } 13 | 14 | // NewIpGenerator creates a new IpGenerator. 15 | func NewIpGenerator(cidrs []netip.Prefix) (*IpGenerator, error) { 16 | var ipRanges []IPRange 17 | for _, cidr := range cidrs { 18 | ipRange, err := NewIPRange(cidr) 19 | if err != nil { 20 | // We can choose to skip invalid CIDRs or return an error. 21 | // For now, let's skip. 22 | continue 23 | } 24 | ipRanges = append(ipRanges, ipRange) 25 | } 26 | return &IpGenerator{ 27 | ipRanges: ipRanges, 28 | currentRange: 0, 29 | }, nil 30 | } 31 | 32 | // Next returns the next IP address in the sequence. 33 | // It iterates through ranges sequentially. When a range is exhausted, it moves to the next. 34 | // When all ranges are exhausted, it returns nil. 35 | func (g *IpGenerator) Next() (netip.Addr, bool) { 36 | if len(g.ipRanges) == 0 { 37 | return netip.Addr{}, false 38 | } 39 | 40 | for g.currentRange < len(g.ipRanges) { 41 | ip, ok := g.ipRanges[g.currentRange].Next() 42 | if ok { 43 | return ip, true 44 | } 45 | // Current range is exhausted, move to the next one. 46 | g.currentRange++ 47 | } 48 | 49 | // All ranges are exhausted. 50 | return netip.Addr{}, false 51 | } 52 | 53 | // GetAll returns all IP addresses from all ranges. 54 | func (g *IpGenerator) GetAll() []netip.Addr { 55 | var allIPs []netip.Addr 56 | for _, r := range g.ipRanges { 57 | allIPs = append(allIPs, r.GetAll()...) 58 | } 59 | return allIPs 60 | } 61 | 62 | // IPRange represents a range of IP addresses from a CIDR. 63 | type IPRange struct { 64 | start *big.Int 65 | current *big.Int 66 | end *big.Int 67 | isIPv4 bool 68 | } 69 | 70 | // NewIPRange creates a new IPRange from a CIDR prefix. 71 | func NewIPRange(cidr netip.Prefix) (IPRange, error) { 72 | startIP := cidr.Addr() 73 | startInt := big.NewInt(0).SetBytes(startIP.AsSlice()) 74 | 75 | // Calculate the last IP address in the CIDR range. 76 | prefixLen := cidr.Bits() 77 | addrLen := 128 78 | if startIP.Is4() { 79 | addrLen = 32 80 | } 81 | 82 | // Create a mask to get the last IP. 83 | mask := new(big.Int).Lsh(big.NewInt(1), uint(addrLen-prefixLen)) 84 | mask.Sub(mask, big.NewInt(1)) 85 | mask.Not(mask) 86 | 87 | networkInt := new(big.Int).And(startInt, mask) 88 | 89 | broadcastMask := new(big.Int).Lsh(big.NewInt(1), uint(addrLen-prefixLen)) 90 | broadcastMask.Sub(broadcastMask, big.NewInt(1)) 91 | 92 | endInt := new(big.Int).Or(networkInt, broadcastMask) 93 | 94 | return IPRange{ 95 | start: startInt, 96 | current: new(big.Int).Set(startInt), 97 | end: endInt, 98 | isIPv4: startIP.Is4(), 99 | }, nil 100 | } 101 | 102 | // Next returns the next IP address in the range. 103 | func (r *IPRange) Next() (netip.Addr, bool) { 104 | if r.current.Cmp(r.end) > 0 { 105 | return netip.Addr{}, false 106 | } 107 | 108 | ipBytes := r.current.Bytes() 109 | var ip netip.Addr 110 | var ok bool 111 | 112 | // Pad with leading zeros if necessary 113 | addrLen := 16 // IPv6 114 | if r.isIPv4 { 115 | addrLen = 4 116 | } 117 | if len(ipBytes) < addrLen { 118 | paddedBytes := make([]byte, addrLen) 119 | copy(paddedBytes[addrLen-len(ipBytes):], ipBytes) 120 | ip, ok = netip.AddrFromSlice(paddedBytes) 121 | } else { 122 | ip, ok = netip.AddrFromSlice(ipBytes) 123 | } 124 | 125 | if !ok { 126 | // This should ideally not happen if logic is correct. 127 | return netip.Addr{}, false 128 | } 129 | 130 | r.current.Add(r.current, big.NewInt(1)) 131 | 132 | if r.isIPv4 { 133 | return ip.Unmap(), true 134 | } 135 | return ip, true 136 | } 137 | 138 | // GetAll returns all IP addresses in the range. 139 | func (r *IPRange) GetAll() []netip.Addr { 140 | var ips []netip.Addr 141 | current := new(big.Int).Set(r.start) 142 | for current.Cmp(r.end) <= 0 { 143 | ipBytes := current.Bytes() 144 | var ip netip.Addr 145 | var ok bool 146 | 147 | addrLen := 16 // IPv6 148 | if r.isIPv4 { 149 | addrLen = 4 150 | } 151 | if len(ipBytes) < addrLen { 152 | paddedBytes := make([]byte, addrLen) 153 | copy(paddedBytes[addrLen-len(ipBytes):], ipBytes) 154 | ip, ok = netip.AddrFromSlice(paddedBytes) 155 | } else { 156 | ip, ok = netip.AddrFromSlice(ipBytes) 157 | } 158 | 159 | if ok { 160 | if r.isIPv4 { 161 | ips = append(ips, ip.Unmap()) 162 | } else { 163 | ips = append(ips, ip) 164 | } 165 | } 166 | current.Add(current, big.NewInt(1)) 167 | } 168 | return ips 169 | } 170 | -------------------------------------------------------------------------------- /core/engine.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/netip" 7 | 8 | "github.com/shahradelahi/wiresocks" 9 | "go.uber.org/zap" 10 | 11 | "github.com/shahradelahi/cloudflare-warp/cloudflare" 12 | cache2 "github.com/shahradelahi/cloudflare-warp/core/cache" 13 | "github.com/shahradelahi/cloudflare-warp/log" 14 | ) 15 | 16 | // Engine is the main engine for running WARP. 17 | type Engine struct { 18 | ctx context.Context 19 | opts Config 20 | cancel context.CancelFunc 21 | cache *cache2.Cache 22 | } 23 | 24 | // NewEngine creates a new WARP engine. 25 | func NewEngine(ctx context.Context, opts Config) *Engine { 26 | ctx, cancel := context.WithCancel(ctx) 27 | return &Engine{ 28 | ctx: ctx, 29 | opts: opts, 30 | cancel: cancel, 31 | cache: cache2.NewCache(), 32 | } 33 | } 34 | 35 | // Run runs the WARP engine. 36 | func (e *Engine) Run() error { 37 | var endpoints []string 38 | 39 | if e.opts.Scan != nil { 40 | scannedEndpoints, err := e.getScannerEndpoints() 41 | if err != nil { 42 | return err 43 | } 44 | endpoints = scannedEndpoints 45 | } else { 46 | endpoints = e.opts.Endpoints 47 | } 48 | 49 | for { 50 | select { 51 | case <-e.ctx.Done(): 52 | return e.ctx.Err() 53 | default: 54 | if len(endpoints) < 1 && !e.opts.UserProvidedEndpoint { 55 | newEndpoint, _ := e.cache.GetRandomEndpoints(1) 56 | if newEndpoint != nil { 57 | endpoints = newEndpoint 58 | log.Infow("Using new random endpoints from cache", zap.Strings("endpoints", endpoints)) 59 | } 60 | } 61 | 62 | if len(endpoints) == 0 { 63 | return errors.New("no endpoint available") 64 | } 65 | 66 | log.Infow("Connecting to WARP endpoint(s)", zap.Strings("endpoints", endpoints)) 67 | 68 | if err := e.runWarp(endpoints[0]); err != nil { 69 | log.Errorw("WARP connection failed", zap.Error(err), zap.Strings("endpoints", endpoints)) 70 | endpoints = endpoints[1:] 71 | if e.opts.UserProvidedEndpoint { 72 | return err 73 | } 74 | } else { 75 | // connection successful, wait for context to be done 76 | <-e.ctx.Done() 77 | return nil 78 | } 79 | } 80 | } 81 | } 82 | 83 | func (e *Engine) getScannerEndpoints() ([]string, error) { 84 | // make primary identity 85 | ident, err := cloudflare.LoadOrCreateIdentity() 86 | if err != nil { 87 | log.Errorw("Failed to load/create primary identity", zap.Error(err)) 88 | return nil, err 89 | } 90 | 91 | // Reading the private key from the 'Interface' section 92 | e.opts.Scan.PrivateKey = ident.PrivateKey 93 | 94 | // Reading the public key from the 'Peer' section 95 | e.opts.Scan.PublicKey = ident.Config.Peers[0].PublicKey 96 | 97 | res, err := RunScan(e.ctx, *e.opts.Scan) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | endpointStrings := make([]string, len(res)) 103 | for i, ipInfo := range res { 104 | endpointStrings[i] = ipInfo.AddrPort.String() + " (RTT: " + ipInfo.RTT.String() + ")" 105 | } 106 | log.Infow("Scan successful", "found", len(res)) 107 | 108 | endpoints := make([]string, len(res)) 109 | for i := 0; i < len(res); i++ { 110 | endpoints[i] = res[i].AddrPort.String() 111 | } 112 | return endpoints, nil 113 | } 114 | 115 | // Stop stops the WARP engine. 116 | func (e *Engine) Stop() { 117 | e.cancel() 118 | } 119 | 120 | func (e *Engine) runWarp(endpoint string) error { 121 | // make primary identity 122 | ident, err := cloudflare.LoadOrCreateIdentity() 123 | if err != nil { 124 | log.Errorw("Failed to load primary identity", zap.Error(err)) 125 | return err 126 | } 127 | 128 | conf := GenerateWireguardConfig(ident) 129 | 130 | // Set up DNS Address 131 | conf.Interface.DNS = []netip.Addr{e.opts.DnsAddr} 132 | 133 | // Enable keepalive on all peers in config 134 | for i, peer := range conf.Peers { 135 | peer.Endpoint = endpoint 136 | peer.KeepAlive = 5 137 | 138 | conf.Peers[i] = peer 139 | } 140 | 141 | proxyOpts := wiresocks.ProxyConfig{ 142 | SocksBindAddr: e.opts.SocksBindAddress, 143 | HttpBindAddr: e.opts.HttpBindAddress, 144 | } 145 | 146 | return e.startProxy(e.ctx, &conf, &proxyOpts) 147 | } 148 | 149 | // startProxy starts the proxy servers and waits for the context to be done. 150 | func (e *Engine) startProxy(ctx context.Context, conf *wiresocks.Configuration, opts *wiresocks.ProxyConfig) error { 151 | ctx, cancel := context.WithCancelCause(ctx) 152 | defer cancel(nil) 153 | 154 | ws, err := wiresocks.NewWireSocks( 155 | wiresocks.WithContext(ctx), 156 | wiresocks.WithWireguardConfig(conf), 157 | wiresocks.WithProxyConfig(opts), 158 | ) 159 | if err != nil { 160 | return err 161 | } 162 | 163 | go func() { 164 | if err := ws.Run(); err != nil { 165 | log.Errorw("Failed to to start proxy server", zap.Error(err)) 166 | cancel(err) 167 | } 168 | }() 169 | 170 | if opts.SocksBindAddr != nil { 171 | log.Infow("Serving Socks5 proxy", zap.Stringer("addr", opts.SocksBindAddr)) 172 | } 173 | if opts.HttpBindAddr != nil { 174 | log.Infow("Serving HTTP proxy", zap.Stringer("addr", opts.HttpBindAddr)) 175 | } 176 | 177 | <-ctx.Done() 178 | return nil 179 | } 180 | -------------------------------------------------------------------------------- /cmd/run.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/netip" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/spf13/cobra" 14 | "github.com/spf13/viper" 15 | "go.uber.org/zap" 16 | 17 | "github.com/shahradelahi/cloudflare-warp/core" 18 | "github.com/shahradelahi/cloudflare-warp/core/cache" 19 | "github.com/shahradelahi/cloudflare-warp/ipscanner/ipgenerator" 20 | "github.com/shahradelahi/cloudflare-warp/log" 21 | "github.com/shahradelahi/cloudflare-warp/utils" 22 | ) 23 | 24 | var RunCmd = &cobra.Command{ 25 | Use: "run", 26 | Short: "Run the Cloudflare WARP proxy", 27 | Long: `Run the Cloudflare WARP proxy. 28 | This command starts the proxy server and establishes a connection to the Cloudflare network. 29 | You can configure the proxy to use Socks5 or HTTP, specify WARP endpoints, and enable features like WARP+ connections.`, 30 | Run: run, 31 | } 32 | 33 | func init() { 34 | RunCmd.Flags().Bool("4", false, "Use IPv4 for random WARP endpoint selection.") 35 | RunCmd.Flags().Bool("6", false, "Use IPv6 for random WARP endpoint selection.") 36 | RunCmd.Flags().String("socks-addr", "", "Socks5 proxy bind address.") 37 | RunCmd.Flags().String("http-addr", "", "HTTP proxy bind address.") 38 | RunCmd.Flags().String("dns", "1.1.1.1", "DNS server address to use (e.g., 1.1.1.1).") 39 | RunCmd.Flags().StringSliceP("endpoint", "e", []string{}, "Specify a custom WARP endpoint.") 40 | RunCmd.Flags().Bool("scan", false, "Enable WARP IP scanning before connecting.") 41 | RunCmd.Flags().Duration("scan-rtt", 1000*time.Millisecond, "Scanner RTT limit for endpoint selection (e.g., 1000ms).") 42 | 43 | viper.BindPFlag("4", RunCmd.Flags().Lookup("4")) 44 | viper.BindPFlag("6", RunCmd.Flags().Lookup("6")) 45 | viper.BindPFlag("socks-addr", RunCmd.Flags().Lookup("socks-addr")) 46 | viper.BindPFlag("http-addr", RunCmd.Flags().Lookup("http-addr")) 47 | viper.BindPFlag("endpoint", RunCmd.Flags().Lookup("endpoint")) 48 | viper.BindPFlag("dns", RunCmd.Flags().Lookup("dns")) 49 | viper.BindPFlag("scan", RunCmd.Flags().Lookup("scan")) 50 | viper.BindPFlag("scan-rtt", RunCmd.Flags().Lookup("scan-rtt")) 51 | } 52 | 53 | func run(cmd *cobra.Command, args []string) { 54 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 55 | defer cancel() 56 | 57 | if viper.GetBool("4") && viper.GetBool("6") { 58 | fatal(errors.New("can't force v4 and v6 at the same time")) 59 | } 60 | 61 | socksAddrStr := viper.GetString("socks-addr") 62 | httpAddrStr := viper.GetString("http-addr") 63 | 64 | if socksAddrStr == "" && httpAddrStr == "" { 65 | // if no flags are set, print help 66 | cmd.Help() 67 | return 68 | } 69 | 70 | useV4, useV6 := viper.GetBool("4"), viper.GetBool("6") 71 | if !useV4 && !useV6 { 72 | useV4, useV6 = true, true 73 | } 74 | 75 | var socksAddr, httpAddr *netip.AddrPort 76 | if socksAddrStr != "" { 77 | addr, err := netip.ParseAddrPort(socksAddrStr) 78 | if err != nil { 79 | fatal(fmt.Errorf("invalid Socks5 bind address: %w", err)) 80 | } 81 | socksAddr = &addr 82 | } 83 | 84 | if httpAddrStr != "" { 85 | addr, err := netip.ParseAddrPort(httpAddrStr) 86 | if err != nil { 87 | fatal(fmt.Errorf("invalid HTTP bind address: %w", err)) 88 | } 89 | httpAddr = &addr 90 | } 91 | 92 | dnsAddr, err := netip.ParseAddr(viper.GetString("dns")) 93 | if err != nil { 94 | fatal(fmt.Errorf("invalid DNS address: %w", err)) 95 | } 96 | 97 | endpoints := viper.GetStringSlice("endpoint") 98 | userProvidedEndpoint := len(endpoints) > 0 99 | 100 | opts := core.Config{ 101 | SocksBindAddress: socksAddr, 102 | HttpBindAddress: httpAddr, 103 | Endpoints: endpoints, 104 | DnsAddr: dnsAddr, 105 | UserProvidedEndpoint: userProvidedEndpoint, 106 | } 107 | 108 | c := cache.NewCache() 109 | 110 | if viper.GetBool("scan") { 111 | log.Infow("Scanner mode enabled", zap.Duration("max-rtt", viper.GetDuration("rtt"))) 112 | opts.Scan = &core.ScanOptions{ 113 | V4: useV4, 114 | V6: useV6, 115 | MaxRTT: viper.GetDuration("rtt"), 116 | } 117 | } 118 | 119 | if len(opts.Endpoints) == 0 && !viper.GetBool("scan") { 120 | endpoints, err := c.GetRandomEndpoints(1) 121 | if err != nil { 122 | addr, err := utils.ParseResolveAddressPort("engage.cloudflareclient.com:2408", false, opts.DnsAddr.String()) 123 | if err == nil { 124 | iprange, _ := ipgenerator.NewIPRange(netip.PrefixFrom(addr.Addr(), 24)) 125 | ips := iprange.GetAll() 126 | opts.Endpoints = []string{ 127 | fmt.Sprintf("%s:2408", ips[0]), 128 | fmt.Sprintf("%s:500", ips[1]), 129 | } 130 | } else { 131 | log.Warnw("Not enough available endpoints found in cache; automatically enabling scanner mode to discover new endpoints.") 132 | opts.Scan = &core.ScanOptions{ 133 | V4: useV4, 134 | V6: useV6, 135 | MaxRTT: viper.GetDuration("rtt"), 136 | } 137 | } 138 | } else { 139 | opts.Endpoints = endpoints 140 | log.Infow("Using random endpoint from cache", zap.String("endpoint", opts.Endpoints[0])) 141 | } 142 | } 143 | 144 | engine := core.NewEngine(ctx, opts) 145 | defer engine.Stop() 146 | 147 | go func() { 148 | if err := engine.Run(); err != nil { 149 | fatal(err) 150 | } 151 | }() 152 | 153 | <-ctx.Done() 154 | } 155 | 156 | func fatal(err error) { 157 | log.Fatalw("Application encountered a fatal error", zap.Error(err)) 158 | } 159 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | xjasonlyu@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BINARY := warp 2 | MODULE := github.com/shahradelahi/cloudflare-warp 3 | 4 | BUILD_DIR := build 5 | BUILD_TAGS := 6 | BUILD_FLAGS := -v 7 | BUILD_COMMIT := $(shell git rev-parse --short HEAD) 8 | BUILD_VERSION := $(shell git describe --abbrev=0 --tags HEAD) 9 | 10 | CGO_ENABLED := 0 11 | GO111MODULE := on 12 | 13 | LDFLAGS += -w -s -buildid= 14 | LDFLAGS += -X "$(MODULE)/internal/version.Version=$(BUILD_VERSION)" 15 | LDFLAGS += -X "$(MODULE)/internal/version.GitCommit=$(BUILD_COMMIT)" 16 | 17 | GO_BUILD = GO111MODULE=$(GO111MODULE) CGO_ENABLED=$(CGO_ENABLED) \ 18 | go build $(BUILD_FLAGS) -ldflags '$(LDFLAGS)' -tags '$(BUILD_TAGS)' -trimpath 19 | 20 | UNIX_ARCH_LIST = \ 21 | darwin-amd64 \ 22 | darwin-amd64-v3 \ 23 | darwin-arm64 \ 24 | freebsd-386 \ 25 | freebsd-amd64 \ 26 | freebsd-amd64-v3 \ 27 | freebsd-arm64 \ 28 | linux-386 \ 29 | linux-amd64 \ 30 | linux-amd64-v3 \ 31 | linux-arm64 \ 32 | linux-armv5 \ 33 | linux-armv6 \ 34 | linux-armv7 \ 35 | linux-armv8 \ 36 | linux-mips-softfloat \ 37 | linux-mips-hardfloat \ 38 | linux-mipsle-softfloat \ 39 | linux-mipsle-hardfloat \ 40 | linux-mips64 \ 41 | linux-mips64le \ 42 | linux-ppc64 \ 43 | linux-ppc64le \ 44 | linux-s390x \ 45 | linux-loong64 \ 46 | openbsd-amd64 \ 47 | openbsd-amd64-v3 \ 48 | openbsd-arm64 49 | 50 | WINDOWS_ARCH_LIST = \ 51 | windows-386 \ 52 | windows-amd64 \ 53 | windows-amd64-v3 \ 54 | windows-arm64 \ 55 | windows-arm32v7 56 | 57 | all: linux-amd64 linux-arm64 darwin-amd64 darwin-arm64 windows-amd64 58 | 59 | debug: BUILD_TAGS += debug 60 | debug: all 61 | 62 | cloudflare-warp: 63 | $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY) 64 | 65 | darwin-amd64: 66 | GOARCH=amd64 GOOS=darwin $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 67 | 68 | darwin-amd64-v3: 69 | GOARCH=amd64 GOOS=darwin GOAMD64=v3 $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 70 | 71 | darwin-arm64: 72 | GOARCH=arm64 GOOS=darwin $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 73 | 74 | freebsd-386: 75 | GOARCH=386 GOOS=freebsd $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 76 | 77 | freebsd-amd64: 78 | GOARCH=amd64 GOOS=freebsd $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 79 | 80 | freebsd-amd64-v3: 81 | GOARCH=amd64 GOOS=freebsd GOAMD64=v3 $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 82 | 83 | freebsd-arm64: 84 | GOARCH=arm64 GOOS=freebsd $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 85 | 86 | linux-386: 87 | GOARCH=386 GOOS=linux $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 88 | 89 | linux-amd64: 90 | GOARCH=amd64 GOOS=linux $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 91 | 92 | linux-amd64-v3: 93 | GOARCH=amd64 GOOS=linux GOAMD64=v3 $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 94 | 95 | linux-arm64: 96 | GOARCH=arm64 GOOS=linux $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 97 | 98 | linux-armv5: 99 | GOARCH=arm GOARM=5 GOOS=linux $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 100 | 101 | linux-armv6: 102 | GOARCH=arm GOARM=6 GOOS=linux $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 103 | 104 | linux-armv7: 105 | GOARCH=arm GOARM=7 GOOS=linux $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 106 | 107 | linux-armv8: 108 | GOARCH=arm GOARM=8 GOOS=linux $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 109 | 110 | linux-mips-softfloat: 111 | GOARCH=mips GOMIPS=softfloat GOOS=linux $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 112 | 113 | linux-mips-hardfloat: 114 | GOARCH=mips GOMIPS=hardfloat GOOS=linux $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 115 | 116 | linux-mipsle-softfloat: 117 | GOARCH=mipsle GOMIPS=softfloat GOOS=linux $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 118 | 119 | linux-mipsle-hardfloat: 120 | GOARCH=mipsle GOMIPS=hardfloat GOOS=linux $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 121 | 122 | linux-mips64: 123 | GOARCH=mips64 GOOS=linux $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 124 | 125 | linux-mips64le: 126 | GOARCH=mips64le GOOS=linux $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 127 | 128 | linux-ppc64: 129 | GOARCH=ppc64 GOOS=linux $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 130 | 131 | linux-ppc64le: 132 | GOARCH=ppc64le GOOS=linux $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 133 | 134 | linux-s390x: 135 | GOARCH=s390x GOOS=linux $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 136 | 137 | linux-loong64: 138 | GOARCH=loong64 GOOS=linux $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 139 | 140 | openbsd-amd64: 141 | GOARCH=amd64 GOOS=openbsd $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 142 | 143 | openbsd-amd64-v3: 144 | GOARCH=amd64 GOOS=openbsd GOAMD64=v3 $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 145 | 146 | openbsd-arm64: 147 | GOARCH=arm64 GOOS=openbsd $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@ 148 | 149 | windows-386: 150 | GOARCH=386 GOOS=windows $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@.exe 151 | 152 | windows-amd64: 153 | GOARCH=amd64 GOOS=windows $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@.exe 154 | 155 | windows-amd64-v3: 156 | GOARCH=amd64 GOOS=windows GOAMD64=v3 $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@.exe 157 | 158 | windows-arm64: 159 | GOARCH=arm64 GOOS=windows $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@.exe 160 | 161 | windows-arm32v7: 162 | GOARCH=arm GOARM=7 GOOS=windows $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY)-$@.exe 163 | 164 | unix_releases := $(addsuffix .zip, $(UNIX_ARCH_LIST)) 165 | windows_releases := $(addsuffix .zip, $(WINDOWS_ARCH_LIST)) 166 | 167 | $(unix_releases): %.zip: % 168 | @zip -qmj $(BUILD_DIR)/$(BINARY)-$(basename $@).zip $(BUILD_DIR)/$(BINARY)-$(basename $@) 169 | 170 | $(windows_releases): %.zip: % 171 | @zip -qmj $(BUILD_DIR)/$(BINARY)-$(basename $@).zip $(BUILD_DIR)/$(BINARY)-$(basename $@).exe 172 | 173 | all-arch: $(UNIX_ARCH_LIST) $(WINDOWS_ARCH_LIST) 174 | 175 | releases: $(unix_releases) $(windows_releases) 176 | 177 | lint: 178 | GOOS=darwin golangci-lint run ./... 179 | GOOS=windows golangci-lint run ./... 180 | GOOS=linux golangci-lint run ./... 181 | GOOS=freebsd golangci-lint run ./... 182 | GOOS=openbsd golangci-lint run ./... 183 | 184 | clean: 185 | rm -rf $(BUILD_DIR) 186 | -------------------------------------------------------------------------------- /core/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "math/rand" 7 | "os" 8 | "path/filepath" 9 | "sort" 10 | "sync" 11 | "time" 12 | 13 | "github.com/shahradelahi/cloudflare-warp/core/datadir" 14 | ) 15 | 16 | const ( 17 | maxFailures = 3 18 | ) 19 | 20 | var ( 21 | instance *Cache 22 | once sync.Once 23 | ) 24 | 25 | // Endpoint represents a WARP endpoint with its details. 26 | type Endpoint struct { 27 | Address string `json:"address"` 28 | RTT time.Duration `json:"rtt"` 29 | Timestamp time.Time `json:"timestamp"` 30 | Failures int `json:"failures"` 31 | } 32 | 33 | // Cache stores the cached endpoints. 34 | type Cache struct { 35 | Endpoints []Endpoint `json:"endpoints"` 36 | mutex sync.Mutex 37 | } 38 | 39 | // NewCache creates a new Cache instance. 40 | func NewCache() *Cache { 41 | once.Do(func() { 42 | instance = &Cache{ 43 | Endpoints: make([]Endpoint, 0), 44 | } 45 | }) 46 | return instance 47 | } 48 | 49 | // SaveEndpoint saves a new endpoint to the cache or updates an existing one. 50 | func (c *Cache) SaveEndpoint(address string, rtt time.Duration) { 51 | c.mutex.Lock() 52 | defer c.mutex.Unlock() 53 | 54 | for i, endpoint := range c.Endpoints { 55 | if endpoint.Address == address { 56 | c.Endpoints[i].RTT = rtt 57 | c.Endpoints[i].Timestamp = time.Now() 58 | c.Endpoints[i].Failures = 0 59 | return 60 | } 61 | } 62 | 63 | c.Endpoints = append(c.Endpoints, Endpoint{ 64 | Address: address, 65 | RTT: rtt, 66 | Timestamp: time.Now(), 67 | Failures: 0, 68 | }) 69 | } 70 | 71 | // GetBestEndpoint retrieves the endpoint with the lowest RTT that has not failed more than maxFailures. 72 | func (c *Cache) GetBestEndpoint() (*Endpoint, error) { 73 | c.mutex.Lock() 74 | defer c.mutex.Unlock() 75 | 76 | var availableEndpoints []Endpoint 77 | for _, endpoint := range c.Endpoints { 78 | if endpoint.Failures < maxFailures { 79 | availableEndpoints = append(availableEndpoints, endpoint) 80 | } 81 | } 82 | 83 | if len(availableEndpoints) == 0 { 84 | return nil, fmt.Errorf("no available endpoints in the cache") 85 | } 86 | 87 | sort.Slice(availableEndpoints, func(i, j int) bool { 88 | return availableEndpoints[i].RTT < availableEndpoints[j].RTT 89 | }) 90 | 91 | return &availableEndpoints[0], nil 92 | } 93 | 94 | // GetRandomEndpoint retrieves a random endpoint that has not failed more than maxFailures. 95 | func (c *Cache) GetRandomEndpoint() (*Endpoint, error) { 96 | c.mutex.Lock() 97 | defer c.mutex.Unlock() 98 | 99 | var availableEndpoints []Endpoint 100 | for _, endpoint := range c.Endpoints { 101 | if endpoint.Failures < maxFailures { 102 | availableEndpoints = append(availableEndpoints, endpoint) 103 | } 104 | } 105 | 106 | if len(availableEndpoints) == 0 { 107 | return nil, fmt.Errorf("no available endpoints in the cache") 108 | } 109 | 110 | return &availableEndpoints[rand.Intn(len(availableEndpoints))], nil 111 | } 112 | 113 | // GetAllEndpoints retrieves all endpoints that have not failed more than maxFailures, sorted by failures. 114 | func (c *Cache) GetAllEndpoints() []Endpoint { 115 | c.mutex.Lock() 116 | defer c.mutex.Unlock() 117 | 118 | var availableEndpoints []Endpoint 119 | for _, endpoint := range c.Endpoints { 120 | if endpoint.Failures < maxFailures { 121 | availableEndpoints = append(availableEndpoints, endpoint) 122 | } 123 | } 124 | 125 | sort.Slice(availableEndpoints, func(i, j int) bool { 126 | return availableEndpoints[i].Failures < availableEndpoints[j].Failures 127 | }) 128 | 129 | return availableEndpoints 130 | } 131 | 132 | // RecordFailure increments the failure count for a given endpoint and removes it if it exceeds the maxFailures. 133 | func (c *Cache) RecordFailure(address string) { 134 | c.mutex.Lock() 135 | defer c.mutex.Unlock() 136 | 137 | for i, endpoint := range c.Endpoints { 138 | if endpoint.Address == address { 139 | c.Endpoints[i].Failures++ 140 | if c.Endpoints[i].Failures >= maxFailures { 141 | c.Endpoints = append(c.Endpoints[:i], c.Endpoints[i+1:]...) 142 | } 143 | return 144 | } 145 | } 146 | } 147 | 148 | // RecordSuccess resets the failure count for a given endpoint. 149 | func (c *Cache) RecordSuccess(address string) { 150 | c.mutex.Lock() 151 | defer c.mutex.Unlock() 152 | 153 | for i, endpoint := range c.Endpoints { 154 | if endpoint.Address == address { 155 | c.Endpoints[i].Failures = 0 156 | return 157 | } 158 | } 159 | } 160 | 161 | // LoadCache loads the cache from a file. 162 | func (c *Cache) LoadCache() error { 163 | c.mutex.Lock() 164 | defer c.mutex.Unlock() 165 | 166 | dir := datadir.GetDataDir() 167 | if dir == "" { 168 | return fmt.Errorf("data directory not set") 169 | } 170 | filePath := filepath.Join(dir, "endpoints.json") 171 | 172 | data, err := os.ReadFile(filePath) 173 | if err != nil { 174 | if os.IsNotExist(err) { 175 | return nil // Cache file doesn't exist yet, which is fine. 176 | } 177 | return err 178 | } 179 | 180 | return json.Unmarshal(data, &c.Endpoints) 181 | } 182 | 183 | // SaveCache saves the cache to a file. 184 | func (c *Cache) SaveCache() error { 185 | c.mutex.Lock() 186 | defer c.mutex.Unlock() 187 | 188 | dir := datadir.GetDataDir() 189 | if dir == "" { 190 | return fmt.Errorf("data directory not set") 191 | } 192 | filePath := filepath.Join(dir, "endpoints.json") 193 | 194 | data, err := json.MarshalIndent(c.Endpoints, "", " ") 195 | if err != nil { 196 | return err 197 | } 198 | 199 | return os.WriteFile(filePath, data, 0644) 200 | } 201 | 202 | func (c *Cache) GetRandomEndpoints(count int) ([]string, error) { 203 | var endpoints []string 204 | for i := 0; i < count; i++ { 205 | endpoint, err := c.GetRandomEndpoint() 206 | if err != nil { 207 | return nil, err 208 | } 209 | endpoints = append(endpoints, endpoint.Address) 210 | } 211 | return endpoints, nil 212 | } 213 | -------------------------------------------------------------------------------- /ipscanner/engine/queue.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "sort" 5 | "sync" 6 | "time" 7 | 8 | "go.uber.org/zap" 9 | 10 | "github.com/shahradelahi/cloudflare-warp/ipscanner/model" 11 | "github.com/shahradelahi/cloudflare-warp/log" 12 | ) 13 | 14 | type IPQueue struct { 15 | queue []statute.IPInfo 16 | maxQueueSize int 17 | mu sync.Mutex 18 | available chan struct{} 19 | maxTTL time.Duration 20 | rttThreshold time.Duration 21 | inIdealMode bool 22 | reserved statute.IPInfQueue 23 | } 24 | 25 | func NewIPQueue(opts *statute.ScannerOptions) *IPQueue { 26 | var reserved statute.IPInfQueue 27 | return &IPQueue{ 28 | queue: make([]statute.IPInfo, 0), 29 | maxQueueSize: opts.IPQueueSize, 30 | maxTTL: opts.IPQueueTTL, 31 | rttThreshold: opts.MaxDesirableRTT, 32 | available: make(chan struct{}, opts.IPQueueSize), 33 | reserved: reserved, 34 | } 35 | } 36 | 37 | func (q *IPQueue) Enqueue(info statute.IPInfo) bool { 38 | q.mu.Lock() 39 | defer q.mu.Unlock() 40 | 41 | for _, existingIP := range q.queue { 42 | if existingIP.AddrPort == info.AddrPort { 43 | return false 44 | } 45 | } 46 | 47 | defer func() { 48 | log.Debugw("IP queue state change", zap.Int("current_size", len(q.queue))) 49 | for _, ipInfo := range q.queue { 50 | log.Debugw( 51 | "IP queue item details", 52 | zap.Time("created", ipInfo.CreatedAt), 53 | zap.Stringer("addr", ipInfo.AddrPort), 54 | zap.Duration("rtt", ipInfo.RTT), 55 | ) 56 | } 57 | }() 58 | 59 | log.Debug("Enqueue: Sorting queue by RTT") 60 | sort.Slice(q.queue, func(i, j int) bool { 61 | return q.queue[i].RTT < q.queue[j].RTT 62 | }) 63 | 64 | if len(q.queue) == 0 { 65 | log.Debug("Enqueue: empty queue adding first available item") 66 | q.queue = append(q.queue, info) 67 | return false 68 | } 69 | 70 | if info.RTT <= q.rttThreshold { 71 | log.Debug("Enqueue: the new item's RTT is less than at least one of the members.") 72 | if len(q.queue) >= q.maxQueueSize && info.RTT < q.queue[len(q.queue)-1].RTT { 73 | log.Debug("Enqueue: the queue is full, remove the item with the highest RTT.") 74 | q.queue = q.queue[:len(q.queue)-1] 75 | } else if len(q.queue) < q.maxQueueSize { 76 | log.Debug("Enqueue: Insert the new item in a sorted position.") 77 | index := sort.Search(len(q.queue), func(i int) bool { return q.queue[i].RTT > info.RTT }) 78 | q.queue = append(q.queue[:index], append([]statute.IPInfo{info}, q.queue[index:]...)...) 79 | } else { 80 | log.Debug("Enqueue: The Queue is full but we keep the new item in the reserved queue.") 81 | q.reserved.Enqueue(info) 82 | } 83 | } 84 | 85 | log.Debug("Enqueue: Checking if any member has a higher RTT than the threshold.") 86 | for _, member := range q.queue { 87 | if member.RTT > q.rttThreshold { 88 | return false // If any member has a higher RTT than the threshold, return false. 89 | } 90 | } 91 | 92 | log.Debug("Enqueue: All members have an RTT lower than the threshold.") 93 | if len(q.queue) < q.maxQueueSize { 94 | // the queue isn't full dont wait 95 | return false 96 | } 97 | 98 | q.inIdealMode = true 99 | // ok wait for expiration signal 100 | log.Debug("Enqueue: All members have an RTT lower than the threshold. Waiting for expiration signal.") 101 | return true 102 | } 103 | 104 | func (q *IPQueue) Dequeue() (statute.IPInfo, bool) { 105 | defer func() { 106 | log.Debugw("IP queue state change", zap.Int("current_size", len(q.queue))) 107 | for _, ipInfo := range q.queue { 108 | log.Debugw( 109 | "IP queue item details", 110 | zap.Time("created", ipInfo.CreatedAt), 111 | zap.Stringer("addr", ipInfo.AddrPort), 112 | zap.Duration("rtt", ipInfo.RTT), 113 | ) 114 | } 115 | }() 116 | q.mu.Lock() 117 | defer q.mu.Unlock() 118 | 119 | if len(q.queue) == 0 { 120 | return statute.IPInfo{}, false 121 | } 122 | 123 | info := q.queue[len(q.queue)-1] 124 | q.queue = q.queue[0 : len(q.queue)-1] 125 | 126 | q.available <- struct{}{} 127 | 128 | return info, true 129 | } 130 | 131 | func (q *IPQueue) Init() { 132 | q.mu.Lock() 133 | defer q.mu.Unlock() 134 | 135 | if !q.inIdealMode { 136 | q.available <- struct{}{} 137 | return 138 | } 139 | } 140 | 141 | func (q *IPQueue) Expire() { 142 | q.mu.Lock() 143 | defer q.mu.Unlock() 144 | 145 | log.Debug("Expire: In ideal mode") 146 | defer func() { 147 | log.Debugw("IP queue state change", zap.Int("current_size", len(q.queue))) 148 | for _, ipInfo := range q.queue { 149 | log.Debugw( 150 | "IP queue item details", 151 | zap.Time("created", ipInfo.CreatedAt), 152 | zap.Stringer("addr", ipInfo.AddrPort), 153 | zap.Duration("rtt", ipInfo.RTT), 154 | ) 155 | } 156 | }() 157 | 158 | shouldStartNewScan := false 159 | resQ := make([]statute.IPInfo, 0) 160 | for i := 0; i < len(q.queue); i++ { 161 | if time.Since(q.queue[i].CreatedAt) > q.maxTTL { 162 | log.Debug("Expire: Removing expired item from queue") 163 | shouldStartNewScan = true 164 | } else { 165 | resQ = append(resQ, q.queue[i]) 166 | } 167 | } 168 | q.queue = resQ 169 | log.Debug("Expire: Adding reserved items to queue") 170 | for i := 0; i < q.maxQueueSize && i < q.reserved.Size(); i++ { 171 | q.queue = append(q.queue, q.reserved.Dequeue()) 172 | } 173 | if shouldStartNewScan { 174 | q.available <- struct{}{} 175 | } 176 | } 177 | 178 | func (q *IPQueue) AvailableIPs(desc bool) []statute.IPInfo { 179 | q.mu.Lock() 180 | defer q.mu.Unlock() 181 | 182 | // Create a separate slice for sorting 183 | sortedQueue := make([]statute.IPInfo, len(q.queue)) 184 | copy(sortedQueue, q.queue) 185 | 186 | // Sort by RTT ascending/descending 187 | sort.Slice(sortedQueue, func(i, j int) bool { 188 | if desc { 189 | return sortedQueue[i].RTT > sortedQueue[j].RTT 190 | } 191 | return sortedQueue[i].RTT < sortedQueue[j].RTT 192 | }) 193 | 194 | return sortedQueue 195 | } 196 | 197 | func (q *IPQueue) Size() int { 198 | q.mu.Lock() 199 | defer q.mu.Unlock() 200 | return len(q.queue) 201 | } 202 | -------------------------------------------------------------------------------- /cloudflare/network/tls.go: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net" 7 | "net/netip" 8 | "time" 9 | 10 | "github.com/avast/retry-go" 11 | "github.com/noql-net/certpool" 12 | tls "github.com/refraction-networking/utls" 13 | "go.uber.org/zap" 14 | 15 | "github.com/shahradelahi/cloudflare-warp/log" 16 | "github.com/shahradelahi/cloudflare-warp/utils" 17 | ) 18 | 19 | // Dialer is a struct that holds various options for custom dialing. 20 | type Dialer struct{} 21 | 22 | const utlsExtensionSNICurve uint16 = 0x15 23 | 24 | // SNICurveExtension implements SNICurve (0x15) extension 25 | type SNICurveExtension struct { 26 | *tls.GenericExtension 27 | SNICurveLen int 28 | WillPad bool // set false to disable extension 29 | } 30 | 31 | // Len returns the length of the SNICurveExtension. 32 | func (e *SNICurveExtension) Len() int { 33 | if e.WillPad { 34 | return 4 + e.SNICurveLen 35 | } 36 | return 0 37 | } 38 | 39 | // Read reads the SNICurveExtension. 40 | func (e *SNICurveExtension) Read(b []byte) (n int, err error) { 41 | if !e.WillPad { 42 | return 0, io.EOF 43 | } 44 | if len(b) < e.Len() { 45 | return 0, io.ErrShortBuffer 46 | } 47 | // https://tools.ietf.org/html/rfc7627 48 | b[0] = byte(utlsExtensionSNICurve >> 8) 49 | b[1] = byte(utlsExtensionSNICurve) 50 | b[2] = byte(e.SNICurveLen >> 8) 51 | b[3] = byte(e.SNICurveLen) 52 | y := make([]byte, 1200) 53 | copy(b[4:], y) 54 | return e.Len(), io.EOF 55 | } 56 | 57 | const SNICurveSize = 1200 58 | 59 | func spec(sni string) *tls.ClientHelloSpec { 60 | return &tls.ClientHelloSpec{ 61 | TLSVersMax: tls.VersionTLS12, 62 | TLSVersMin: tls.VersionTLS12, 63 | CipherSuites: []uint16{ 64 | tls.GREASE_PLACEHOLDER, 65 | tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, 66 | tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, 67 | tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, 68 | tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, 69 | tls.TLS_AES_128_GCM_SHA256, // tls 1.3 70 | tls.FAKE_TLS_DHE_RSA_WITH_AES_256_CBC_SHA, 71 | tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, 72 | tls.TLS_RSA_WITH_AES_256_CBC_SHA, 73 | }, 74 | Extensions: []tls.TLSExtension{ 75 | &SNICurveExtension{ 76 | SNICurveLen: SNICurveSize, 77 | WillPad: true, 78 | }, 79 | &tls.SupportedCurvesExtension{Curves: []tls.CurveID{tls.X25519, tls.CurveP256}}, 80 | &tls.SupportedPointsExtension{SupportedPoints: []byte{0}}, // uncompressed 81 | &tls.SessionTicketExtension{}, 82 | &tls.ALPNExtension{AlpnProtocols: []string{"http/1.1"}}, 83 | &tls.SignatureAlgorithmsExtension{ 84 | SupportedSignatureAlgorithms: []tls.SignatureScheme{ 85 | tls.ECDSAWithP256AndSHA256, 86 | tls.ECDSAWithP384AndSHA384, 87 | tls.ECDSAWithP521AndSHA512, 88 | tls.PSSWithSHA256, 89 | tls.PSSWithSHA384, 90 | tls.PSSWithSHA512, 91 | tls.PKCS1WithSHA256, 92 | tls.PKCS1WithSHA384, 93 | tls.PKCS1WithSHA512, 94 | tls.ECDSAWithSHA1, 95 | tls.PKCS1WithSHA1, 96 | }, 97 | }, 98 | &tls.KeyShareExtension{KeyShares: []tls.KeyShare{ 99 | {Group: tls.CurveID(tls.GREASE_PLACEHOLDER), Data: []byte{0}}, 100 | {Group: tls.X25519}, 101 | }}, 102 | &tls.PSKKeyExchangeModesExtension{Modes: []uint8{1}}, // pskModeDHE 103 | &tls.SNIExtension{ServerName: sni}, 104 | }, 105 | GetSessionID: nil, 106 | } 107 | } 108 | 109 | func makeTLSHelloPacketWithSNICurve(plainConn net.Conn, config *tls.Config, sni string) (*tls.UConn, error) { 110 | utlsConn := tls.UClient(plainConn, config, tls.HelloCustom) 111 | err := utlsConn.ApplyPreset(spec(sni)) 112 | if err != nil { 113 | return nil, fmt.Errorf("uTlsConn.Handshake() error: %w", err) 114 | } 115 | 116 | err = utlsConn.Handshake() 117 | if err != nil { 118 | return nil, fmt.Errorf("uTlsConn.Handshake() error: %w", err) 119 | } 120 | 121 | return utlsConn, nil 122 | } 123 | 124 | func dialCurve(network string, ip netip.Addr, sni string) (net.Conn, error) { 125 | plainDialer := &net.Dialer{ 126 | Timeout: 5 * time.Second, 127 | KeepAlive: 5 * time.Second, 128 | } 129 | 130 | plainConn, err := plainDialer.Dial(network, netip.AddrPortFrom(ip, 443).String()) 131 | if err != nil { 132 | return nil, err 133 | } 134 | 135 | config := tls.Config{ 136 | ServerName: sni, 137 | MinVersion: tls.VersionTLS12, 138 | RootCAs: certpool.Roots(), 139 | } 140 | 141 | tlsConn, handshakeErr := makeTLSHelloPacketWithSNICurve(plainConn, &config, sni) 142 | if handshakeErr != nil { 143 | _ = plainConn.Close() 144 | return nil, handshakeErr 145 | } 146 | return tlsConn, nil 147 | } 148 | 149 | func dial2(network string, ip netip.Addr, sni string) (net.Conn, error) { 150 | plainDialer := &net.Dialer{ 151 | Timeout: 5 * time.Second, 152 | KeepAlive: 5 * time.Second, 153 | } 154 | 155 | plainConn, err := plainDialer.Dial(network, netip.AddrPortFrom(ip, 443).String()) 156 | if err != nil { 157 | return nil, err 158 | } 159 | 160 | tlsConfig := tls.Config{ 161 | ServerName: sni, 162 | MinVersion: tls.VersionTLS13, 163 | RootCAs: certpool.Roots(), 164 | } 165 | 166 | tlsConn := tls.Client(plainConn, &tlsConfig) 167 | err = tlsConn.Handshake() 168 | if err != nil { 169 | _ = plainConn.Close() 170 | return nil, err 171 | } 172 | 173 | return tlsConn, nil 174 | } 175 | func dial3(network string, ip netip.Addr, sni string) (net.Conn, error) { 176 | plainDialer := &net.Dialer{ 177 | Timeout: 5 * time.Second, 178 | KeepAlive: 5 * time.Second, 179 | } 180 | 181 | plainConn, err := plainDialer.Dial(network, netip.AddrPortFrom(ip, 443).String()) 182 | if err != nil { 183 | return nil, err 184 | } 185 | 186 | tlsConfig := tls.Config{ 187 | ServerName: sni, 188 | MinVersion: tls.VersionTLS13, 189 | RootCAs: certpool.Roots(), 190 | } 191 | 192 | tlsConn := tls.UClient(plainConn, &tlsConfig, tls.HelloChrome_Auto) 193 | err = tlsConn.Handshake() 194 | if err != nil { 195 | _ = plainConn.Close() 196 | return nil, err 197 | } 198 | 199 | return tlsConn, nil 200 | } 201 | 202 | // TLSDial dials a TLS connection. 203 | func (d *Dialer) TLSDial(network, addr string) (net.Conn, error) { 204 | sni, _, err := net.SplitHostPort(addr) 205 | if err != nil { 206 | return nil, err 207 | } 208 | ip, err := utils.RandomIPFromPrefix(netip.MustParsePrefix("141.101.113.0/24")) 209 | if err != nil { 210 | return nil, err 211 | } 212 | 213 | var tlsConn net.Conn 214 | 215 | log.Debugw("Attempting TLS dial with fingerprint 1 (SNICurve)", zap.String("target_address", addr)) 216 | err = retry.Do( 217 | func() error { 218 | tlsConn, err = dialCurve(network, ip, sni) 219 | return err 220 | }, 221 | retry.Attempts(3), 222 | retry.Delay(250*time.Millisecond), 223 | retry.DelayType(retry.FixedDelay), 224 | retry.OnRetry(func(n uint, err error) { 225 | log.Info("retrying TLS dial", zap.Uint("attempt", n), zap.Error(err)) 226 | }), 227 | ) 228 | 229 | if err != nil { 230 | log.Debugw("Attempting TLS dial with fingerprint 2 (TLS 1.3 default)", zap.String("target_address", addr)) 231 | err = retry.Do( 232 | func() error { 233 | tlsConn, err = dial2(network, ip, sni) 234 | return err 235 | }, 236 | retry.Attempts(3), 237 | retry.Delay(250*time.Millisecond), 238 | retry.DelayType(retry.FixedDelay), 239 | retry.OnRetry(func(n uint, err error) { 240 | log.Warnw("TLS dial attempt failed, retrying...", zap.Uint("attempt", n+1), zap.Error(err)) 241 | }), 242 | ) 243 | } 244 | 245 | if err != nil { 246 | log.Debugw("Attempting TLS dial with fingerprint 3 (Chrome Auto)", zap.String("target_address", addr)) 247 | err = retry.Do( 248 | func() error { 249 | tlsConn, err = dial3(network, ip, sni) 250 | return err 251 | }, 252 | retry.Attempts(3), 253 | retry.Delay(250*time.Millisecond), 254 | retry.DelayType(retry.FixedDelay), 255 | retry.OnRetry(func(n uint, err error) { 256 | log.Warnw("TLS dial attempt failed, retrying...", zap.Uint("attempt", n+1), zap.Error(err)) 257 | }), 258 | ) 259 | } 260 | 261 | return tlsConn, nil 262 | } 263 | -------------------------------------------------------------------------------- /ipscanner/ping/warp.go: -------------------------------------------------------------------------------- 1 | package ping 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/rand" 7 | "encoding/base64" 8 | "encoding/binary" 9 | "errors" 10 | "fmt" 11 | "net" 12 | "net/netip" 13 | "sync" 14 | "time" 15 | 16 | "github.com/flynn/noise" 17 | "go.uber.org/zap" 18 | "golang.org/x/crypto/blake2s" 19 | "golang.org/x/crypto/curve25519" 20 | 21 | "github.com/shahradelahi/cloudflare-warp/cloudflare/network" 22 | "github.com/shahradelahi/cloudflare-warp/ipscanner/model" 23 | "github.com/shahradelahi/cloudflare-warp/log" 24 | "github.com/shahradelahi/cloudflare-warp/utils" 25 | ) 26 | 27 | const ( 28 | minRandomPackets = 20 29 | maxRandomPackets = 50 30 | minRandomPacketSize = 10 // Relative to header size 31 | maxRandomPacketSize = 120 // Relative to header size 32 | minRandomPacketDelayMs = 80 33 | maxRandomPacketDelayMs = 150 34 | ) 35 | 36 | type WarpPingResult struct { 37 | AddrPort netip.AddrPort 38 | RTT time.Duration 39 | Err error 40 | } 41 | 42 | func (h *WarpPingResult) Result() statute.IPInfo { 43 | return statute.IPInfo{AddrPort: h.AddrPort, RTT: h.RTT, CreatedAt: time.Now()} 44 | } 45 | 46 | func (h *WarpPingResult) Error() error { 47 | return h.Err 48 | } 49 | 50 | func (h *WarpPingResult) String() string { 51 | if h.Err != nil { 52 | return fmt.Sprintf("%s", h.Err) 53 | } else { 54 | return fmt.Sprintf("%s: protocol=%s, time=%d ms", h.AddrPort, "warp", h.RTT) 55 | } 56 | } 57 | 58 | type WarpPing struct { 59 | PrivateKey string 60 | PeerPublicKey string 61 | PresharedKey string 62 | IP netip.Addr 63 | opts *statute.ScannerOptions 64 | } 65 | 66 | func (h *WarpPing) Ping() statute.IPingResult { 67 | return h.PingContext(context.Background()) 68 | } 69 | 70 | func (h *WarpPing) PingContext(ctx context.Context) statute.IPingResult { 71 | ports := network.ScannerPorts() 72 | results := make(chan statute.IPingResult, len(ports)) 73 | var wg sync.WaitGroup 74 | 75 | for _, port := range ports { 76 | wg.Add(1) 77 | addr := netip.AddrPortFrom(h.IP, port) 78 | go func(addr netip.AddrPort) { 79 | defer wg.Done() 80 | log.Debugf("Attempting to ping WARP endpoint: %s", addr.String()) 81 | rtt, err := initiateHandshake( 82 | ctx, 83 | addr, 84 | h.PrivateKey, 85 | h.PeerPublicKey, 86 | h.PresharedKey, 87 | true, // Obfuscation 88 | ) 89 | if err == nil { 90 | log.Debugf("Successfully pinged WARP endpoint %s, RTT: %s", addr.String(), rtt.String()) 91 | results <- &WarpPingResult{AddrPort: addr, RTT: rtt, Err: nil} 92 | } else { 93 | log.Debugw("Failed to ping WARP endpoint", zap.String("address", addr.String()), zap.Error(err)) 94 | if h.opts != nil && h.opts.EventsHandler != nil { 95 | h.opts.EventsHandler.IncrementFailure(addr.String()) 96 | } 97 | results <- h.errorResult(err) 98 | } 99 | }(addr) 100 | } 101 | 102 | wg.Wait() 103 | close(results) 104 | 105 | var lastErr error 106 | for res := range results { 107 | if res.Error() == nil { 108 | return res 109 | } 110 | lastErr = res.Error() 111 | } 112 | 113 | return h.errorResult(lastErr) 114 | } 115 | 116 | func (h *WarpPing) errorResult(err error) *WarpPingResult { 117 | r := &WarpPingResult{} 118 | r.Err = err 119 | return r 120 | } 121 | 122 | func staticKeypair(privateKeyBase64 string) (noise.DHKey, error) { 123 | privateKey, err := base64.StdEncoding.DecodeString(privateKeyBase64) 124 | if err != nil { 125 | return noise.DHKey{}, err 126 | } 127 | 128 | var pubkey, privkey [32]byte 129 | copy(privkey[:], privateKey) 130 | curve25519.ScalarBaseMult(&pubkey, &privkey) 131 | 132 | return noise.DHKey{ 133 | Private: privateKey, 134 | Public: pubkey[:], 135 | }, nil 136 | } 137 | 138 | func ephemeralKeypair() (noise.DHKey, error) { 139 | // Generate an ephemeral private key 140 | ephemeralPrivateKey := make([]byte, 32) 141 | if _, err := rand.Read(ephemeralPrivateKey); err != nil { 142 | return noise.DHKey{}, err 143 | } 144 | 145 | // Derive the corresponding ephemeral public key 146 | ephemeralPublicKey, err := curve25519.X25519(ephemeralPrivateKey, curve25519.Basepoint) 147 | if err != nil { 148 | return noise.DHKey{}, err 149 | } 150 | 151 | return noise.DHKey{ 152 | Private: ephemeralPrivateKey, 153 | Public: ephemeralPublicKey, 154 | }, nil 155 | } 156 | 157 | func generateObfuscationHeader() ([]byte, error) { 158 | clist := []byte{0xDC, 0xDE, 0xD3, 0xD9, 0xD0, 0xEC, 0xEE, 0xE3} 159 | firstByteIndex, err := utils.RandomInt(0, uint64(len(clist)-1)) 160 | if err != nil { 161 | return nil, fmt.Errorf("failed to generate random byte for header: %w", err) 162 | } 163 | header := []byte{ 164 | clist[firstByteIndex], 165 | 0x00, 0x00, 0x00, 0x01, 0x08, 166 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 167 | 0x00, 0x00, 0x44, 0xD0, 168 | } 169 | _, err = rand.Read(header[6:14]) 170 | if err != nil { 171 | return nil, fmt.Errorf("failed to generate random part of header: %w", err) 172 | } 173 | return header, nil 174 | } 175 | 176 | func sendRandomPackets(ctx context.Context, conn net.Conn, obfuscation bool) error { 177 | if !obfuscation { 178 | return nil 179 | } 180 | 181 | header, err := generateObfuscationHeader() 182 | if err != nil { 183 | return fmt.Errorf("failed to generate obfuscation header: %w", err) 184 | } 185 | if header == nil { 186 | return nil 187 | } 188 | 189 | numPackets, err := utils.RandomInt(minRandomPackets, maxRandomPackets) 190 | if err != nil { 191 | return fmt.Errorf("failed to generate random packet count: %w", err) 192 | } 193 | 194 | maxPacketSize := uint64(len(header)) + maxRandomPacketSize 195 | randomPacket := make([]byte, maxPacketSize) 196 | 197 | for i := uint64(0); i < numPackets; i++ { 198 | select { 199 | case <-ctx.Done(): 200 | return ctx.Err() 201 | default: 202 | packetSize, err := utils.RandomInt(uint64(len(header))+minRandomPacketSize, maxPacketSize) 203 | if err != nil { 204 | return fmt.Errorf("failed to generate random packet size: %w", err) 205 | } 206 | 207 | // Fill random payload 208 | if packetSize > uint64(len(header)) { 209 | _, err = rand.Read(randomPacket[len(header):packetSize]) 210 | if err != nil { 211 | return fmt.Errorf("failed to generate random packet payload: %w", err) 212 | } 213 | } 214 | 215 | // Copy header 216 | copy(randomPacket[:len(header)], header) 217 | 218 | _, err = conn.Write(randomPacket[:packetSize]) 219 | if err != nil { 220 | return fmt.Errorf("error sending random packet: %w", err) 221 | } 222 | 223 | delay, err := utils.RandomInt(minRandomPacketDelayMs, maxRandomPacketDelayMs) 224 | if err != nil { 225 | log.Warnw("Failed to generate random delay", zap.Error(err)) 226 | } else { 227 | time.Sleep(time.Duration(delay) * time.Millisecond) 228 | } 229 | } 230 | } 231 | return nil 232 | } 233 | 234 | func initiateHandshake(ctx context.Context, serverAddr netip.AddrPort, privateKeyBase64, peerPublicKeyBase64, presharedKeyBase64 string, obfuscation bool) (time.Duration, error) { 235 | staticKeyPair, err := staticKeypair(privateKeyBase64) 236 | if err != nil { 237 | return 0, err 238 | } 239 | 240 | peerPublicKey, err := base64.StdEncoding.DecodeString(peerPublicKeyBase64) 241 | if err != nil { 242 | return 0, err 243 | } 244 | 245 | presharedKey, err := base64.StdEncoding.DecodeString(presharedKeyBase64) 246 | if err != nil { 247 | return 0, err 248 | } 249 | 250 | if presharedKeyBase64 == "" { 251 | presharedKey = make([]byte, 32) 252 | } 253 | 254 | ephemeral, err := ephemeralKeypair() 255 | if err != nil { 256 | return 0, err 257 | } 258 | 259 | cs := noise.NewCipherSuite(noise.DH25519, noise.CipherChaChaPoly, noise.HashBLAKE2s) 260 | hs, err := noise.NewHandshakeState(noise.Config{ 261 | CipherSuite: cs, 262 | Pattern: noise.HandshakeIK, 263 | Initiator: true, 264 | StaticKeypair: staticKeyPair, 265 | PeerStatic: peerPublicKey, 266 | Prologue: []byte("WireGuard v1 zx2c4 Jason@zx2c4.com"), 267 | PresharedKey: presharedKey, 268 | PresharedKeyPlacement: 2, 269 | EphemeralKeypair: ephemeral, 270 | Random: rand.Reader, 271 | }) 272 | if err != nil { 273 | return 0, err 274 | } 275 | 276 | // Prepare handshake initiation packet 277 | 278 | // TAI64N timestamp calculation 279 | now := time.Now().UTC() 280 | epochOffset := int64(4611686018427387914) // TAI offset from Unix epoch 281 | 282 | tai64nTimestampBuf := make([]byte, 0, 16) 283 | tai64nTimestampBuf = binary.BigEndian.AppendUint64(tai64nTimestampBuf, uint64(epochOffset+now.Unix())) 284 | tai64nTimestampBuf = binary.BigEndian.AppendUint32(tai64nTimestampBuf, uint32(now.Nanosecond())) 285 | msg, _, _, err := hs.WriteMessage(nil, tai64nTimestampBuf) 286 | if err != nil { 287 | return 0, err 288 | } 289 | 290 | initiationPacket := new(bytes.Buffer) 291 | binary.Write(initiationPacket, binary.BigEndian, []byte{0x01, 0x00, 0x00, 0x00}) 292 | binary.Write(initiationPacket, binary.BigEndian, utils.Uint32ToBytes(28)) 293 | binary.Write(initiationPacket, binary.BigEndian, msg) 294 | 295 | macKey := blake2s.Sum256(append([]byte("mac1----"), peerPublicKey...)) 296 | hasher, err := blake2s.New128(macKey[:]) // using macKey as the key 297 | if err != nil { 298 | return 0, err 299 | } 300 | _, err = hasher.Write(initiationPacket.Bytes()) 301 | if err != nil { 302 | return 0, err 303 | } 304 | initiationPacketMAC := hasher.Sum(nil) 305 | 306 | // Append the MAC and 16 null bytes to the initiation packet 307 | binary.Write(initiationPacket, binary.BigEndian, initiationPacketMAC[:16]) 308 | binary.Write(initiationPacket, binary.BigEndian, [16]byte{}) 309 | 310 | conn, err := net.Dial("udp", serverAddr.String()) 311 | if err != nil { 312 | return 0, err 313 | } 314 | defer conn.Close() 315 | 316 | if err := sendRandomPackets(ctx, conn, obfuscation); err != nil { 317 | return 0, err 318 | } 319 | 320 | _, err = initiationPacket.WriteTo(conn) 321 | if err != nil { 322 | return 0, err 323 | } 324 | t0 := time.Now() 325 | 326 | response := make([]byte, 92) 327 | conn.SetReadDeadline(time.Now().Add(5 * time.Second)) 328 | i, err := conn.Read(response) 329 | if err != nil { 330 | return 0, err 331 | } 332 | rtt := time.Since(t0) 333 | 334 | if i < 60 { 335 | return 0, fmt.Errorf("invalid handshake response length %d bytes", i) 336 | } 337 | 338 | // Check the response type 339 | if response[0] != 2 { // 2 is the message type for response 340 | return 0, errors.New("invalid response type") 341 | } 342 | 343 | // Extract sender and receiver index from the response 344 | // peer index 345 | _ = binary.LittleEndian.Uint32(response[4:8]) 346 | // our index(we set it to 28) 347 | ourIndex := binary.LittleEndian.Uint32(response[8:12]) 348 | if ourIndex != 28 { // Check if the response corresponds to our sender index 349 | return 0, errors.New("invalid sender index in response") 350 | } 351 | 352 | payload, _, _, err := hs.ReadMessage(nil, response[12:60]) 353 | if err != nil { 354 | return 0, err 355 | } 356 | 357 | // Check if the payload is empty (as expected in WireGuard handshake) 358 | if len(payload) != 0 { 359 | return 0, errors.New("unexpected payload in response") 360 | } 361 | 362 | return rtt, nil 363 | } 364 | 365 | func NewWarpPing(ip netip.Addr, opts *statute.ScannerOptions) *WarpPing { 366 | return &WarpPing{ 367 | PrivateKey: opts.WarpPrivateKey, 368 | PeerPublicKey: opts.WarpPeerPublicKey, 369 | PresharedKey: opts.WarpPresharedKey, 370 | IP: ip, 371 | opts: opts, 372 | } 373 | } 374 | 375 | var ( 376 | _ statute.IPing = (*WarpPing)(nil) 377 | _ statute.IPingResult = (*WarpPingResult)(nil) 378 | ) 379 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ☁️ Cloudflare Warp 2 | 3 | [![GitHub Workflow][1]](https://github.com/shahradelahi/cloudflare-warp/actions) 4 | [![Go Version][2]](https://img.shields.io/github/go-mod/go-version/shahradelahi/cloudflare-warp?logo=go) 5 | [![Go Report Card][3]](https://goreportcard.com/report/github.com/shahradelahi/cloudflare-warp) 6 | [![Maintainability][4]](https://api.codeclimate.com/v1/badges/b5b30239174fc6603aca/maintainability) 7 | [![GitHub License][5]](https://img.shields.io/github/license/shahradelahi/cloudflare-warp) 8 | [![Docker Pulls][6]](https://hub.docker.com/r/shahradelahi/cloudflare-warp) 9 | [![Releases][7]](https://github.com/shahradelahi/cloudflare-warp/releases) 10 | 11 | [1]: https://img.shields.io/github/actions/workflow/status/shahradelahi/cloudflare-warp/docker.yml?logo=github 12 | [2]: https://img.shields.io/github/go-mod/go-version/shahradelahi/cloudflare-warp?logo=go 13 | [3]: https://goreportcard.com/badge/github.com/shahradelahi/cloudflare-warp 14 | [4]: https://qlty.sh/gh/shahradelahi/projects/cloudflare-warp/maintainability.svg 15 | [5]: https://img.shields.io/github/license/shahradelahi/cloudflare-warp 16 | [6]: https://img.shields.io/docker/pulls/shahradelahi/cloudflare-warp?logo=docker 17 | [7]: https://img.shields.io/github/v/release/shahradelahi/cloudflare-warp?logo=smartthings 18 | 19 | ## Table of Contents 20 | 21 | - [Motivation](#-motivation) 22 | - [Features](#-features) 23 | - [Installation](#-installation) 24 | - [From GitHub Releases](#from-github-releases) 25 | - [Building from Source](#building-from-source) 26 | - [Docker](#docker) 27 | - [Usage](#-usage) 28 | - [Command-line Flags](#command-line-flags) 29 | - [Register a new account](#register-a-new-account) 30 | - [Add a license key](#add-a-license-key) 31 | - [Generate WireGuard configuration](#generate-wireguard-configuration) 32 | - [Check device status](#check-device-status) 33 | - [Verify Warp/Warp+ works](#verify-warpplus-works) 34 | - [Run the WARP proxy](#run-the-warp-proxy) 35 | - [Scan for the best WARP IP](#scan-for-the-best-warp-ip) 36 | - [Configuration](#-configuration) 37 | - [Performance](#-performance) 38 | - [Community](#-community) 39 | - [Credits](#-credits) 40 | - [Notice of Non-Affiliation and Disclaimer](#notice-of-non-affiliation-and-disclaimer) 41 | - [Star History](#star-history) 42 | 43 | ## 💡 Motivation 44 | 45 | Many VPN clients, including official WireGuard and Cloudflare WARP clients, often require kernel-level access or route all system traffic through the tunnel by default. `cloudflare-warp` was created to provide a more flexible, lightweight, and cross-platform alternative. 46 | 47 | ## ✨ Features 48 | 49 | - **Cross-Platform**: Runs on Linux/macOS/Windows/FreeBSD/OpenBSD with platform-specific optimizations. 50 | - **User-Space Networking**: Implements a user-space networking stack to handle traffic, avoiding the need for kernel-level privileges. 51 | - **Proxy Support**: Includes support for HTTP and Socks5 proxies for secure and private browsing. 52 | - **Full SOCKS Support:** Implements Socks5 with TCP (`CONNECT`) and UDP (`ASSOCIATE`) support. 53 | - **DPI Evasion**: Utilizes techniques by `AmneziaWG` and `uTLS` helping to confuse Deep Packet Inspection (DPI) systems. 54 | - **IP Scanner**: Built-in scanner to find the best Cloudflare WARP IP addresses with optimal RTT. 55 | 56 | ## 🚀 Installation 57 | 58 | There are multiple ways to install `cloudflare-warp`. 59 | 60 | ### From GitHub Releases 61 | 62 | You can download pre-compiled binaries for various operating systems and architectures from the [releases page](https://github.com/shahradelahi/cloudflare-warp/releases). 63 | 64 | ### Building from Source 65 | 66 | Since the tool is written in Go, it should be rather trivial. 67 | 68 | 1. Ensure that you have Go installed on your system. You can download it from [here](https://golang.org/dl/). At least Go 1.24.4 is required (as per `go.mod`). 69 | 70 | 2. Clone this repository and switch to the project's root directory: 71 | 72 | ```bash 73 | git clone https://github.com/shahradelahi/cloudflare-warp.git 74 | cd cloudflare-warp 75 | ``` 76 | 77 | 3. Build the project using the `Makefile`: 78 | 79 | ```bash 80 | make cloudflare-warp 81 | ``` 82 | 83 | The compiled binary will be located in the `build/` directory. 84 | 85 | If you would rather cross compile, set the `GOOS` and `GOARCH` environment variables accordingly. For example, to build for Windows on a Linux system: 86 | 87 | ```bash 88 | GOOS=windows GOARCH=amd64 CGO_ENABLED=0 make cloudflare-warp 89 | ``` 90 | 91 | ### Docker 92 | 93 | You can also run `cloudflare-warp` using Docker. A `Dockerfile` is provided in the repository. 94 | 95 | To build the image, run: 96 | 97 | ```bash 98 | docker build -t cloudflare-warp . 99 | ``` 100 | 101 | Example usage (spawns a SOCKS proxy and exposes it on port 1080): 102 | 103 | ```bash 104 | docker run -d \ 105 | --name cloudflare-warp \ 106 | -v ./warp-data:/var/lib/cloudflare-warp \ 107 | -p 1080:1080 \ 108 | --restart=unless-stopped \ 109 | cloudflare-warp --socks-addr 0.0.0.0:1080 110 | ``` 111 | **Note:** Inside the container, the proxy must bind to `0.0.0.0` to be accessible from outside. 112 | 113 | ## ⚙️ Usage 114 | 115 | Run `warp` in a terminal without any arguments to display the help screen. All commands and parameters are documented. 116 | 117 | ```bash 118 | ./warp --help 119 | ``` 120 | 121 | ### Command-line Flags 122 | 123 | Run `warp --help` for detailed information on each command's flags. 124 | 125 | ### Register a new account 126 | 127 | This command creates a fresh WARP account and saves the identity files. 128 | 129 | ```bash 130 | warp generate 131 | ``` 132 | 133 | ### Add a license key 134 | 135 | If you have an existing Warp+ subscription, you can bind the account generated by this tool to your phone's account, sharing its Warp+ status. Please note that there is a limit of 5 maximum devices linked at a time. You can remove linked devices from the 1.1.1.1 app on your phone. 136 | 137 | > [!CAUTION] 138 | > Only subscriptions purchased directly from the official 1.1.1.1 app are supported. Keys obtained by any other means, including referrals, will not work and will not be supported. 139 | 140 | First, get your Warp+ account license key. To view it on Android: 141 | 1. Open the `1.1.1.1` app 142 | 2. Click on the hamburger menu button in the top-right corner 143 | 3. Navigate to: `Account` > `Key` 144 | 145 | Now, use the command below to update the account's name and license: 146 | 147 | ```bash 148 | warp update --name "My Warp Device" --license "YOUR_LICENSE_KEY" 149 | ``` 150 | 151 | ### Generate WireGuard configuration 152 | 153 | This command generates and prints the WireGuard configuration based on your WARP identity. 154 | 155 | ```bash 156 | warp generate 157 | ``` 158 | 159 | ### Check device status 160 | 161 | Run the following command in a terminal to check the status of your current Cloudflare Warp device: 162 | 163 | ```bash 164 | warp status 165 | ``` 166 | 167 | ### Verify Warp/Warp+ works 168 | 169 | After connecting to the WARP proxy (see `Run the WARP proxy` section), you can verify that Warp/Warp+ is working by checking your IP address or visiting a Cloudflare trace page. 170 | 171 | **Using `curl`:** 172 | 173 | ```bash 174 | curl -x socks5://127.0.0.1:1080 https://cloudflare.com/cdn-cgi/trace 175 | # Or for HTTP proxy: 176 | curl -x http://127.0.0.1:8118 https://cloudflare.com/cdn-cgi/trace 177 | ``` 178 | 179 | Look for `warp=on` or `warp=plus` in the output. 180 | 181 | **Using a web browser:** 182 | 183 | Open your browser and navigate to `https://cloudflare.com/cdn-cgi/trace/`. Look for `warp=on` or `warp=plus` on the page. 184 | 185 | ### Run the WARP proxy 186 | 187 | This command starts the proxy server and establishes a connection to the Cloudflare network. You can configure the proxy to use Socks5 or HTTP, specify WARP endpoints, and enable features like WARP+ connections. 188 | 189 | **Example: Run with a SOCKS5 proxy on port 1080 and an HTTP proxy on port 8118.** 190 | 191 | ```bash 192 | warp run --socks-addr 127.0.0.1:1080 --http-addr 127.0.0.1:8118 193 | ``` 194 | 195 | **Example: Run with IP scanning enabled to find the best endpoint.** 196 | 197 | ```bash 198 | warp run --scan --4 --rtt 500ms 199 | ``` 200 | This will scan for IPv4 endpoints with a maximum RTT of 500ms. 201 | 202 | ### Scan for the best WARP IP 203 | 204 | This command scans for the best Cloudflare WARP IP addresses by testing a list of known CIDRs. It measures the Round-Trip Time (RTT) and displays a list of the fastest available endpoints. This is useful for finding optimal endpoints to use with the `run` command for better performance. 205 | 206 | **Example: Scan for IPv4 endpoints with a maximum RTT of 1000ms.** 207 | 208 | ```bash 209 | warp scanner --ipv4 --rtt 1000ms 210 | ``` 211 | 212 | ## 📁 Configuration 213 | 214 | For simplicity, the tool stores its identity and configuration in JSON files within a data directory. By default, this is `~/.cloudflare-warp` on Linux/macOS or a platform-specific equivalent. You can specify a different data directory using the `--data-dir` flag. 215 | 216 | The primary configuration files are: 217 | - `reg.json`: Contains your WARP registration ID, token, and private key. **Confidential.** 218 | - `conf.json`: Contains your WARP account details (license, quota, etc.) and the WARP configuration (peers, interface addresses). 219 | 220 | These files are automatically managed by the `warp` commands (e.g., `generate`, `update`). 221 | 222 | ## ⚡ Performance 223 | 224 | The project is in active development, and performance is a continuous focus. While the official client leverages highly optimized implementations, `cloudflare-warp` aims to provide a robust user-space solution. Performance can vary based on network conditions and system resources. 225 | 226 | ## 💬 Community 227 | 228 | Welcome and feel free to ask any questions at [Discussions](https://github.com/shahradelahi/cloudflare-warp/discussions). 229 | 230 | ## 🙏 Credits 231 | 232 | - [Cloudflare WARP](https://developers.cloudflare.com/cloudflare-one/connections/connect-devices/warp/): For the WARP service this project connects to. 233 | - [wiresocks](https://github.com/shahradelahi/wiresocks) - A user-space WireGuard client that exposes SOCKS and HTTP proxies, which this project builds upon. 234 | - [amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) - Go Implementation of Amnezia WireGuard. 235 | - [utls](https://github.com/refraction-networking/utls) - A Go library for custom TLS client hellos, used for DPI evasion. 236 | - [cobra](https://github.com/spf13/cobra) - Powerful CLI library for Go. 237 | - And many other open-source projects and contributors that make this possible. 238 | 239 | ## ⚠️ Notice of Non-Affiliation and Disclaimer 240 | 241 | We are not affiliated, associated, authorized, endorsed by, or in any way officially connected with Cloudflare, or any of its subsidiaries or its affiliates. The official Cloudflare website can be found at https://www.cloudflare.com/. 242 | 243 | The names Cloudflare Warp and Cloudflare as well as related names, marks, emblems and images are registered trademarks of their respective owners. 244 | 245 | This tool is an independent open-source project and is provided "as is" without any guarantees. Use at your own risk. We are not responsible for any consequences that may arise from using this tool, including but not limited to system damage, network issues, or legal implications. 246 | 247 | ## License 248 | 249 | [MIT](/LICENSE) © [Shahrad Elahi](https://github.com/shahradelahi) and [contributors](https://github.com/shahradelahi/aes-object/graphs/contributors). 250 | 251 | ## ⭐ Star History 252 | 253 | 254 | 255 | 256 | 257 | Star History Chart 258 | 259 | 260 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/amnezia-vpn/amneziawg-go v0.2.13 h1:xFxCumnmtYTOMeg14MPG53p9RpmvSnjrDEB1bFma79A= 2 | github.com/amnezia-vpn/amneziawg-go v0.2.13/go.mod h1:TB75yoG4yP6nVcJY3CPWstDcgOtlouC4xWyE7U9XqzU= 3 | github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= 4 | github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= 5 | github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= 6 | github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= 7 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 8 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 12 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 13 | github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg= 14 | github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= 15 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 16 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 17 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 18 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 19 | github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= 20 | github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= 21 | github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= 22 | github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 23 | github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= 24 | github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= 25 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 26 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 27 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 28 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 29 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 30 | github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= 31 | github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= 32 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 33 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 34 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 35 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 36 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 37 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 38 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 39 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 40 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 41 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 42 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 43 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 44 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 45 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 46 | github.com/noql-net/certpool v0.0.0-20250713011742-73291c48ecc1 h1:hZ1XozfYy+rakCtLubBSBy5Fo34tTvO3Aixr3kIn/+Q= 47 | github.com/noql-net/certpool v0.0.0-20250713011742-73291c48ecc1/go.mod h1:taGk+qgyZvWBaBI8hZPnbq/CsnRk7Tbiqw7Yr19X97s= 48 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 49 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 50 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 51 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 52 | github.com/refraction-networking/utls v1.8.0 h1:L38krhiTAyj9EeiQQa2sg+hYb4qwLCqdMcpZrRfbONE= 53 | github.com/refraction-networking/utls v1.8.0/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= 54 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 55 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 56 | github.com/rodaine/table v1.3.0 h1:4/3S3SVkHnVZX91EHFvAMV7K42AnJ0XuymRR2C5HlGE= 57 | github.com/rodaine/table v1.3.0/go.mod h1:47zRsHar4zw0jgxGxL9YtFfs7EGN6B/TaS+/Dmk4WxU= 58 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 59 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 60 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 61 | github.com/sagernet/sing v0.7.5 h1:gNMwZCLPqR+4e0g6dwi0sSsrvOmoMjpZgqxKsuJZatc= 62 | github.com/sagernet/sing v0.7.5/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= 63 | github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 64 | github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= 65 | github.com/shahradelahi/wiresocks v0.0.0-20250814100958-4c0b16594e4d h1:LQJoupBK6OI6nQeFY+8Bu8D07XrK7ETdK6Rqw+TKrl4= 66 | github.com/shahradelahi/wiresocks v0.0.0-20250814100958-4c0b16594e4d/go.mod h1:VA5Kh0NKeizJUFXo8hw72mWTQmJNviRB1SrgTqquVNc= 67 | github.com/shahradelahi/wiresocks v0.0.0-20250814224151-c57fc1e4137d h1:Qjg9/DlSSZ2FkepCiv8iV+kYR8SCd1/lGTNbdi5Ikck= 68 | github.com/shahradelahi/wiresocks v0.0.0-20250814224151-c57fc1e4137d/go.mod h1:VA5Kh0NKeizJUFXo8hw72mWTQmJNviRB1SrgTqquVNc= 69 | github.com/shahradelahi/wiresocks v0.0.0-20250815002029-d2ed70aae079 h1:CnlE3P1i5fw6BPdZeGosSjgCQs1k4OgrJuoj76N2i5s= 70 | github.com/shahradelahi/wiresocks v0.0.0-20250815002029-d2ed70aae079/go.mod h1:VA5Kh0NKeizJUFXo8hw72mWTQmJNviRB1SrgTqquVNc= 71 | github.com/shahradelahi/wiresocks v0.0.0-20250819105937-eada7aea2058 h1:hNrh5t3t5AiQnmVKrWV5lHm5j9sABu8k8kBOrzpx5Y8= 72 | github.com/shahradelahi/wiresocks v0.0.0-20250819105937-eada7aea2058/go.mod h1:FV5GSSd5nMYkTEl1k7+jhRb1n+Ea/z25AGaxiEteyuE= 73 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 74 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 75 | github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= 76 | github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= 77 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 78 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 79 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 80 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 81 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 82 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 83 | github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= 84 | github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 85 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 86 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 87 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 88 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 89 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 90 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 91 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 92 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 93 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 94 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 95 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 96 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 97 | github.com/tevino/abool v1.2.0 h1:heAkClL8H6w+mK5md9dzsuohKeXHUpY7Vw0ZCKW+huA= 98 | github.com/tevino/abool v1.2.0/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K07Tzg= 99 | go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 100 | go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 101 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 102 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 103 | go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= 104 | go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 105 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 106 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 107 | golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 108 | golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= 109 | golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 110 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 111 | golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= 112 | golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= 113 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 114 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 115 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 116 | golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 117 | golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 118 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 119 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 120 | golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 121 | golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 122 | golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 123 | golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 124 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 125 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= 126 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= 127 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 128 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 129 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 130 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 131 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 132 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 133 | gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI= 134 | gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g= 135 | -------------------------------------------------------------------------------- /cloudflare/api.go: -------------------------------------------------------------------------------- 1 | package cloudflare 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net" 11 | "net/http" 12 | "time" 13 | 14 | "github.com/shahradelahi/cloudflare-warp/cloudflare/model" 15 | "github.com/shahradelahi/cloudflare-warp/cloudflare/network" 16 | ) 17 | 18 | const ( 19 | apiBase string = "https://api.cloudflareclient.com/v0a1922" 20 | ) 21 | 22 | func defaultHeaders() map[string]string { 23 | return map[string]string{ 24 | "Content-Type": "application/json; charset=UTF-8", 25 | "User-Agent": "okhttp/3.12.1", 26 | "CF-Client-Version": "a-6.30-3596", 27 | } 28 | } 29 | 30 | type WarpAPI struct { 31 | client *http.Client 32 | } 33 | 34 | func NewWarpAPI() *WarpAPI { 35 | tlsDialer := network.Dialer{} 36 | // Create a custom HTTP transport 37 | transport := &http.Transport{ 38 | DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) { 39 | return tlsDialer.TLSDial(network, addr) 40 | }, 41 | } 42 | 43 | return &WarpAPI{ 44 | client: &http.Client{Transport: transport}, 45 | } 46 | } 47 | 48 | func (w *WarpAPI) GetAccount(authToken, deviceID string) (model.IdentityAccount, error) { 49 | reqUrl := fmt.Sprintf("%s/reg/%s/account", apiBase, deviceID) 50 | method := "GET" 51 | 52 | req, err := http.NewRequest(method, reqUrl, nil) 53 | if err != nil { 54 | return model.IdentityAccount{}, err 55 | } 56 | 57 | // Set headers 58 | for k, v := range defaultHeaders() { 59 | req.Header.Set(k, v) 60 | } 61 | req.Header.Set("Authorization", "Bearer "+authToken) 62 | 63 | // Create HTTP client and execute request 64 | resp, err := w.client.Do(req) 65 | if err != nil { 66 | return model.IdentityAccount{}, err 67 | } 68 | defer resp.Body.Close() 69 | 70 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 71 | return model.IdentityAccount{}, fmt.Errorf("API request failed with status: %s", resp.Status) 72 | } 73 | 74 | // convert response to byte array 75 | responseData, err := io.ReadAll(resp.Body) 76 | if err != nil { 77 | return model.IdentityAccount{}, err 78 | } 79 | 80 | var rspData = model.IdentityAccount{} 81 | if err := json.Unmarshal(responseData, &rspData); err != nil { 82 | return model.IdentityAccount{}, err 83 | } 84 | 85 | return rspData, nil 86 | } 87 | 88 | func (w *WarpAPI) GetBoundDevices(authToken, deviceID string) ([]model.IdentityDevice, error) { 89 | reqUrl := fmt.Sprintf("%s/reg/%s/account/devices", apiBase, deviceID) 90 | method := "GET" 91 | 92 | req, err := http.NewRequest(method, reqUrl, nil) 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | // Set headers 98 | for k, v := range defaultHeaders() { 99 | req.Header.Set(k, v) 100 | } 101 | req.Header.Set("Authorization", "Bearer "+authToken) 102 | 103 | // Create HTTP client and execute request 104 | resp, err := w.client.Do(req) 105 | if err != nil { 106 | return nil, err 107 | } 108 | defer resp.Body.Close() 109 | 110 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 111 | return nil, fmt.Errorf("API request failed with status: %s", resp.Status) 112 | } 113 | 114 | // convert response to byte array 115 | responseData, err := io.ReadAll(resp.Body) 116 | if err != nil { 117 | return nil, err 118 | } 119 | 120 | var rspData = []model.IdentityDevice{} 121 | if err := json.Unmarshal(responseData, &rspData); err != nil { 122 | return nil, err 123 | } 124 | 125 | return rspData, nil 126 | } 127 | 128 | func (w *WarpAPI) GetSourceBoundDevice(authToken, deviceID string) (*model.IdentityDevice, error) { 129 | devices, err := w.GetBoundDevices(authToken, deviceID) 130 | if err != nil { 131 | return nil, err 132 | } 133 | 134 | for _, device := range devices { 135 | if device.ID == deviceID { 136 | return &device, nil 137 | } 138 | } 139 | 140 | return nil, errors.New("no matching bound device found") 141 | } 142 | 143 | func (w *WarpAPI) GetSourceDevice(authToken, deviceID string) (model.Identity, error) { 144 | reqUrl := fmt.Sprintf("%s/reg/%s", apiBase, deviceID) 145 | method := "GET" 146 | 147 | req, err := http.NewRequest(method, reqUrl, nil) 148 | if err != nil { 149 | return model.Identity{}, err 150 | } 151 | 152 | // Set headers 153 | for k, v := range defaultHeaders() { 154 | req.Header.Set(k, v) 155 | } 156 | req.Header.Set("Authorization", "Bearer "+authToken) 157 | 158 | // Create HTTP client and execute request 159 | resp, err := w.client.Do(req) 160 | if err != nil { 161 | return model.Identity{}, err 162 | } 163 | defer resp.Body.Close() 164 | 165 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 166 | return model.Identity{}, fmt.Errorf("API request failed with status: %s", resp.Status) 167 | } 168 | 169 | // convert response to byte array 170 | responseData, err := io.ReadAll(resp.Body) 171 | if err != nil { 172 | return model.Identity{}, err 173 | } 174 | 175 | var rspData = model.Identity{} 176 | if err := json.Unmarshal(responseData, &rspData); err != nil { 177 | return model.Identity{}, err 178 | } 179 | 180 | return rspData, nil 181 | } 182 | 183 | func (w *WarpAPI) Register(publicKey string) (model.Identity, error) { 184 | reqUrl := fmt.Sprintf("%s/reg", apiBase) 185 | method := "POST" 186 | 187 | data := map[string]interface{}{ 188 | "install_id": "", 189 | "fcm_token": "", 190 | "tos": time.Now().Format(time.RFC3339Nano), 191 | "key": publicKey, 192 | "type": "Android", 193 | "model": "PC", 194 | "locale": "en_US", 195 | "warp_enabled": true, 196 | } 197 | 198 | jsonBody, err := json.Marshal(data) 199 | if err != nil { 200 | return model.Identity{}, err 201 | } 202 | 203 | req, err := http.NewRequest(method, reqUrl, bytes.NewBuffer(jsonBody)) 204 | if err != nil { 205 | return model.Identity{}, err 206 | } 207 | 208 | // Set headers 209 | for k, v := range defaultHeaders() { 210 | req.Header.Set(k, v) 211 | } 212 | 213 | // Create HTTP client and execute request 214 | resp, err := w.client.Do(req) 215 | if err != nil { 216 | return model.Identity{}, err 217 | } 218 | defer resp.Body.Close() 219 | 220 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 221 | return model.Identity{}, fmt.Errorf("API request failed with status: %s", resp.Status) 222 | } 223 | 224 | // convert response to byte array 225 | responseData, err := io.ReadAll(resp.Body) 226 | if err != nil { 227 | return model.Identity{}, err 228 | } 229 | 230 | var rspData = model.Identity{} 231 | if err := json.Unmarshal(responseData, &rspData); err != nil { 232 | return model.Identity{}, err 233 | } 234 | 235 | return rspData, nil 236 | } 237 | 238 | func (w *WarpAPI) ResetAccountLicense(authToken, deviceID string) (model.License, error) { 239 | reqUrl := fmt.Sprintf("%s/reg/%s/account/license", apiBase, deviceID) 240 | method := "POST" 241 | 242 | req, err := http.NewRequest(method, reqUrl, nil) 243 | if err != nil { 244 | return model.License{}, err 245 | } 246 | 247 | // Set headers 248 | for k, v := range defaultHeaders() { 249 | req.Header.Set(k, v) 250 | } 251 | req.Header.Set("Authorization", "Bearer "+authToken) 252 | 253 | // Create HTTP client and execute request 254 | resp, err := w.client.Do(req) 255 | if err != nil { 256 | return model.License{}, err 257 | } 258 | defer resp.Body.Close() 259 | 260 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 261 | return model.License{}, fmt.Errorf("API request failed with response: %s", resp.Status) 262 | } 263 | 264 | // convert response to byte array 265 | responseData, err := io.ReadAll(resp.Body) 266 | if err != nil { 267 | return model.License{}, err 268 | } 269 | 270 | var rspData = model.License{} 271 | if err := json.Unmarshal(responseData, &rspData); err != nil { 272 | return model.License{}, err 273 | } 274 | 275 | return rspData, nil 276 | } 277 | 278 | func (w *WarpAPI) UpdateAccount(authToken, deviceID, license string) (model.IdentityAccount, error) { 279 | reqUrl := fmt.Sprintf("%s/reg/%s/account", apiBase, deviceID) 280 | method := "PUT" 281 | 282 | jsonBody, err := json.Marshal(map[string]interface{}{"license": license}) 283 | if err != nil { 284 | return model.IdentityAccount{}, err 285 | } 286 | 287 | req, err := http.NewRequest(method, reqUrl, bytes.NewBuffer(jsonBody)) 288 | if err != nil { 289 | return model.IdentityAccount{}, err 290 | } 291 | 292 | // Set headers 293 | for k, v := range defaultHeaders() { 294 | req.Header.Set(k, v) 295 | } 296 | req.Header.Set("Authorization", "Bearer "+authToken) 297 | 298 | // Create HTTP client and execute request 299 | resp, err := w.client.Do(req) 300 | if err != nil { 301 | return model.IdentityAccount{}, err 302 | } 303 | defer resp.Body.Close() 304 | 305 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 306 | return model.IdentityAccount{}, fmt.Errorf("API request failed with status: %s", resp.Status) 307 | } 308 | 309 | // convert response to byte array 310 | responseData, err := io.ReadAll(resp.Body) 311 | if err != nil { 312 | return model.IdentityAccount{}, err 313 | } 314 | 315 | var rspData = model.IdentityAccount{} 316 | if err := json.Unmarshal(responseData, &rspData); err != nil { 317 | return model.IdentityAccount{}, err 318 | } 319 | 320 | return rspData, nil 321 | } 322 | 323 | func (w *WarpAPI) UpdateBoundDevice(authToken, deviceID, otherDeviceID, name string, active bool) (model.IdentityDevice, error) { 324 | reqUrl := fmt.Sprintf("%s/reg/%s/account/reg/%s", apiBase, deviceID, otherDeviceID) 325 | method := "PATCH" 326 | 327 | data := map[string]interface{}{ 328 | "active": active, 329 | "name": name, 330 | } 331 | 332 | jsonBody, err := json.Marshal(data) 333 | if err != nil { 334 | return model.IdentityDevice{}, err 335 | } 336 | 337 | req, err := http.NewRequest(method, reqUrl, bytes.NewBuffer(jsonBody)) 338 | if err != nil { 339 | return model.IdentityDevice{}, err 340 | } 341 | 342 | // Set headers 343 | for k, v := range defaultHeaders() { 344 | req.Header.Set(k, v) 345 | } 346 | req.Header.Set("Authorization", "Bearer "+authToken) 347 | 348 | // Create HTTP client and execute request 349 | resp, err := w.client.Do(req) 350 | if err != nil { 351 | return model.IdentityDevice{}, err 352 | } 353 | defer resp.Body.Close() 354 | 355 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 356 | return model.IdentityDevice{}, fmt.Errorf("API request failed with status: %s", resp.Status) 357 | } 358 | 359 | // convert response to byte array 360 | responseData, err := io.ReadAll(resp.Body) 361 | if err != nil { 362 | return model.IdentityDevice{}, err 363 | } 364 | 365 | var rspData = model.IdentityDevice{} 366 | if err := json.Unmarshal(responseData, &rspData); err != nil { 367 | return model.IdentityDevice{}, err 368 | } 369 | 370 | return rspData, nil 371 | } 372 | 373 | func (w *WarpAPI) UpdateSourceDevice(authToken, deviceID, publicKey string) (model.Identity, error) { 374 | reqUrl := fmt.Sprintf("%s/reg/%s", apiBase, deviceID) 375 | method := "PATCH" 376 | 377 | jsonBody, err := json.Marshal(map[string]interface{}{"key": publicKey}) 378 | if err != nil { 379 | return model.Identity{}, err 380 | } 381 | 382 | req, err := http.NewRequest(method, reqUrl, bytes.NewBuffer(jsonBody)) 383 | if err != nil { 384 | return model.Identity{}, err 385 | } 386 | 387 | // Set headers 388 | for k, v := range defaultHeaders() { 389 | req.Header.Set(k, v) 390 | } 391 | req.Header.Set("Authorization", "Bearer "+authToken) 392 | 393 | // Create HTTP client and execute request 394 | resp, err := w.client.Do(req) 395 | if err != nil { 396 | return model.Identity{}, err 397 | } 398 | defer resp.Body.Close() 399 | 400 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 401 | return model.Identity{}, fmt.Errorf("API request failed with status: %s", resp.Status) 402 | } 403 | 404 | // convert response to byte array 405 | responseData, err := io.ReadAll(resp.Body) 406 | if err != nil { 407 | return model.Identity{}, err 408 | } 409 | 410 | var rspData = model.Identity{} 411 | if err := json.Unmarshal(responseData, &rspData); err != nil { 412 | return model.Identity{}, err 413 | } 414 | 415 | return rspData, nil 416 | } 417 | 418 | func (w *WarpAPI) DeleteDevice(authToken, deviceID string) error { 419 | reqUrl := fmt.Sprintf("%s/reg/%s", apiBase, deviceID) 420 | method := "DELETE" 421 | 422 | req, err := http.NewRequest(method, reqUrl, nil) 423 | if err != nil { 424 | return err 425 | } 426 | 427 | // Set headers 428 | for k, v := range defaultHeaders() { 429 | req.Header.Set(k, v) 430 | } 431 | req.Header.Set("Authorization", "Bearer "+authToken) 432 | 433 | // Create HTTP client and execute request 434 | resp, err := w.client.Do(req) 435 | if err != nil { 436 | return err 437 | } 438 | defer resp.Body.Close() 439 | 440 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 441 | return fmt.Errorf("API request failed with status: %s", resp.Status) 442 | } 443 | 444 | return nil 445 | } 446 | --------------------------------------------------------------------------------