forked from retoor/devplacepy
Zo doen wijdat in oost
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
# devplace-node
|
||||
|
||||
Native donor client for the DevPlace community node network. One static Go binary, standard library only.
|
||||
|
||||
## What you share
|
||||
|
||||
When running, and only inside the limits you configured:
|
||||
|
||||
- Your internet connection and your IP address, used to fetch public pages from the destinations on the allowlist (today: `reddit.com`, `redd.it` and their subdomains) over ports 80 and 443, GET only.
|
||||
- The fetched public page, which DevPlace receives and may cache for a short time.
|
||||
- Byte counters (up and down) so your daily cap and your lifetime totals are correct.
|
||||
|
||||
## What is never shared
|
||||
|
||||
- Your cookies, logins, accounts and browser. The client has no cookie jar and drops `Cookie`, `Authorization` and `Proxy-Authorization` from every request. It uses no browser profile and reads no files besides its own config.
|
||||
- Any access to your local network: addresses that are private, loopback, link-local, CGNAT or multicast are refused after local DNS resolution, and the connection goes to the address that was checked.
|
||||
- Any destination outside the baked-in allowlist. A list sent by the server can only be equal or narrower. Redirects that leave the allowlist are refused.
|
||||
- Anything on ports other than 80 and 443.
|
||||
|
||||
The device secret (`dpn_...`) is stored in the config file with mode 0600 and is never logged or printed.
|
||||
|
||||
## Use
|
||||
|
||||
```
|
||||
devplace-node login --server https://devplace.net --secret dpn_...
|
||||
devplace-node status
|
||||
devplace-node run
|
||||
devplace-node version
|
||||
```
|
||||
|
||||
Create the device secret on your DevPlace profile after consenting to bandwidth sharing. `login` asks the server to accept the secret before storing it (`--no-verify` skips that). The secret can also come from `DEVPLACE_NODE_SECRET`. Every command takes `--config PATH`.
|
||||
|
||||
`run` is quiet: it logs start, disconnects, and one totals line per day. Add `-v` for detail. Exit code 3 means the server ended this device for good (revoked, suspended, kill switch, no consent, not adult, replaced by another instance of the same device); fix the cause and start it again.
|
||||
|
||||
## Config file
|
||||
|
||||
Stored at `<user config dir>/devplace-node/config.json`, with `usage.json` (today's byte count) beside it.
|
||||
|
||||
```json
|
||||
{
|
||||
"server": "https://devplace.net",
|
||||
"secret": "dpn_...",
|
||||
"bandwidth_mb_day": 500,
|
||||
"max_concurrent": 2,
|
||||
"max_tunnels": 2,
|
||||
"active_from": "",
|
||||
"active_to": "",
|
||||
"timezone": ""
|
||||
}
|
||||
```
|
||||
|
||||
`active_from` and `active_to` are `HH:MM` in `timezone` (empty means the system timezone); a window may wrap midnight. These limits are enforced locally and the server can only make them stricter.
|
||||
|
||||
## Build and package
|
||||
|
||||
`./build.sh [outdir]` cross-compiles linux/amd64, linux/arm64, darwin/arm64, darwin/amd64 and windows/amd64 with `CGO_ENABLED=0 -trimpath`, and writes `manifest.json` (name, os, arch, sha256, size, version) beside the binaries. `VERSION=1.2.3 ./build.sh` overrides the version.
|
||||
|
||||
`packaging/` holds a systemd user unit (`loginctl enable-linger $USER` keeps it running without a login), a launchd plist, a Windows scheduled-task script and a container compose file.
|
||||
|
||||
## Tests
|
||||
|
||||
`go vet ./... && go test ./...`
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
out=${1:-./dist}
|
||||
version=${VERSION:-$(git -C "$here" describe --tags --always --dirty 2>/dev/null || echo dev)}
|
||||
targets="linux/amd64 linux/arm64 darwin/arm64 darwin/amd64 windows/amd64"
|
||||
|
||||
mkdir -p "$out"
|
||||
out=$(cd "$out" && pwd)
|
||||
manifest="$out/manifest.json"
|
||||
rm -f "$manifest"
|
||||
|
||||
hash_of() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$1" | cut -d' ' -f1
|
||||
else
|
||||
shasum -a 256 "$1" | cut -d' ' -f1
|
||||
fi
|
||||
}
|
||||
|
||||
size_of() {
|
||||
wc -c <"$1" | tr -d ' '
|
||||
}
|
||||
|
||||
printf '[\n' >"$manifest"
|
||||
first=1
|
||||
for target in $targets; do
|
||||
goos=${target%/*}
|
||||
goarch=${target#*/}
|
||||
name="devplace-node-$goos-$goarch"
|
||||
if [ "$goos" = "windows" ]; then
|
||||
name="$name.exe"
|
||||
fi
|
||||
(cd "$here" && CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags "-s -w -X main.Version=$version" -o "$out/$name" .)
|
||||
if [ "$first" -eq 0 ]; then
|
||||
printf ',\n' >>"$manifest"
|
||||
fi
|
||||
first=0
|
||||
printf ' {"name": "%s", "os": "%s", "arch": "%s", "sha256": "%s", "size": %s, "version": "%s"}' \
|
||||
"$name" "$goos" "$goarch" "$(hash_of "$out/$name")" "$(size_of "$out/$name")" "$version" >>"$manifest"
|
||||
done
|
||||
printf '\n]\n' >>"$manifest"
|
||||
|
||||
for target in $targets; do
|
||||
goos=${target%/*}
|
||||
goarch=${target#*/}
|
||||
name="devplace-node-$goos-$goarch"
|
||||
if [ "$goos" = "windows" ]; then
|
||||
name="$name.exe"
|
||||
fi
|
||||
echo "$out/$name"
|
||||
done
|
||||
echo "$manifest"
|
||||
@@ -0,0 +1,177 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultBandwidthMB = 500
|
||||
DefaultMaxConcurrent = 2
|
||||
DefaultMaxTunnels = 2
|
||||
ConfigDirName = "devplace-node"
|
||||
ConfigFileName = "config.json"
|
||||
UsageFileName = "usage.json"
|
||||
maxConfigBytes = 64 * 1024
|
||||
)
|
||||
|
||||
var hoursPattern = regexp.MustCompile(`^([01]\d|2[0-3]):[0-5]\d$`)
|
||||
|
||||
type Config struct {
|
||||
Server string `json:"server"`
|
||||
Secret string `json:"secret"`
|
||||
BandwidthMBDay int `json:"bandwidth_mb_day"`
|
||||
MaxConcurrent int `json:"max_concurrent"`
|
||||
MaxTunnels int `json:"max_tunnels"`
|
||||
ActiveFrom string `json:"active_from"`
|
||||
ActiveTo string `json:"active_to"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
BandwidthMBDay: DefaultBandwidthMB,
|
||||
MaxConcurrent: DefaultMaxConcurrent,
|
||||
MaxTunnels: DefaultMaxTunnels,
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultConfigPath() (string, error) {
|
||||
base, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot find the user config directory: %w", err)
|
||||
}
|
||||
return filepath.Join(base, ConfigDirName, ConfigFileName), nil
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
parsed, err := url.Parse(c.Server)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return errors.New("server must be an http or https url such as https://devplace.net")
|
||||
}
|
||||
if !strings.HasPrefix(c.Secret, SecretPrefix) || len(c.Secret) < len(SecretPrefix)+8 {
|
||||
return errors.New("secret must be a device secret starting with " + SecretPrefix)
|
||||
}
|
||||
if c.BandwidthMBDay < 1 || c.BandwidthMBDay > MaxDonorMBPerDay {
|
||||
return fmt.Errorf("bandwidth_mb_day must be between 1 and %d", MaxDonorMBPerDay)
|
||||
}
|
||||
if c.MaxConcurrent < 1 || c.MaxConcurrent > MaxConcurrentCeiling {
|
||||
return fmt.Errorf("max_concurrent must be between 1 and %d", MaxConcurrentCeiling)
|
||||
}
|
||||
if c.MaxTunnels < 1 || c.MaxTunnels > MaxTunnelsCeiling {
|
||||
return fmt.Errorf("max_tunnels must be between 1 and %d", MaxTunnelsCeiling)
|
||||
}
|
||||
for _, value := range []string{c.ActiveFrom, c.ActiveTo} {
|
||||
if value != "" && !hoursPattern.MatchString(value) {
|
||||
return errors.New("active_from and active_to must be empty or HH:MM")
|
||||
}
|
||||
}
|
||||
if (c.ActiveFrom == "") != (c.ActiveTo == "") {
|
||||
return errors.New("active_from and active_to must be set together")
|
||||
}
|
||||
if _, err := c.Location(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) Location() (*time.Location, error) {
|
||||
if c.Timezone == "" {
|
||||
return time.Local, nil
|
||||
}
|
||||
location, err := time.LoadLocation(c.Timezone)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("timezone %q is not known", c.Timezone)
|
||||
}
|
||||
return location, nil
|
||||
}
|
||||
|
||||
func (c Config) BaseURL() string {
|
||||
return strings.TrimRight(c.Server, "/")
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (Config, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Config{}, fmt.Errorf("no config at %s, run: devplace-node login --server URL --secret dpn_...", path)
|
||||
}
|
||||
return Config{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
config := DefaultConfig()
|
||||
decoder := json.NewDecoder(&limitedReader{r: file, left: maxConfigBytes})
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&config); err != nil {
|
||||
return Config{}, fmt.Errorf("config %s is invalid: %w", path, err)
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
return Config{}, fmt.Errorf("config %s is invalid: %w", path, err)
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
type limitedReader struct {
|
||||
r *os.File
|
||||
left int
|
||||
}
|
||||
|
||||
func (l *limitedReader) Read(p []byte) (int, error) {
|
||||
if l.left <= 0 {
|
||||
return 0, errors.New("file is too large")
|
||||
}
|
||||
if len(p) > l.left {
|
||||
p = p[:l.left]
|
||||
}
|
||||
n, err := l.r.Read(p)
|
||||
l.left -= n
|
||||
return n, err
|
||||
}
|
||||
|
||||
func SaveConfig(path string, config Config) error {
|
||||
if err := config.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileAtomic(path, append(data, '\n'))
|
||||
}
|
||||
|
||||
func writeFileAtomic(path string, data []byte) error {
|
||||
temp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := temp.Name()
|
||||
if err := temp.Chmod(0o600); err != nil {
|
||||
temp.Close()
|
||||
os.Remove(name)
|
||||
return err
|
||||
}
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
temp.Close()
|
||||
os.Remove(name)
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
os.Remove(name)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(name, path); err != nil {
|
||||
os.Remove(name)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func validConfig() Config {
|
||||
config := DefaultConfig()
|
||||
config.Server = "https://devplace.net"
|
||||
config.Secret = "dpn_abcdefghijklmnop"
|
||||
return config
|
||||
}
|
||||
|
||||
func TestConfigRoundTripShapeAndMode(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sub", "config.json")
|
||||
if err := SaveConfig(path, validConfig()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
keys := map[string]any{}
|
||||
if err := json.Unmarshal(raw, &keys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"server", "secret", "bandwidth_mb_day", "max_concurrent", "max_tunnels", "active_from", "active_to", "timezone"} {
|
||||
if _, ok := keys[key]; !ok {
|
||||
t.Errorf("missing key %s", key)
|
||||
}
|
||||
}
|
||||
if len(keys) != 8 {
|
||||
t.Errorf("unexpected keys %v", keys)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
info, _ := os.Stat(path)
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("mode %v", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
loaded, err := LoadConfig(path)
|
||||
if err != nil || loaded != validConfig() {
|
||||
t.Fatalf("loaded %+v %v", loaded, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidationTable(t *testing.T) {
|
||||
cases := map[string]func(*Config){
|
||||
"server scheme": func(c *Config) { c.Server = "ftp://x" },
|
||||
"server empty": func(c *Config) { c.Server = "" },
|
||||
"secret prefix": func(c *Config) { c.Secret = "abc_1234567890" },
|
||||
"secret short": func(c *Config) { c.Secret = "dpn_" },
|
||||
"bandwidth low": func(c *Config) { c.BandwidthMBDay = 0 },
|
||||
"bandwidth high": func(c *Config) { c.BandwidthMBDay = MaxDonorMBPerDay + 1 },
|
||||
"concurrency": func(c *Config) { c.MaxConcurrent = 99 },
|
||||
"tunnels": func(c *Config) { c.MaxTunnels = 0 },
|
||||
"hours format": func(c *Config) { c.ActiveFrom, c.ActiveTo = "9:00", "17:00" },
|
||||
"hours lone": func(c *Config) { c.ActiveFrom = "09:00" },
|
||||
"timezone": func(c *Config) { c.Timezone = "Mars/Base" },
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
config := validConfig()
|
||||
mutate(&config)
|
||||
if config.Validate() == nil {
|
||||
t.Errorf("%s accepted", name)
|
||||
}
|
||||
}
|
||||
good := validConfig()
|
||||
good.ActiveFrom, good.ActiveTo, good.Timezone = "22:00", "06:00", "Europe/Amsterdam"
|
||||
if err := good.Validate(); err != nil {
|
||||
t.Errorf("good config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnknownFieldsAndMissingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
if _, err := LoadConfig(path); err == nil || !strings.Contains(err.Error(), "login") {
|
||||
t.Errorf("missing file: %v", err)
|
||||
}
|
||||
_ = os.WriteFile(path, []byte(`{"server":"https://x.y","secret":"dpn_abcdefghijk","surprise":1}`), 0o600)
|
||||
if _, err := LoadConfig(path); err == nil {
|
||||
t.Error("unknown field accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandsVersionAndUsage(t *testing.T) {
|
||||
var out, errOut strings.Builder
|
||||
if code := execute([]string{"version"}, &out, &errOut); code != exitOK || !strings.Contains(out.String(), Version) {
|
||||
t.Fatalf("version: %d %q", code, out.String())
|
||||
}
|
||||
if code := execute(nil, &out, &errOut); code != exitUsage {
|
||||
t.Fatalf("no args: %d", code)
|
||||
}
|
||||
if code := execute([]string{"bogus"}, &out, &errOut); code != exitUsage {
|
||||
t.Fatalf("bogus: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginStoresWithoutVerifyAndNeverEchoesSecret(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
var out, errOut strings.Builder
|
||||
args := []string{"--config", path, "--server", "https://devplace.net", "--secret", "dpn_abcdefghijklmnop", "--no-verify"}
|
||||
if code := execute(append([]string{"login"}, args...), &out, &errOut); code != exitOK {
|
||||
t.Fatalf("login: %d %s", code, errOut.String())
|
||||
}
|
||||
if strings.Contains(out.String()+errOut.String(), "dpn_abcdefghijklmnop") {
|
||||
t.Fatal("secret was printed")
|
||||
}
|
||||
out.Reset()
|
||||
if code := execute([]string{"login", "--config", path, "--server", "nope", "--secret", "x"}, &out, &errOut); code != exitUsage {
|
||||
t.Fatalf("bad login accepted: %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var hopByHop = map[string]bool{
|
||||
"connection": true, "keep-alive": true, "proxy-authenticate": true, "proxy-authorization": true,
|
||||
"proxy-connection": true, "te": true, "trailer": true, "trailers": true,
|
||||
"transfer-encoding": true, "upgrade": true, "content-length": true, "host": true,
|
||||
}
|
||||
|
||||
var identityHeaders = map[string]bool{
|
||||
"cookie": true, "cookie2": true, "authorization": true, "set-cookie": true, "set-cookie2": true,
|
||||
}
|
||||
|
||||
type FetchResult struct {
|
||||
Status int
|
||||
Headers map[string]string
|
||||
Body []byte
|
||||
Truncated bool
|
||||
Elapsed time.Duration
|
||||
}
|
||||
|
||||
type Fetcher struct {
|
||||
policy *Policy
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewFetcher(policy *Policy) *Fetcher {
|
||||
transport := &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: policy.DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: 20 * time.Second,
|
||||
MaxResponseHeaderBytes: 64 * 1024,
|
||||
MaxIdleConns: 4,
|
||||
MaxIdleConnsPerHost: 2,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Jar: nil,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) > MaxRedirects {
|
||||
return refuse(RefuseRedirect, "too many redirects")
|
||||
}
|
||||
_, err := policy.CheckRedirect(req.URL.String(), req.Method)
|
||||
return err
|
||||
},
|
||||
}
|
||||
return &Fetcher{policy: policy, client: client}
|
||||
}
|
||||
|
||||
func requestHeaders(source map[string]any) http.Header {
|
||||
header := http.Header{}
|
||||
count := 0
|
||||
for name, raw := range source {
|
||||
value, ok := raw.(string)
|
||||
lower := strings.ToLower(strings.TrimSpace(name))
|
||||
if !ok || lower == "" || hopByHop[lower] || identityHeaders[lower] || len(value) > MaxHeaderValueBytes {
|
||||
continue
|
||||
}
|
||||
if strings.ContainsAny(value, "\r\n") || strings.ContainsAny(lower, "\r\n: ") {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if count > MaxRequestHeaders {
|
||||
break
|
||||
}
|
||||
header.Set(lower, value)
|
||||
}
|
||||
return header
|
||||
}
|
||||
|
||||
func responseHeaders(source http.Header) map[string]string {
|
||||
out := map[string]string{}
|
||||
for name, values := range source {
|
||||
lower := strings.ToLower(name)
|
||||
if hopByHop[lower] || identityHeaders[lower] || len(out) >= MaxResponseHeaders {
|
||||
continue
|
||||
}
|
||||
joined := strings.Join(values, ", ")
|
||||
if len(joined) > MaxHeaderValueBytes {
|
||||
joined = joined[:MaxHeaderValueBytes]
|
||||
}
|
||||
out[lower] = joined
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ErrorCode(err error) string {
|
||||
var policyErr *PolicyError
|
||||
if errors.As(err, &policyErr) {
|
||||
return policyErr.Code
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return RefuseTimeout
|
||||
}
|
||||
return FailFetch
|
||||
}
|
||||
|
||||
func clampTimeout(seconds int) time.Duration {
|
||||
if seconds < 1 {
|
||||
seconds = DefaultJobTimeout
|
||||
}
|
||||
if seconds > MaxJobTimeout {
|
||||
seconds = MaxJobTimeout
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func (f *Fetcher) Do(ctx context.Context, frame Frame, maxBody int, budget int64) (*FetchResult, error) {
|
||||
target, err := f.policy.Check(frame.URL, frame.Method, "fetch")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, clampTimeout(frame.Timeout))
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
request, err := http.NewRequestWithContext(ctx, target.Method, target.URL, nil)
|
||||
if err != nil {
|
||||
return nil, refuse(RefuseBadURL, "request cannot be built")
|
||||
}
|
||||
request.Header = requestHeaders(frame.Headers)
|
||||
response, err := f.client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
allowed := int64(maxBody)
|
||||
if budget < allowed {
|
||||
allowed = budget
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, allowed+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
truncated := int64(len(body)) > allowed
|
||||
if truncated {
|
||||
body = body[:allowed]
|
||||
}
|
||||
return &FetchResult{
|
||||
Status: response.StatusCode,
|
||||
Headers: responseHeaders(response.Header),
|
||||
Body: body,
|
||||
Truncated: truncated,
|
||||
Elapsed: time.Since(started),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fetchRig struct {
|
||||
policy *Policy
|
||||
f *Fetcher
|
||||
mu sync.Mutex
|
||||
dialed []string
|
||||
}
|
||||
|
||||
func newFetchRig(t *testing.T, handler http.Handler) *fetchRig {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
rig := &fetchRig{}
|
||||
rig.policy = testPolicy(publicAnswer)
|
||||
rig.policy.dial = func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
rig.mu.Lock()
|
||||
rig.dialed = append(rig.dialed, address)
|
||||
rig.mu.Unlock()
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, "tcp", server.Listener.Addr().String())
|
||||
}
|
||||
rig.f = NewFetcher(rig.policy)
|
||||
return rig
|
||||
}
|
||||
|
||||
func job(url string) Frame {
|
||||
return Frame{Type: "job", JobID: "j1", Method: "GET", URL: url, Timeout: 5}
|
||||
}
|
||||
|
||||
func TestFetchSuccessStripsIdentityAndHopByHop(t *testing.T) {
|
||||
var seen http.Header
|
||||
rig := newFetchRig(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = r.Header.Clone()
|
||||
w.Header().Set("Set-Cookie", "session=abc")
|
||||
w.Header().Set("X-Thing", "kept")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
frame := job("http://www.reddit.com/r/go.json")
|
||||
frame.Headers = map[string]any{
|
||||
"Cookie": "a=b", "Authorization": "Bearer x", "Proxy-Authorization": "y", "Connection": "close",
|
||||
"Accept": "application/json", "X-Bad": "a\r\nb", "Host": "evil.com", "X-Number": 5,
|
||||
}
|
||||
result, err := rig.f.Do(context.Background(), frame, 1000, 1<<20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != 200 || string(result.Body) != `{"ok":true}` || result.Truncated {
|
||||
t.Fatalf("result %+v", result)
|
||||
}
|
||||
for _, name := range []string{"Cookie", "Authorization", "Proxy-Authorization", "X-Bad", "X-Number"} {
|
||||
if seen.Get(name) != "" {
|
||||
t.Errorf("%s was forwarded", name)
|
||||
}
|
||||
}
|
||||
if seen.Get("Accept") != "application/json" {
|
||||
t.Error("allowed header dropped")
|
||||
}
|
||||
if _, ok := result.Headers["set-cookie"]; ok {
|
||||
t.Error("set-cookie reported")
|
||||
}
|
||||
if result.Headers["x-thing"] != "kept" {
|
||||
t.Errorf("headers %v", result.Headers)
|
||||
}
|
||||
if len(rig.dialed) != 1 || rig.dialed[0] != "93.184.216.34:80" {
|
||||
t.Errorf("dialed %v", rig.dialed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTruncatesAtBodyCapAndBudget(t *testing.T) {
|
||||
rig := newFetchRig(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(strings.Repeat("x", 5000)))
|
||||
}))
|
||||
result, err := rig.f.Do(context.Background(), job("http://reddit.com/a"), 1000, 1<<20)
|
||||
if err != nil || len(result.Body) != 1000 || !result.Truncated {
|
||||
t.Fatalf("cap: %v %+v", err, result)
|
||||
}
|
||||
result, err = rig.f.Do(context.Background(), job("http://reddit.com/a"), 1000, 300)
|
||||
if err != nil || len(result.Body) != 300 || !result.Truncated {
|
||||
t.Fatalf("budget: %v %+v", err, result)
|
||||
}
|
||||
result, err = rig.f.Do(context.Background(), job("http://reddit.com/a"), 10000, 1<<20)
|
||||
if err != nil || len(result.Body) != 5000 || result.Truncated {
|
||||
t.Fatalf("whole: %v %+v", err, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRefusals(t *testing.T) {
|
||||
rig := newFetchRig(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
cases := []struct {
|
||||
name string
|
||||
frame Frame
|
||||
prep func()
|
||||
expect string
|
||||
}{
|
||||
{"host", job("http://evilreddit.com/"), nil, RefuseHost},
|
||||
{"port", job("http://reddit.com:8080/"), nil, RefusePort},
|
||||
{"method", Frame{JobID: "j", Method: "POST", URL: "http://reddit.com/", Timeout: 5}, nil, RefuseMethod},
|
||||
{"literal", job("http://10.0.0.1/"), nil, RefuseHost},
|
||||
{"private resolve", job("http://reddit.com/"), func() {
|
||||
rig.policy.lookup = func(context.Context, string) ([]netip.Addr, error) {
|
||||
return []netip.Addr{netip.MustParseAddr("192.168.1.10")}, nil
|
||||
}
|
||||
}, RefuseAddress},
|
||||
}
|
||||
for _, item := range cases {
|
||||
if item.prep != nil {
|
||||
item.prep()
|
||||
}
|
||||
before := len(rig.dialed)
|
||||
_, err := rig.f.Do(context.Background(), item.frame, 1000, 1<<20)
|
||||
if got := ErrorCode(err); got != item.expect {
|
||||
t.Errorf("%s: got %q (%v)", item.name, got, err)
|
||||
}
|
||||
if len(rig.dialed) != before {
|
||||
t.Errorf("%s: a connection was attempted", item.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRedirects(t *testing.T) {
|
||||
var hits []string
|
||||
rig := newFetchRig(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits = append(hits, r.Host+r.URL.Path)
|
||||
switch r.URL.Path {
|
||||
case "/to-allowed":
|
||||
http.Redirect(w, r, "http://old.reddit.com/final", http.StatusFound)
|
||||
case "/to-evil":
|
||||
http.Redirect(w, r, "http://evil.example/steal", http.StatusFound)
|
||||
case "/to-private":
|
||||
http.Redirect(w, r, "http://127.0.0.1/admin", http.StatusFound)
|
||||
case "/to-port":
|
||||
http.Redirect(w, r, "http://reddit.com:8080/x", http.StatusFound)
|
||||
case "/loop":
|
||||
http.Redirect(w, r, "/loop", http.StatusFound)
|
||||
default:
|
||||
_, _ = w.Write([]byte("final"))
|
||||
}
|
||||
}))
|
||||
result, err := rig.f.Do(context.Background(), job("http://reddit.com/to-allowed"), 1000, 1<<20)
|
||||
if err != nil || string(result.Body) != "final" {
|
||||
t.Fatalf("allowed redirect: %v", err)
|
||||
}
|
||||
for _, path := range []string{"/to-evil", "/to-private", "/to-port", "/loop"} {
|
||||
_, err := rig.f.Do(context.Background(), job("http://reddit.com"+path), 1000, 1<<20)
|
||||
if ErrorCode(err) != RefuseRedirect {
|
||||
t.Errorf("%s: got %q (%v)", path, ErrorCode(err), err)
|
||||
}
|
||||
}
|
||||
for _, hit := range hits {
|
||||
if strings.HasPrefix(hit, "evil.example") || strings.HasPrefix(hit, "127.0.0.1") {
|
||||
t.Errorf("request reached %s", hit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTimeout(t *testing.T) {
|
||||
rig := newFetchRig(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
}))
|
||||
frame := job("http://reddit.com/slow")
|
||||
frame.Timeout = 1
|
||||
started := time.Now()
|
||||
_, err := rig.f.Do(context.Background(), frame, 1000, 1<<20)
|
||||
if ErrorCode(err) != RefuseTimeout {
|
||||
t.Fatalf("got %q (%v)", ErrorCode(err), err)
|
||||
}
|
||||
if time.Since(started) > 2500*time.Millisecond {
|
||||
t.Fatal("timeout was not enforced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampTimeout(t *testing.T) {
|
||||
cases := map[int]time.Duration{0: 25 * time.Second, -3: 25 * time.Second, 7: 7 * time.Second, 500: 60 * time.Second}
|
||||
for in, want := range cases {
|
||||
if got := clampTimeout(in); got != want {
|
||||
t.Errorf("%d: %v", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module devplace-node
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,238 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
mebibyte = 1024 * 1024
|
||||
persistInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
type usageState struct {
|
||||
Day string `json:"day"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
type Limiter struct {
|
||||
mu sync.Mutex
|
||||
cfg Config
|
||||
loc *time.Location
|
||||
now func() time.Time
|
||||
statePath string
|
||||
serverLimit ServerLimits
|
||||
serverSet ServerSettings
|
||||
hasServer bool
|
||||
day string
|
||||
used int64
|
||||
active int
|
||||
lastPersist time.Time
|
||||
}
|
||||
|
||||
func NewLimiter(cfg Config, statePath string, now func() time.Time) (*Limiter, error) {
|
||||
loc, err := cfg.Location()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limiter := &Limiter{cfg: cfg, loc: loc, now: now, statePath: statePath}
|
||||
limiter.load()
|
||||
return limiter, nil
|
||||
}
|
||||
|
||||
func (l *Limiter) load() {
|
||||
if l.statePath == "" {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(l.statePath)
|
||||
if err != nil || len(data) > 4096 {
|
||||
return
|
||||
}
|
||||
var state usageState
|
||||
if json.Unmarshal(data, &state) != nil || state.Bytes < 0 {
|
||||
return
|
||||
}
|
||||
l.day, l.used = state.Day, state.Bytes
|
||||
}
|
||||
|
||||
func (l *Limiter) persist(force bool) {
|
||||
if l.statePath == "" {
|
||||
return
|
||||
}
|
||||
moment := l.now()
|
||||
if !force && moment.Sub(l.lastPersist) < persistInterval {
|
||||
return
|
||||
}
|
||||
l.lastPersist = moment
|
||||
data, err := json.Marshal(usageState{Day: l.day, Bytes: l.used})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(l.statePath), 0o700); err != nil {
|
||||
return
|
||||
}
|
||||
_ = writeFileAtomic(l.statePath, data)
|
||||
}
|
||||
|
||||
func (l *Limiter) Flush() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.roll()
|
||||
l.persist(true)
|
||||
}
|
||||
|
||||
func (l *Limiter) ApplyServer(limits *ServerLimits, settings *ServerSettings) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if limits != nil {
|
||||
l.serverLimit = *limits
|
||||
}
|
||||
if settings != nil {
|
||||
l.serverSet = *settings
|
||||
}
|
||||
l.hasServer = limits != nil || settings != nil
|
||||
}
|
||||
|
||||
func (l *Limiter) roll() {
|
||||
today := l.now().In(l.loc).Format("2006-01-02")
|
||||
if l.day != today {
|
||||
l.day = today
|
||||
l.used = 0
|
||||
}
|
||||
}
|
||||
|
||||
func minPositive(values ...int) int {
|
||||
best := 0
|
||||
for _, value := range values {
|
||||
if value > 0 && (best == 0 || value < best) {
|
||||
best = value
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func (l *Limiter) capBytes() int64 {
|
||||
mb := minPositive(l.cfg.BandwidthMBDay, l.serverSet.BandwidthMBDay, l.serverLimit.MaxDonorMBPerDay)
|
||||
return int64(mb) * mebibyte
|
||||
}
|
||||
|
||||
func (l *Limiter) concurrencyCap() int {
|
||||
return minPositive(l.cfg.MaxConcurrent, l.serverSet.MaxConcurrent, l.serverLimit.MaxConcurrentPerNode)
|
||||
}
|
||||
|
||||
func minutesOf(text string) int {
|
||||
var hours, minutes int
|
||||
if len(text) != 5 {
|
||||
return -1
|
||||
}
|
||||
hours = int(text[0]-'0')*10 + int(text[1]-'0')
|
||||
minutes = int(text[3]-'0')*10 + int(text[4]-'0')
|
||||
return hours*60 + minutes
|
||||
}
|
||||
|
||||
func InWindow(from, to string, moment time.Time) bool {
|
||||
if from == "" || to == "" || from == to {
|
||||
return true
|
||||
}
|
||||
start, end := minutesOf(from), minutesOf(to)
|
||||
if start < 0 || end < 0 {
|
||||
return true
|
||||
}
|
||||
now := moment.Hour()*60 + moment.Minute()
|
||||
if start < end {
|
||||
return now >= start && now < end
|
||||
}
|
||||
return now >= start || now < end
|
||||
}
|
||||
|
||||
func (l *Limiter) hoursCode() string {
|
||||
moment := l.now()
|
||||
if !InWindow(l.cfg.ActiveFrom, l.cfg.ActiveTo, moment.In(l.loc)) {
|
||||
return RefuseHours
|
||||
}
|
||||
if l.serverSet.ActiveFrom != "" {
|
||||
zone, err := time.LoadLocation(l.serverSet.Timezone)
|
||||
if err != nil {
|
||||
zone = time.UTC
|
||||
}
|
||||
if !InWindow(l.serverSet.ActiveFrom, l.serverSet.ActiveTo, moment.In(zone)) {
|
||||
return RefuseHours
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (l *Limiter) Acquire() string {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.roll()
|
||||
if code := l.hoursCode(); code != "" {
|
||||
return code
|
||||
}
|
||||
if l.used >= l.capBytes() {
|
||||
return RefuseBandwidth
|
||||
}
|
||||
if l.active >= l.concurrencyCap() {
|
||||
return RefuseConcurrency
|
||||
}
|
||||
l.active++
|
||||
return ""
|
||||
}
|
||||
|
||||
func (l *Limiter) Release() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.active > 0 {
|
||||
l.active--
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Limiter) Remaining() int64 {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.roll()
|
||||
left := l.capBytes() - l.used
|
||||
if left < 0 {
|
||||
return 0
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
func (l *Limiter) AddBytes(n int64) {
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.roll()
|
||||
l.used += n
|
||||
l.persist(false)
|
||||
}
|
||||
|
||||
func (l *Limiter) Usage() (day string, used, capacity int64) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.roll()
|
||||
return l.day, l.used, l.capBytes()
|
||||
}
|
||||
|
||||
func ReadUsage(path string, loc *time.Location, now time.Time) int64 {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || len(data) > 4096 {
|
||||
return 0
|
||||
}
|
||||
var state usageState
|
||||
if json.Unmarshal(data, &state) != nil || state.Day != now.In(loc).Format("2006-01-02") {
|
||||
return 0
|
||||
}
|
||||
return state.Bytes
|
||||
}
|
||||
|
||||
func (l *Limiter) ConcurrencyCap() int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.concurrencyCap()
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type clock struct{ now time.Time }
|
||||
|
||||
func (c *clock) Now() time.Time { return c.now }
|
||||
|
||||
func testLimiter(t *testing.T, cfg Config, at string) (*Limiter, *clock) {
|
||||
t.Helper()
|
||||
moment, err := time.Parse(time.RFC3339, at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Timezone = "UTC"
|
||||
c := &clock{now: moment}
|
||||
limiter, err := NewLimiter(cfg, "", c.Now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return limiter, c
|
||||
}
|
||||
|
||||
func TestInWindowTable(t *testing.T) {
|
||||
at := func(text string) time.Time {
|
||||
moment, _ := time.Parse("15:04", text)
|
||||
return moment
|
||||
}
|
||||
cases := []struct {
|
||||
from, to, now string
|
||||
want bool
|
||||
}{
|
||||
{"", "", "12:00", true},
|
||||
{"09:00", "17:00", "09:00", true},
|
||||
{"09:00", "17:00", "16:59", true},
|
||||
{"09:00", "17:00", "17:00", false},
|
||||
{"09:00", "17:00", "08:59", false},
|
||||
{"22:00", "06:00", "23:30", true},
|
||||
{"22:00", "06:00", "05:59", true},
|
||||
{"22:00", "06:00", "06:00", false},
|
||||
{"22:00", "06:00", "12:00", false},
|
||||
{"10:00", "10:00", "03:00", true},
|
||||
}
|
||||
for _, item := range cases {
|
||||
if got := InWindow(item.from, item.to, at(item.now)); got != item.want {
|
||||
t.Errorf("%s-%s at %s: got %v", item.from, item.to, item.now, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireConcurrencyCapAndServerCannotWiden(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.MaxConcurrent = 2
|
||||
limiter, _ := testLimiter(t, cfg, "2026-10-15T12:00:00Z")
|
||||
limiter.ApplyServer(&ServerLimits{MaxConcurrentPerNode: 8}, &ServerSettings{MaxConcurrent: 8})
|
||||
if limiter.Acquire() != "" || limiter.Acquire() != "" {
|
||||
t.Fatal("two slots should be free")
|
||||
}
|
||||
if got := limiter.Acquire(); got != RefuseConcurrency {
|
||||
t.Fatalf("third acquire: %q", got)
|
||||
}
|
||||
limiter.Release()
|
||||
if got := limiter.Acquire(); got != "" {
|
||||
t.Fatalf("after release: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerCanNarrowConcurrency(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.MaxConcurrent = 4
|
||||
limiter, _ := testLimiter(t, cfg, "2026-10-15T12:00:00Z")
|
||||
limiter.ApplyServer(&ServerLimits{MaxConcurrentPerNode: 2}, &ServerSettings{MaxConcurrent: 1})
|
||||
if limiter.Acquire() != "" {
|
||||
t.Fatal("first slot")
|
||||
}
|
||||
if got := limiter.Acquire(); got != RefuseConcurrency {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBandwidthCapLocalAndDayRollover(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.BandwidthMBDay = 1
|
||||
limiter, c := testLimiter(t, cfg, "2026-10-15T23:00:00Z")
|
||||
limiter.ApplyServer(&ServerLimits{MaxDonorMBPerDay: 20480}, &ServerSettings{BandwidthMBDay: 9999})
|
||||
if limiter.Acquire() != "" {
|
||||
t.Fatal("fresh day must allow")
|
||||
}
|
||||
limiter.Release()
|
||||
limiter.AddBytes(mebibyte - 10)
|
||||
if got := limiter.Remaining(); got != 10 {
|
||||
t.Fatalf("remaining %d", got)
|
||||
}
|
||||
limiter.AddBytes(10)
|
||||
if got := limiter.Acquire(); got != RefuseBandwidth {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
c.now = c.now.Add(2 * time.Hour)
|
||||
if got := limiter.Acquire(); got != "" {
|
||||
t.Fatalf("next day: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerCanNarrowBandwidth(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.BandwidthMBDay = 500
|
||||
limiter, _ := testLimiter(t, cfg, "2026-10-15T12:00:00Z")
|
||||
limiter.ApplyServer(nil, &ServerSettings{BandwidthMBDay: 2})
|
||||
if got := limiter.Remaining(); got != 2*mebibyte {
|
||||
t.Fatalf("remaining %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveHoursLocalAndServer(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.ActiveFrom, cfg.ActiveTo = "22:00", "06:00"
|
||||
limiter, c := testLimiter(t, cfg, "2026-10-15T12:00:00Z")
|
||||
if got := limiter.Acquire(); got != RefuseHours {
|
||||
t.Fatalf("noon: %q", got)
|
||||
}
|
||||
c.now = c.now.Add(11 * time.Hour)
|
||||
if got := limiter.Acquire(); got != "" {
|
||||
t.Fatalf("23:00: %q", got)
|
||||
}
|
||||
limiter.Release()
|
||||
limiter.ApplyServer(nil, &ServerSettings{ActiveFrom: "01:00", ActiveTo: "02:00", Timezone: "UTC"})
|
||||
if got := limiter.Acquire(); got != RefuseHours {
|
||||
t.Fatalf("server window: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsagePersistsAcrossRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "usage.json")
|
||||
cfg := DefaultConfig()
|
||||
cfg.Timezone = "UTC"
|
||||
moment := time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)
|
||||
c := &clock{now: moment}
|
||||
first, err := NewLimiter(cfg, path, c.Now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first.AddBytes(12345)
|
||||
first.Flush()
|
||||
second, err := NewLimiter(cfg, path, c.Now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, used, _ := second.Usage(); used != 12345 {
|
||||
t.Fatalf("used %d", used)
|
||||
}
|
||||
c.now = moment.Add(24 * time.Hour)
|
||||
if _, used, _ := second.Usage(); used != 0 {
|
||||
t.Fatalf("next day used %d", used)
|
||||
}
|
||||
if got := ReadUsage(path, time.UTC, moment); got != 12345 {
|
||||
t.Fatalf("read usage %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
out *log.Logger
|
||||
verbose bool
|
||||
}
|
||||
|
||||
func NewLogger(w io.Writer, verbose bool) *Logger {
|
||||
return &Logger{out: log.New(w, "devplace-node ", log.LstdFlags|log.LUTC), verbose: verbose}
|
||||
}
|
||||
|
||||
func (l *Logger) Printf(format string, args ...any) {
|
||||
l.out.Output(2, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (l *Logger) Debugf(format string, args ...any) {
|
||||
if l.verbose {
|
||||
l.out.Output(2, fmt.Sprintf(format, args...))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
_ "time/tzdata"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
const (
|
||||
exitOK = 0
|
||||
exitError = 1
|
||||
exitUsage = 2
|
||||
exitFatal = 3
|
||||
usageText = `usage: devplace-node <command> [flags]
|
||||
|
||||
commands:
|
||||
login store the server and device secret (--server URL --secret dpn_...)
|
||||
status show the stored configuration, today's usage and whether the server accepts this device
|
||||
run connect and serve jobs in the foreground
|
||||
version print the version
|
||||
|
||||
flags for every command: --config PATH (default: <user config dir>/devplace-node/config.json)
|
||||
`
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(execute(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
func execute(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprint(stderr, usageText)
|
||||
return exitUsage
|
||||
}
|
||||
command, rest := args[0], args[1:]
|
||||
switch command {
|
||||
case "version":
|
||||
fmt.Fprintln(stdout, "devplace-node", Version)
|
||||
return exitOK
|
||||
case "login":
|
||||
return runLogin(rest, stdout, stderr)
|
||||
case "status":
|
||||
return runStatus(rest, stdout, stderr)
|
||||
case "run":
|
||||
return runRun(rest, stderr)
|
||||
case "help", "-h", "--help":
|
||||
fmt.Fprint(stdout, usageText)
|
||||
return exitOK
|
||||
}
|
||||
fmt.Fprintf(stderr, "unknown command %q\n%s", command, usageText)
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
func resolveConfigPath(flagValue string) (string, error) {
|
||||
if flagValue != "" {
|
||||
return flagValue, nil
|
||||
}
|
||||
return DefaultConfigPath()
|
||||
}
|
||||
|
||||
func newFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) {
|
||||
set := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
set.SetOutput(stderr)
|
||||
path := set.String("config", "", "config file path")
|
||||
return set, path
|
||||
}
|
||||
|
||||
func runLogin(args []string, stdout, stderr io.Writer) int {
|
||||
set, pathFlag := newFlags("login", stderr)
|
||||
server := set.String("server", "", "server url, for example https://devplace.net")
|
||||
secret := set.String("secret", "", "device secret (or set DEVPLACE_NODE_SECRET)")
|
||||
bandwidth := set.Int("bandwidth-mb", DefaultBandwidthMB, "daily bandwidth cap in MB")
|
||||
concurrent := set.Int("concurrency", DefaultMaxConcurrent, "maximum parallel jobs")
|
||||
tunnels := set.Int("tunnels", DefaultMaxTunnels, "maximum parallel tunnels")
|
||||
from := set.String("from", "", "active hours start HH:MM")
|
||||
to := set.String("to", "", "active hours end HH:MM")
|
||||
zone := set.String("timezone", "", "IANA timezone for the active hours (default: system)")
|
||||
skip := set.Bool("no-verify", false, "store without asking the server to accept the secret")
|
||||
if err := set.Parse(args); err != nil {
|
||||
return exitUsage
|
||||
}
|
||||
if *secret == "" {
|
||||
*secret = os.Getenv("DEVPLACE_NODE_SECRET")
|
||||
}
|
||||
path, err := resolveConfigPath(*pathFlag)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return exitError
|
||||
}
|
||||
config := Config{
|
||||
Server: *server, Secret: *secret, BandwidthMBDay: *bandwidth, MaxConcurrent: *concurrent,
|
||||
MaxTunnels: *tunnels, ActiveFrom: *from, ActiveTo: *to, Timezone: *zone,
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
fmt.Fprintln(stderr, "login refused:", err)
|
||||
return exitUsage
|
||||
}
|
||||
if !*skip {
|
||||
if code := verifyDevice(config, stderr); code != exitOK {
|
||||
return code
|
||||
}
|
||||
}
|
||||
if err := SaveConfig(path, config); err != nil {
|
||||
fmt.Fprintln(stderr, "config could not be written:", err)
|
||||
return exitError
|
||||
}
|
||||
fmt.Fprintf(stdout, "stored %s (mode 0600)\n", path)
|
||||
return exitOK
|
||||
}
|
||||
|
||||
func verifyDevice(config Config, stderr io.Writer) int {
|
||||
session := NewSession(config, NewPolicy(), nil, nil, nil, NewLogger(stderr, false))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(TicketTimeoutSeconds+5)*time.Second)
|
||||
defer cancel()
|
||||
_, result := session.fetchTicket(ctx)
|
||||
if result.fatal != "" {
|
||||
fmt.Fprintln(stderr, "server refused this device:", result.fatal)
|
||||
return exitFatal
|
||||
}
|
||||
if result.reason != "" {
|
||||
fmt.Fprintln(stderr, "server not verified:", result.reason, "(use --no-verify to store anyway)")
|
||||
return exitError
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
func runStatus(args []string, stdout, stderr io.Writer) int {
|
||||
set, pathFlag := newFlags("status", stderr)
|
||||
if err := set.Parse(args); err != nil {
|
||||
return exitUsage
|
||||
}
|
||||
path, err := resolveConfigPath(*pathFlag)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return exitError
|
||||
}
|
||||
config, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return exitError
|
||||
}
|
||||
location, _ := config.Location()
|
||||
used := ReadUsage(filepath.Join(filepath.Dir(path), UsageFileName), location, time.Now())
|
||||
fmt.Fprintf(stdout, "version: %s\nconfig: %s\nserver: %s\nsecret: stored\n", Version, path, config.Server)
|
||||
fmt.Fprintf(stdout, "bandwidth: %d MB/day (used today: %.1f MB)\n", config.BandwidthMBDay, float64(used)/mebibyte)
|
||||
fmt.Fprintf(stdout, "concurrency: %d jobs, %d tunnels\n", config.MaxConcurrent, config.MaxTunnels)
|
||||
hours := "always"
|
||||
if config.ActiveFrom != "" {
|
||||
hours = config.ActiveFrom + "-" + config.ActiveTo
|
||||
}
|
||||
fmt.Fprintf(stdout, "active hours: %s (%s)\n", hours, location)
|
||||
if code := verifyDevice(config, stderr); code != exitOK {
|
||||
fmt.Fprintln(stdout, "server: not accepting this device")
|
||||
return code
|
||||
}
|
||||
fmt.Fprintln(stdout, "server: accepts this device")
|
||||
return exitOK
|
||||
}
|
||||
|
||||
func runRun(args []string, stderr io.Writer) int {
|
||||
set, pathFlag := newFlags("run", stderr)
|
||||
verbose := set.Bool("v", false, "verbose logging")
|
||||
if err := set.Parse(args); err != nil {
|
||||
return exitUsage
|
||||
}
|
||||
path, err := resolveConfigPath(*pathFlag)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return exitError
|
||||
}
|
||||
config, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return exitError
|
||||
}
|
||||
logger := NewLogger(stderr, *verbose)
|
||||
limiter, err := NewLimiter(config, filepath.Join(filepath.Dir(path), UsageFileName), time.Now)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return exitError
|
||||
}
|
||||
policy := NewPolicy()
|
||||
stats := NewStats(time.Now())
|
||||
session := NewSession(config, policy, limiter, NewFetcher(policy), stats, logger)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
logger.Printf("started version %s, server %s", Version, config.Server)
|
||||
err = session.Run(ctx)
|
||||
limiter.Flush()
|
||||
logger.Printf("%s", stats.Line())
|
||||
var fatal *FatalError
|
||||
if errors.As(err, &fatal) {
|
||||
logger.Printf("%s", fatal.Error())
|
||||
return exitFatal
|
||||
}
|
||||
if err != nil {
|
||||
logger.Printf("stopped: %v", err)
|
||||
return exitError
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
devplace-node:
|
||||
image: gcr.io/distroless/static-debian12:nonroot
|
||||
command: ["/devplace-node", "run", "--config", "/config/config.json"]
|
||||
volumes:
|
||||
- ./devplace-node-linux-amd64:/devplace-node:ro
|
||||
- ./config:/config
|
||||
restart: on-failure
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
mem_limit: 128m
|
||||
pids_limit: 64
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=DevPlace community node
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%h/.local/bin/devplace-node run
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
RestartPreventExitStatus=3
|
||||
Nice=10
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>nl.devplace.node</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/devplace-node</string>
|
||||
<string>run</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<dict>
|
||||
<key>SuccessfulExit</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>60</integer>
|
||||
<key>ProcessType</key>
|
||||
<string>Background</string>
|
||||
<key>LowPriorityIO</key>
|
||||
<true/>
|
||||
<key>Nice</key>
|
||||
<integer>10</integer>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/devplace-node.log</string>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/dev/null</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,34 @@
|
||||
param(
|
||||
[string]$Binary = "$env:LOCALAPPDATA\devplace-node\devplace-node.exe",
|
||||
[switch]$Remove
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$TaskName = "DevPlaceNode"
|
||||
|
||||
if ($Remove) {
|
||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
|
||||
Write-Host "Removed scheduled task $TaskName"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not (Test-Path $Binary)) {
|
||||
throw "devplace-node.exe not found at $Binary. Download it there first."
|
||||
}
|
||||
|
||||
$action = New-ScheduledTaskAction -Execute $Binary -Argument "run"
|
||||
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
|
||||
$settings = New-ScheduledTaskSettingsSet `
|
||||
-AllowStartIfOnBatteries `
|
||||
-DontStopIfGoingOnBatteries `
|
||||
-RestartCount 3 `
|
||||
-RestartInterval (New-TimeSpan -Minutes 5) `
|
||||
-ExecutionTimeLimit ([TimeSpan]::Zero) `
|
||||
-StartWhenAvailable
|
||||
$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited
|
||||
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Force | Out-Null
|
||||
Write-Host "Registered scheduled task $TaskName. It starts at your next logon."
|
||||
Write-Host "Store the device secret first: devplace-node.exe login --server https://devplace.net --secret dpn_..."
|
||||
Write-Host "Start now: Start-ScheduledTask -TaskName $TaskName"
|
||||
Write-Host "Stop and remove: .\windows-install.ps1 -Remove"
|
||||
@@ -0,0 +1,317 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PolicyError struct {
|
||||
Code string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *PolicyError) Error() string {
|
||||
if e.Reason == "" {
|
||||
return e.Code
|
||||
}
|
||||
return e.Code + ": " + e.Reason
|
||||
}
|
||||
|
||||
func refuse(code, reason string) error {
|
||||
return &PolicyError{Code: code, Reason: reason}
|
||||
}
|
||||
|
||||
type Target struct {
|
||||
URL string
|
||||
Method string
|
||||
Host string
|
||||
Port int
|
||||
Dest Destination
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
mu sync.RWMutex
|
||||
dests []Destination
|
||||
lookup func(ctx context.Context, host string) ([]netip.Addr, error)
|
||||
public func(netip.Addr) bool
|
||||
dial func(ctx context.Context, network, address string) (net.Conn, error)
|
||||
}
|
||||
|
||||
func NewPolicy() *Policy {
|
||||
dialer := &net.Dialer{Timeout: time.Duration(TunnelDialSeconds) * time.Second}
|
||||
return &Policy{
|
||||
dests: cloneDestinations(BakedDestinations),
|
||||
lookup: systemLookup,
|
||||
public: IsPublicAddr,
|
||||
dial: dialer.DialContext,
|
||||
}
|
||||
}
|
||||
|
||||
func systemLookup(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
return net.DefaultResolver.LookupNetIP(ctx, "ip", host)
|
||||
}
|
||||
|
||||
func cloneDestinations(list []Destination) []Destination {
|
||||
out := make([]Destination, len(list))
|
||||
for i, item := range list {
|
||||
item.Methods = slices.Clone(item.Methods)
|
||||
item.Ports = slices.Clone(item.Ports)
|
||||
item.Modes = slices.Clone(item.Modes)
|
||||
out[i] = item
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeHost(host string) string {
|
||||
return strings.TrimRight(strings.ToLower(strings.TrimSpace(host)), ".")
|
||||
}
|
||||
|
||||
func suffixMatches(suffix, host string) bool {
|
||||
return host == suffix || strings.HasSuffix(host, "."+suffix)
|
||||
}
|
||||
|
||||
func intersectStrings(a, b []string) []string {
|
||||
out := []string{}
|
||||
for _, item := range a {
|
||||
if slices.ContainsFunc(b, func(other string) bool { return strings.EqualFold(item, other) }) {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func intersectPorts(a, b []int) []int {
|
||||
out := []int{}
|
||||
for _, item := range a {
|
||||
if slices.Contains(b, item) && slices.Contains(AllowedPorts, item) {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *Policy) Refine(offered []Destination) []string {
|
||||
var rejected []string
|
||||
narrowed := []Destination{}
|
||||
seen := map[string]bool{}
|
||||
for _, item := range offered {
|
||||
suffix := normalizeHost(item.Suffix)
|
||||
var base *Destination
|
||||
for i := range BakedDestinations {
|
||||
if suffixMatches(BakedDestinations[i].Suffix, suffix) {
|
||||
base = &BakedDestinations[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if base == nil || seen[suffix] {
|
||||
rejected = append(rejected, item.Suffix)
|
||||
continue
|
||||
}
|
||||
seen[suffix] = true
|
||||
narrowed = append(narrowed, Destination{
|
||||
Suffix: suffix,
|
||||
Adapter: base.Adapter,
|
||||
Methods: intersectStrings(base.Methods, item.Methods),
|
||||
Ports: intersectPorts(base.Ports, item.Ports),
|
||||
Modes: intersectStrings(base.Modes, item.Modes),
|
||||
MaxRPS: item.MaxRPS,
|
||||
})
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.dests = narrowed
|
||||
p.mu.Unlock()
|
||||
return rejected
|
||||
}
|
||||
|
||||
func (p *Policy) Destinations() []Destination {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return cloneDestinations(p.dests)
|
||||
}
|
||||
|
||||
func (p *Policy) destinationFor(host string) (Destination, bool) {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
var best Destination
|
||||
found := false
|
||||
for _, item := range p.dests {
|
||||
if suffixMatches(item.Suffix, host) && (!found || len(item.Suffix) > len(best.Suffix)) {
|
||||
best, found = item, true
|
||||
}
|
||||
}
|
||||
return best, found
|
||||
}
|
||||
|
||||
func isAddressLiteral(host string) bool {
|
||||
_, err := netip.ParseAddr(strings.Trim(host, "[]"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (p *Policy) CheckHostPort(host string, port int, mode string) (Destination, error) {
|
||||
host = normalizeHost(host)
|
||||
if host == "" {
|
||||
return Destination{}, refuse(RefuseBadURL, "no host")
|
||||
}
|
||||
if isAddressLiteral(host) {
|
||||
return Destination{}, refuse(RefuseHost, "address literals are not allowed")
|
||||
}
|
||||
dest, ok := p.destinationFor(host)
|
||||
if !ok {
|
||||
return Destination{}, refuse(RefuseHost, "destination is not on the allowlist")
|
||||
}
|
||||
if !slices.Contains(AllowedPorts, port) || !slices.Contains(dest.Ports, port) {
|
||||
return Destination{}, refuse(RefusePort, "port is not allowed")
|
||||
}
|
||||
if mode != "" && mode != "any" && !slices.Contains(dest.Modes, mode) {
|
||||
return Destination{}, refuse(RefuseHost, "mode is not allowed for this destination")
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
func (p *Policy) Check(rawURL, method, mode string) (Target, error) {
|
||||
if rawURL == "" || len(rawURL) > MaxURLChars {
|
||||
return Target{}, refuse(RefuseBadURL, "url is missing or too long")
|
||||
}
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return Target{}, refuse(RefuseBadURL, "url cannot be parsed")
|
||||
}
|
||||
defaultPort := 0
|
||||
switch parsed.Scheme {
|
||||
case "http":
|
||||
defaultPort = 80
|
||||
case "https":
|
||||
defaultPort = 443
|
||||
default:
|
||||
return Target{}, refuse(RefuseBadURL, "only http and https are allowed")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return Target{}, refuse(RefuseBadURL, "credentials in a url are not allowed")
|
||||
}
|
||||
port := defaultPort
|
||||
if text := parsed.Port(); text != "" {
|
||||
port, err = strconv.Atoi(text)
|
||||
if err != nil {
|
||||
return Target{}, refuse(RefuseBadURL, "port is not a number")
|
||||
}
|
||||
}
|
||||
dest, err := p.CheckHostPort(parsed.Hostname(), port, mode)
|
||||
if err != nil {
|
||||
return Target{}, err
|
||||
}
|
||||
verb := strings.ToUpper(strings.TrimSpace(method))
|
||||
if !slices.Contains(upperAll(dest.Methods), verb) {
|
||||
return Target{}, refuse(RefuseMethod, "method is not allowed here")
|
||||
}
|
||||
return Target{URL: parsed.String(), Method: verb, Host: normalizeHost(parsed.Hostname()), Port: port, Dest: dest}, nil
|
||||
}
|
||||
|
||||
func upperAll(list []string) []string {
|
||||
out := make([]string, len(list))
|
||||
for i, item := range list {
|
||||
out[i] = strings.ToUpper(item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *Policy) CheckRedirect(location, method string) (Target, error) {
|
||||
target, err := p.Check(location, method, "fetch")
|
||||
if err != nil {
|
||||
var policyErr *PolicyError
|
||||
if errors.As(err, &policyErr) {
|
||||
return Target{}, refuse(RefuseRedirect, policyErr.Reason)
|
||||
}
|
||||
return Target{}, refuse(RefuseRedirect, err.Error())
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (p *Policy) Resolve(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
host = normalizeHost(host)
|
||||
addrs, err := p.lookup(ctx, host)
|
||||
if err != nil {
|
||||
return nil, refuse(FailDNS, "host did not resolve")
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return nil, refuse(FailDNS, "host has no addresses")
|
||||
}
|
||||
if len(addrs) > 16 {
|
||||
addrs = addrs[:16]
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if !p.public(addr.Unmap()) {
|
||||
return nil, refuse(RefuseAddress, "host resolves to a non public address")
|
||||
}
|
||||
}
|
||||
return addrs, nil
|
||||
}
|
||||
|
||||
func (p *Policy) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, portText, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, refuse(RefuseBadURL, "bad address")
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
return nil, refuse(RefuseBadURL, "bad port")
|
||||
}
|
||||
if _, err := p.CheckHostPort(host, port, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addrs, err := p.Resolve(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var last error
|
||||
for _, addr := range addrs {
|
||||
conn, err := p.dial(ctx, network, net.JoinHostPort(addr.Unmap().String(), portText))
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
last = err
|
||||
}
|
||||
return nil, fmt.Errorf("connect failed: %w", last)
|
||||
}
|
||||
|
||||
var blockedPrefixes = mustPrefixes(
|
||||
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
|
||||
"172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24", "192.88.99.0/24", "192.168.0.0/16",
|
||||
"198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4",
|
||||
"::/128", "::1/128", "::ffff:0:0/96", "64:ff9b::/96", "64:ff9b:1::/48", "100::/64",
|
||||
"2001::/32", "2001:db8::/32", "2002::/16", "fc00::/7", "fe80::/10", "ff00::/8",
|
||||
)
|
||||
|
||||
func mustPrefixes(list ...string) []netip.Prefix {
|
||||
out := make([]netip.Prefix, len(list))
|
||||
for i, item := range list {
|
||||
out[i] = netip.MustParsePrefix(item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func IsPublicAddr(addr netip.Addr) bool {
|
||||
addr = addr.Unmap()
|
||||
if !addr.IsValid() || addr.Zone() != "" {
|
||||
return false
|
||||
}
|
||||
if addr.IsUnspecified() || addr.IsLoopback() || addr.IsPrivate() || addr.IsMulticast() ||
|
||||
addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() || addr.IsInterfaceLocalMulticast() ||
|
||||
!addr.IsGlobalUnicast() {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range blockedPrefixes {
|
||||
if prefix.Contains(addr) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testPolicy(answer func(host string) []netip.Addr) *Policy {
|
||||
policy := NewPolicy()
|
||||
policy.lookup = func(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
return answer(host), nil
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func publicAnswer(string) []netip.Addr {
|
||||
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}
|
||||
}
|
||||
|
||||
func codeOf(err error) string {
|
||||
var policyErr *PolicyError
|
||||
if errors.As(err, &policyErr) {
|
||||
return policyErr.Code
|
||||
}
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return "other:" + err.Error()
|
||||
}
|
||||
|
||||
func TestCheckTable(t *testing.T) {
|
||||
policy := testPolicy(publicAnswer)
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
method string
|
||||
mode string
|
||||
want string
|
||||
}{
|
||||
{"apex", "https://reddit.com/r/go", "GET", "fetch", ""},
|
||||
{"subdomain", "https://old.reddit.com/r/go.json", "get", "fetch", ""},
|
||||
{"short domain", "https://redd.it/abc", "GET", "any", ""},
|
||||
{"explicit 443", "https://www.reddit.com:443/", "GET", "fetch", ""},
|
||||
{"plain http 80", "http://reddit.com/", "GET", "fetch", ""},
|
||||
{"trailing dot", "https://reddit.com./", "GET", "fetch", ""},
|
||||
{"upper case host", "https://WWW.REDDIT.COM/", "GET", "fetch", ""},
|
||||
{"prefix attack", "https://evilreddit.com/", "GET", "fetch", RefuseHost},
|
||||
{"suffix attack", "https://reddit.com.evil.net/", "GET", "fetch", RefuseHost},
|
||||
{"hyphen attack", "https://not-reddit.com/", "GET", "fetch", RefuseHost},
|
||||
{"other host", "https://example.com/", "GET", "fetch", RefuseHost},
|
||||
{"port 8080", "https://reddit.com:8080/", "GET", "fetch", RefusePort},
|
||||
{"port 22", "http://reddit.com:22/", "GET", "fetch", RefusePort},
|
||||
{"port 0", "https://reddit.com:0/", "GET", "fetch", RefusePort},
|
||||
{"ipv4 literal", "http://127.0.0.1/", "GET", "fetch", RefuseHost},
|
||||
{"ipv4 literal 80", "http://93.184.216.34/", "GET", "fetch", RefuseHost},
|
||||
{"ipv6 literal", "http://[::1]/", "GET", "fetch", RefuseHost},
|
||||
{"credentials", "https://user:pw@reddit.com/", "GET", "fetch", RefuseBadURL},
|
||||
{"scheme ftp", "ftp://reddit.com/", "GET", "fetch", RefuseBadURL},
|
||||
{"scheme file", "file:///etc/passwd", "GET", "fetch", RefuseBadURL},
|
||||
{"empty", "", "GET", "fetch", RefuseBadURL},
|
||||
{"post", "https://reddit.com/", "POST", "fetch", RefuseMethod},
|
||||
{"delete", "https://reddit.com/", "DELETE", "fetch", RefuseMethod},
|
||||
{"bad port text", "https://reddit.com:abc/", "GET", "fetch", RefuseBadURL},
|
||||
}
|
||||
for _, item := range cases {
|
||||
_, err := policy.Check(item.url, item.method, item.mode)
|
||||
if got := codeOf(err); got != item.want {
|
||||
t.Errorf("%s: got %q want %q", item.name, got, item.want)
|
||||
}
|
||||
}
|
||||
long := "https://reddit.com/" + string(make([]byte, MaxURLChars))
|
||||
if _, err := policy.Check(long, "GET", "fetch"); codeOf(err) != RefuseBadURL {
|
||||
t.Errorf("long url not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHostPortModes(t *testing.T) {
|
||||
policy := testPolicy(publicAnswer)
|
||||
if _, err := policy.CheckHostPort("reddit.com", 443, "tunnel"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := policy.CheckHostPort("reddit.com", 25, "tunnel"); codeOf(err) != RefusePort {
|
||||
t.Fatalf("port: %v", err)
|
||||
}
|
||||
if _, err := policy.CheckHostPort("evilreddit.com", 443, "tunnel"); codeOf(err) != RefuseHost {
|
||||
t.Fatalf("host: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicAddressTable(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"8.8.8.8": true, "93.184.216.34": true, "2606:4700:4700::1111": true,
|
||||
"10.0.0.1": false, "10.255.255.255": false, "127.0.0.1": false, "127.1.2.3": false,
|
||||
"169.254.169.254": false, "172.16.0.1": false, "172.31.255.255": false, "172.32.0.1": true,
|
||||
"192.168.1.1": false, "100.64.0.1": false, "100.127.255.255": false, "100.128.0.1": true,
|
||||
"0.0.0.0": false, "0.1.2.3": false, "224.0.0.1": false, "239.255.255.250": false,
|
||||
"255.255.255.255": false, "240.0.0.1": false, "198.18.0.1": false, "192.0.2.1": false,
|
||||
"::": false, "::1": false, "fe80::1": false, "fc00::1": false, "fd12:3456::1": false,
|
||||
"ff02::1": false, "::ffff:127.0.0.1": false, "::ffff:10.0.0.1": false, "::ffff:8.8.8.8": true,
|
||||
"2001:db8::1": false, "64:ff9b::7f00:1": false, "2002:7f00:1::1": false, "2001::1": false,
|
||||
}
|
||||
for text, want := range cases {
|
||||
if got := IsPublicAddr(netip.MustParseAddr(text)); got != want {
|
||||
t.Errorf("%s: got %v want %v", text, got, want)
|
||||
}
|
||||
}
|
||||
if IsPublicAddr(netip.Addr{}) {
|
||||
t.Error("invalid address counted as public")
|
||||
}
|
||||
if IsPublicAddr(netip.MustParseAddr("fe80::1%eth0")) {
|
||||
t.Error("zoned address counted as public")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRefusesAnyPrivateAnswer(t *testing.T) {
|
||||
cases := map[string][]string{
|
||||
"loopback": {"127.0.0.1"},
|
||||
"private": {"10.1.2.3"},
|
||||
"mixed": {"93.184.216.34", "192.168.0.1"},
|
||||
"v6 loopback": {"::1"},
|
||||
"v6 mapped": {"::ffff:10.0.0.1"},
|
||||
"cgnat": {"100.64.5.5"},
|
||||
}
|
||||
for name, list := range cases {
|
||||
addrs := []netip.Addr{}
|
||||
for _, item := range list {
|
||||
addrs = append(addrs, netip.MustParseAddr(item))
|
||||
}
|
||||
policy := testPolicy(func(string) []netip.Addr { return addrs })
|
||||
if _, err := policy.Resolve(context.Background(), "reddit.com"); codeOf(err) != RefuseAddress {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
empty := testPolicy(func(string) []netip.Addr { return nil })
|
||||
if _, err := empty.Resolve(context.Background(), "reddit.com"); codeOf(err) != FailDNS {
|
||||
t.Errorf("empty answer: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialConnectsToValidatedIPAndDefeatsRebinding(t *testing.T) {
|
||||
calls := 0
|
||||
policy := testPolicy(func(string) []netip.Addr {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}
|
||||
}
|
||||
return []netip.Addr{netip.MustParseAddr("127.0.0.1")}
|
||||
})
|
||||
var dialed []string
|
||||
policy.dial = func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
dialed = append(dialed, address)
|
||||
client, server := net.Pipe()
|
||||
server.Close()
|
||||
return client, nil
|
||||
}
|
||||
if _, err := policy.DialContext(context.Background(), "tcp", "www.reddit.com:443"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(dialed) != 1 || dialed[0] != "93.184.216.34:443" {
|
||||
t.Fatalf("dialed %v", dialed)
|
||||
}
|
||||
if _, err := policy.DialContext(context.Background(), "tcp", "www.reddit.com:443"); codeOf(err) != RefuseAddress {
|
||||
t.Fatalf("rebinding not refused: %v", err)
|
||||
}
|
||||
if len(dialed) != 1 {
|
||||
t.Fatalf("private address was dialed: %v", dialed)
|
||||
}
|
||||
if _, err := policy.DialContext(context.Background(), "tcp", "evil.com:443"); codeOf(err) != RefuseHost {
|
||||
t.Fatalf("host: %v", err)
|
||||
}
|
||||
if _, err := policy.DialContext(context.Background(), "tcp", "reddit.com:22"); codeOf(err) != RefusePort {
|
||||
t.Fatalf("port: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectLeavingAllowlistRefused(t *testing.T) {
|
||||
policy := testPolicy(publicAnswer)
|
||||
cases := map[string]string{
|
||||
"https://old.reddit.com/x": "",
|
||||
"https://redd.it/y": "",
|
||||
"https://evil.com/": RefuseRedirect,
|
||||
"https://evilreddit.com/": RefuseRedirect,
|
||||
"http://reddit.com:8080/": RefuseRedirect,
|
||||
"http://127.0.0.1/": RefuseRedirect,
|
||||
"ftp://reddit.com/": RefuseRedirect,
|
||||
"https://reddit.com.evil.io": RefuseRedirect,
|
||||
}
|
||||
for location, want := range cases {
|
||||
_, err := policy.CheckRedirect(location, "GET")
|
||||
if got := codeOf(err); got != want {
|
||||
t.Errorf("%s: got %q want %q", location, got, want)
|
||||
}
|
||||
}
|
||||
if _, err := policy.CheckRedirect("https://reddit.com/", "POST"); codeOf(err) != RefuseRedirect {
|
||||
t.Error("method change through redirect allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefineOnlyNarrows(t *testing.T) {
|
||||
policy := testPolicy(publicAnswer)
|
||||
rejected := policy.Refine([]Destination{
|
||||
{Suffix: "old.reddit.com", Methods: []string{"GET", "POST"}, Ports: []int{80, 443, 8080}, Modes: []string{"fetch", "any"}},
|
||||
{Suffix: "redd.it", Methods: []string{"GET"}, Ports: []int{443}, Modes: []string{"tunnel", "fetch"}},
|
||||
{Suffix: "evil.com", Methods: []string{"GET"}, Ports: []int{443}, Modes: []string{"fetch"}},
|
||||
{Suffix: "evilreddit.com", Methods: []string{"GET"}, Ports: []int{443}, Modes: []string{"fetch"}},
|
||||
{Suffix: "old.reddit.com", Methods: []string{"GET"}, Ports: []int{443}, Modes: []string{"fetch"}},
|
||||
})
|
||||
if len(rejected) != 3 {
|
||||
t.Fatalf("rejected %v", rejected)
|
||||
}
|
||||
if _, err := policy.Check("https://old.reddit.com/", "GET", "fetch"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := policy.Check("https://old.reddit.com/", "POST", "fetch"); codeOf(err) != RefuseMethod {
|
||||
t.Errorf("post widened: %v", err)
|
||||
}
|
||||
if _, err := policy.Check("http://old.reddit.com:8080/", "GET", "fetch"); codeOf(err) != RefusePort {
|
||||
t.Errorf("port widened: %v", err)
|
||||
}
|
||||
if _, err := policy.Check("https://www.reddit.com/", "GET", "fetch"); codeOf(err) != RefuseHost {
|
||||
t.Errorf("dropped destination still allowed: %v", err)
|
||||
}
|
||||
if _, err := policy.Check("http://redd.it/a", "GET", "fetch"); codeOf(err) != RefusePort {
|
||||
t.Errorf("redd.it port 80 should be gone: %v", err)
|
||||
}
|
||||
policy.Refine(nil)
|
||||
if _, err := policy.Check("https://redd.it/a", "GET", "fetch"); codeOf(err) != RefuseHost {
|
||||
t.Errorf("empty refinement must allow nothing: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package main
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
ProtocolVersion = 1
|
||||
DeviceKind = "native"
|
||||
SecretPrefix = "dpn_"
|
||||
|
||||
MaxWSFramePayload = 256 * 1024
|
||||
MaxInboundMessage = 256 * 1024
|
||||
MaxResultMessage = 3 * 1024 * 1024
|
||||
|
||||
DefaultReconnectMin = 1
|
||||
DefaultReconnectMax = 60
|
||||
DefaultPingSeconds = 20
|
||||
DefaultMaxBodyBytes = 2 * 1024 * 1024
|
||||
DefaultJobTimeout = 25
|
||||
MaxJobTimeout = 60
|
||||
HelloTimeoutSeconds = 10
|
||||
TicketTimeoutSeconds = 15
|
||||
MissedPingsBeforeDead = 3
|
||||
UsageIntervalSeconds = 60
|
||||
MaxRedirects = 5
|
||||
MaxRequestHeaders = 32
|
||||
MaxResponseHeaders = 64
|
||||
MaxHeaderValueBytes = 1024
|
||||
MaxIDBytes = 64
|
||||
MaxURLChars = 2048
|
||||
MaxConcurrentCeiling = 8
|
||||
MaxTunnelsCeiling = 8
|
||||
TunnelChunkBytes = 32 * 1024
|
||||
TunnelQueueDepth = 64
|
||||
TunnelIdleSeconds = 60
|
||||
TunnelLifetimeSeconds = 300
|
||||
TunnelDialSeconds = 10
|
||||
MaxDonorMBPerDay = 20 * 1024
|
||||
StableSessionSeconds = 30
|
||||
FastRetryMillis = 1500
|
||||
)
|
||||
|
||||
const (
|
||||
RefuseHost = "host_not_allowed"
|
||||
RefusePort = "port_not_allowed"
|
||||
RefuseAddress = "address_not_public"
|
||||
RefuseRedirect = "redirect_not_allowed"
|
||||
RefuseMethod = "method_not_allowed"
|
||||
RefuseBandwidth = "bandwidth_exhausted"
|
||||
RefuseConcurrency = "concurrency_exceeded"
|
||||
RefuseHours = "outside_hours"
|
||||
RefuseTimeout = "timeout"
|
||||
RefuseBadURL = "bad_url"
|
||||
FailFetch = "fetch_failed"
|
||||
FailDNS = "dns_failed"
|
||||
)
|
||||
|
||||
const (
|
||||
CloseNormal = 1000
|
||||
CloseGoingAway = 1001
|
||||
CloseProtocolError = 1002
|
||||
CloseUnsupported = 1003
|
||||
CloseInvalidPayload = 1007
|
||||
ClosePolicy = 1008
|
||||
CloseTooLarge = 1009
|
||||
CloseTryAgain = 1013
|
||||
CloseRetryOwner = 4013
|
||||
CloseCapacity = 4029
|
||||
CloseProtocol = 4400
|
||||
CloseKicked = 4403
|
||||
)
|
||||
|
||||
var FatalReasons = map[string]bool{
|
||||
"revoked": true,
|
||||
"suspended": true,
|
||||
"kill_switch": true,
|
||||
"no_consent": true,
|
||||
"no_account": true,
|
||||
"deletion_pending": true,
|
||||
"account_inactive": true,
|
||||
"not_adult": true,
|
||||
"not_enabled": true,
|
||||
"replaced": true,
|
||||
"device": true,
|
||||
}
|
||||
|
||||
var BakedDestinations = []Destination{
|
||||
{Suffix: "reddit.com", Adapter: "reddit", Methods: []string{"GET"}, Ports: []int{80, 443}, Modes: []string{"tunnel", "fetch"}},
|
||||
{Suffix: "redd.it", Adapter: "reddit", Methods: []string{"GET"}, Ports: []int{80, 443}, Modes: []string{"tunnel", "fetch"}},
|
||||
}
|
||||
|
||||
var AllowedPorts = []int{80, 443}
|
||||
|
||||
type Destination struct {
|
||||
Suffix string `json:"suffix"`
|
||||
Adapter string `json:"adapter"`
|
||||
Methods []string `json:"methods"`
|
||||
Ports []int `json:"ports"`
|
||||
Modes []string `json:"modes"`
|
||||
MaxRPS float64 `json:"max_rps"`
|
||||
}
|
||||
|
||||
type ServerLimits struct {
|
||||
MaxBodyBytes int `json:"max_body_bytes"`
|
||||
MaxConcurrentPerNode int `json:"max_concurrent_per_node"`
|
||||
MaxDonorMBPerDay int `json:"max_donor_mb_per_day"`
|
||||
PingSeconds int `json:"ping_seconds"`
|
||||
ReconnectMinSeconds int `json:"reconnect_min_seconds"`
|
||||
ReconnectMaxSeconds int `json:"reconnect_max_seconds"`
|
||||
JobTimeoutSeconds int `json:"job_timeout_seconds"`
|
||||
MaxFrameBytes int `json:"max_frame_bytes"`
|
||||
}
|
||||
|
||||
type ServerSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BandwidthMBDay int `json:"bandwidth_mb_day"`
|
||||
MaxConcurrent int `json:"max_concurrent"`
|
||||
ActiveFrom string `json:"active_from"`
|
||||
ActiveTo string `json:"active_to"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
type Frame struct {
|
||||
Type string `json:"type"`
|
||||
JobID string `json:"job_id"`
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]any `json:"headers"`
|
||||
Timeout int `json:"timeout"`
|
||||
ConnID string `json:"conn_id"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
B64 string `json:"b64"`
|
||||
Reason string `json:"reason"`
|
||||
DeviceUID string `json:"device_uid"`
|
||||
Limits *ServerLimits `json:"limits"`
|
||||
Destinations []Destination `json:"destinations"`
|
||||
Settings *ServerSettings `json:"settings"`
|
||||
}
|
||||
|
||||
func decodeFrame(raw []byte) (Frame, error) {
|
||||
var frame Frame
|
||||
if err := json.Unmarshal(raw, &frame); err != nil {
|
||||
return frame, err
|
||||
}
|
||||
return frame, nil
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FatalError struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *FatalError) Error() string {
|
||||
return "the server ended this device for good: " + e.Reason
|
||||
}
|
||||
|
||||
type outcome struct {
|
||||
fatal string
|
||||
fast bool
|
||||
stable bool
|
||||
reason string
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
cfg Config
|
||||
policy *Policy
|
||||
limiter *Limiter
|
||||
fetcher *Fetcher
|
||||
stats *Stats
|
||||
log *Logger
|
||||
httpc *http.Client
|
||||
jitter func() float64
|
||||
dialWS func(ctx context.Context, rawURL string) (*Conn, error)
|
||||
limits ServerLimits
|
||||
maxBody int
|
||||
}
|
||||
|
||||
func NewSession(cfg Config, policy *Policy, limiter *Limiter, fetcher *Fetcher, stats *Stats, logger *Logger) *Session {
|
||||
return &Session{
|
||||
cfg: cfg,
|
||||
policy: policy,
|
||||
limiter: limiter,
|
||||
fetcher: fetcher,
|
||||
stats: stats,
|
||||
log: logger,
|
||||
httpc: &http.Client{Timeout: time.Duration(TicketTimeoutSeconds) * time.Second, Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}},
|
||||
jitter: rand.Float64,
|
||||
dialWS: Dial,
|
||||
limits: defaultLimits(),
|
||||
maxBody: DefaultMaxBodyBytes,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultLimits() ServerLimits {
|
||||
return ServerLimits{
|
||||
MaxBodyBytes: DefaultMaxBodyBytes,
|
||||
PingSeconds: DefaultPingSeconds,
|
||||
ReconnectMinSeconds: DefaultReconnectMin,
|
||||
ReconnectMaxSeconds: DefaultReconnectMax,
|
||||
JobTimeoutSeconds: DefaultJobTimeout,
|
||||
MaxFrameBytes: MaxInboundMessage,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) Backoff(attempt int) time.Duration {
|
||||
low := s.limits.ReconnectMinSeconds
|
||||
high := s.limits.ReconnectMaxSeconds
|
||||
if low < 1 {
|
||||
low = DefaultReconnectMin
|
||||
}
|
||||
if high < low {
|
||||
high = low
|
||||
}
|
||||
if attempt > 16 {
|
||||
attempt = 16
|
||||
}
|
||||
ceiling := low << attempt
|
||||
if ceiling > high || ceiling <= 0 {
|
||||
ceiling = high
|
||||
}
|
||||
seconds := float64(low) + s.jitter()*float64(ceiling-low)
|
||||
return time.Duration(seconds * float64(time.Second))
|
||||
}
|
||||
|
||||
func sleepContext(ctx context.Context, d time.Duration) {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) Run(ctx context.Context) error {
|
||||
attempt := 0
|
||||
for ctx.Err() == nil {
|
||||
result := s.connectOnce(ctx)
|
||||
if result.fatal != "" {
|
||||
return &FatalError{Reason: result.fatal}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
if result.stable {
|
||||
attempt = 0
|
||||
}
|
||||
var delay time.Duration
|
||||
if result.fast {
|
||||
delay = time.Duration((0.5 + s.jitter()) * float64(FastRetryMillis) * float64(time.Millisecond))
|
||||
} else {
|
||||
delay = s.Backoff(attempt)
|
||||
attempt++
|
||||
}
|
||||
s.log.Printf("disconnected (%s), reconnecting in %s", result.reason, delay.Round(100*time.Millisecond))
|
||||
sleepContext(ctx, delay)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ticketAnswer struct {
|
||||
Ticket string `json:"ticket"`
|
||||
WSPath string `json:"ws_path"`
|
||||
}
|
||||
|
||||
type errorAnswer struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func (s *Session) fetchTicket(ctx context.Context) (ticketAnswer, outcome) {
|
||||
var answer ticketAnswer
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.BaseURL()+"/nodes/ticket", nil)
|
||||
if err != nil {
|
||||
return answer, outcome{fatal: "bad server url"}
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+s.cfg.Secret)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "devplace-node/"+Version)
|
||||
response, err := s.httpc.Do(request)
|
||||
if err != nil {
|
||||
return answer, outcome{reason: "ticket request failed: " + stripURL(err).Error()}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, 64*1024))
|
||||
switch {
|
||||
case response.StatusCode == http.StatusOK:
|
||||
if json.Unmarshal(body, &answer) != nil || answer.Ticket == "" || !strings.HasPrefix(answer.WSPath, "/") {
|
||||
return answer, outcome{reason: "ticket answer is malformed"}
|
||||
}
|
||||
return answer, outcome{}
|
||||
case response.StatusCode == http.StatusUnauthorized:
|
||||
return answer, outcome{fatal: "unauthorized, the device secret is not valid (revoked or rotated)"}
|
||||
case response.StatusCode == http.StatusForbidden:
|
||||
var failure errorAnswer
|
||||
_ = json.Unmarshal(body, &failure)
|
||||
reason := failure.Error.Reason
|
||||
if FatalReasons[reason] {
|
||||
return answer, outcome{fatal: reason}
|
||||
}
|
||||
return answer, outcome{reason: "ticket refused: " + reason}
|
||||
default:
|
||||
return answer, outcome{reason: fmt.Sprintf("ticket request answered http %d", response.StatusCode)}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) socketURL(answer ticketAnswer) (string, error) {
|
||||
base, err := url.Parse(s.cfg.BaseURL())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
scheme := "ws"
|
||||
if base.Scheme == "https" {
|
||||
scheme = "wss"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%s?ticket=%s", scheme, base.Host, answer.WSPath, url.QueryEscape(answer.Ticket)), nil
|
||||
}
|
||||
|
||||
func (s *Session) connectOnce(ctx context.Context) outcome {
|
||||
answer, failed := s.fetchTicket(ctx)
|
||||
if failed.fatal != "" || failed.reason != "" {
|
||||
return failed
|
||||
}
|
||||
target, err := s.socketURL(answer)
|
||||
if err != nil {
|
||||
return outcome{fatal: "bad server url"}
|
||||
}
|
||||
conn, err := s.dialWS(ctx, target)
|
||||
if err != nil {
|
||||
return outcome{reason: err.Error()}
|
||||
}
|
||||
defer conn.Close()
|
||||
sessionCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
go func() {
|
||||
<-sessionCtx.Done()
|
||||
conn.Close()
|
||||
}()
|
||||
started := time.Now()
|
||||
result := s.converse(sessionCtx, conn)
|
||||
if time.Since(started) >= time.Duration(StableSessionSeconds)*time.Second {
|
||||
result.stable = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Session) sender(conn *Conn) Sender {
|
||||
return func(frameType string, fields map[string]any) error {
|
||||
payload := map[string]any{"type": frameType}
|
||||
for key, value := range fields {
|
||||
payload[key] = value
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limit := MaxInboundMessage
|
||||
if frameType == "job_result" {
|
||||
limit = MaxResultMessage
|
||||
}
|
||||
if len(data) > limit {
|
||||
return fmt.Errorf("%s frame of %d bytes exceeds the %d byte cap", frameType, len(data), limit)
|
||||
}
|
||||
return conn.WriteText(string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) helloFields() map[string]any {
|
||||
return map[string]any{
|
||||
"protocol": ProtocolVersion,
|
||||
"client_version": Version,
|
||||
"kind": DeviceKind,
|
||||
"platform": runtime.GOOS + "/" + runtime.GOARCH,
|
||||
"modes": []string{"fetch", "tunnel"},
|
||||
"limits": map[string]any{"max_concurrent": s.limiter.ConcurrencyCap()},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) applyWelcome(frame Frame) {
|
||||
limits := defaultLimits()
|
||||
if frame.Limits != nil {
|
||||
given := *frame.Limits
|
||||
if given.MaxBodyBytes > 0 {
|
||||
limits.MaxBodyBytes = given.MaxBodyBytes
|
||||
}
|
||||
if given.PingSeconds > 0 {
|
||||
limits.PingSeconds = given.PingSeconds
|
||||
}
|
||||
if given.ReconnectMinSeconds > 0 {
|
||||
limits.ReconnectMinSeconds = given.ReconnectMinSeconds
|
||||
}
|
||||
if given.ReconnectMaxSeconds > 0 {
|
||||
limits.ReconnectMaxSeconds = given.ReconnectMaxSeconds
|
||||
}
|
||||
limits.MaxConcurrentPerNode = given.MaxConcurrentPerNode
|
||||
limits.MaxDonorMBPerDay = given.MaxDonorMBPerDay
|
||||
if given.JobTimeoutSeconds > 0 {
|
||||
limits.JobTimeoutSeconds = given.JobTimeoutSeconds
|
||||
}
|
||||
}
|
||||
s.limits = limits
|
||||
s.maxBody = min(limits.MaxBodyBytes, DefaultMaxBodyBytes)
|
||||
if rejected := s.policy.Refine(frame.Destinations); len(rejected) > 0 {
|
||||
s.log.Printf("server offered destinations outside the baked-in list, ignored: %s", strings.Join(rejected, ", "))
|
||||
}
|
||||
s.limiter.ApplyServer(&limits, frame.Settings)
|
||||
}
|
||||
|
||||
func (s *Session) converse(ctx context.Context, conn *Conn) outcome {
|
||||
send := s.sender(conn)
|
||||
if err := send("hello", s.helloFields()); err != nil {
|
||||
return outcome{reason: "hello failed: " + err.Error()}
|
||||
}
|
||||
_ = conn.SetReadDeadline(time.Now().Add(time.Duration(HelloTimeoutSeconds) * time.Second))
|
||||
raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return s.closeOutcome(err)
|
||||
}
|
||||
welcome, err := decodeFrame(raw)
|
||||
if err != nil {
|
||||
return s.protocolFailure(conn, "welcome is not valid json")
|
||||
}
|
||||
if welcome.Type == "kick" {
|
||||
return s.kickOutcome(welcome.Reason)
|
||||
}
|
||||
if welcome.Type != "welcome" {
|
||||
return s.protocolFailure(conn, "expected welcome, got "+welcome.Type)
|
||||
}
|
||||
s.applyWelcome(welcome)
|
||||
s.log.Debugf("connected as device %s with %d allowed destinations", welcome.DeviceUID, len(s.policy.Destinations()))
|
||||
|
||||
var jobs sync.WaitGroup
|
||||
tunnels := NewTunnels(s.policy, s.limiter, s.stats, send, s.cfg.MaxTunnels)
|
||||
background, stop := context.WithCancel(ctx)
|
||||
jobs.Add(1)
|
||||
go func() {
|
||||
defer jobs.Done()
|
||||
s.housekeeping(background, send)
|
||||
}()
|
||||
defer func() {
|
||||
stop()
|
||||
tunnels.CloseAll()
|
||||
jobs.Wait()
|
||||
s.limiter.Flush()
|
||||
}()
|
||||
|
||||
patience := time.Duration(s.limits.PingSeconds*MissedPingsBeforeDead) * time.Second
|
||||
for ctx.Err() == nil {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(patience))
|
||||
raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return s.closeOutcome(err)
|
||||
}
|
||||
frame, err := decodeFrame(raw)
|
||||
if err != nil {
|
||||
return s.protocolFailure(conn, "frame is not valid json")
|
||||
}
|
||||
switch frame.Type {
|
||||
case "ping":
|
||||
if err := send("pong", nil); err != nil {
|
||||
return outcome{reason: "pong failed: " + err.Error()}
|
||||
}
|
||||
case "job":
|
||||
s.startJob(background, &jobs, send, frame)
|
||||
case "tunnel_open":
|
||||
tunnels.Open(background, frame)
|
||||
case "tunnel_data":
|
||||
tunnels.Data(frame)
|
||||
case "tunnel_close":
|
||||
tunnels.Close(frame.ConnID)
|
||||
case "settings_update":
|
||||
s.limiter.ApplyServer(nil, frame.Settings)
|
||||
case "kick":
|
||||
return s.kickOutcome(frame.Reason)
|
||||
default:
|
||||
return s.protocolFailure(conn, "unexpected frame "+frame.Type)
|
||||
}
|
||||
}
|
||||
return outcome{reason: "stopped"}
|
||||
}
|
||||
|
||||
func (s *Session) protocolFailure(conn *Conn, reason string) outcome {
|
||||
_ = conn.WriteClose(CloseProtocol, "protocol")
|
||||
return outcome{reason: "protocol error: " + reason}
|
||||
}
|
||||
|
||||
func (s *Session) kickOutcome(reason string) outcome {
|
||||
if FatalReasons[reason] {
|
||||
return outcome{fatal: reason}
|
||||
}
|
||||
return outcome{reason: "kicked: " + reason}
|
||||
}
|
||||
|
||||
func (s *Session) closeOutcome(err error) outcome {
|
||||
var closeErr *CloseError
|
||||
if !errors.As(err, &closeErr) {
|
||||
return outcome{reason: "connection lost: " + err.Error()}
|
||||
}
|
||||
if FatalReasons[closeErr.Reason] {
|
||||
return outcome{fatal: closeErr.Reason}
|
||||
}
|
||||
if closeErr.Code == CloseRetryOwner {
|
||||
return outcome{fast: true, reason: "server asked for a retry on the owner worker"}
|
||||
}
|
||||
return outcome{reason: closeErr.Error()}
|
||||
}
|
||||
|
||||
func (s *Session) housekeeping(ctx context.Context, send Sender) {
|
||||
ticker := time.NewTicker(time.Duration(UsageIntervalSeconds) * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if line := s.stats.Roll(time.Now()); line != "" {
|
||||
s.log.Printf("%s", line)
|
||||
}
|
||||
up, down := s.stats.Take()
|
||||
if up+down > 0 {
|
||||
_ = send("usage", map[string]any{"bytes_up": up, "bytes_down": down})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) startJob(ctx context.Context, jobs *sync.WaitGroup, send Sender, frame Frame) {
|
||||
if frame.JobID == "" || len(frame.JobID) > MaxIDBytes {
|
||||
s.log.Printf("job without a usable id ignored")
|
||||
return
|
||||
}
|
||||
if code := s.limiter.Acquire(); code != "" {
|
||||
s.stats.Refused()
|
||||
_ = send("job_error", map[string]any{"job_id": frame.JobID, "code": code})
|
||||
return
|
||||
}
|
||||
jobs.Add(1)
|
||||
go func() {
|
||||
defer jobs.Done()
|
||||
defer s.limiter.Release()
|
||||
s.runJob(ctx, send, frame)
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Session) runJob(ctx context.Context, send Sender, frame Frame) {
|
||||
result, err := s.fetcher.Do(ctx, frame, s.maxBody, s.limiter.Remaining())
|
||||
if err != nil {
|
||||
code := ErrorCode(err)
|
||||
var policyErr *PolicyError
|
||||
if errors.As(err, &policyErr) {
|
||||
s.stats.Refused()
|
||||
} else {
|
||||
s.stats.Failed()
|
||||
}
|
||||
s.log.Debugf("job %s failed: %s", frame.JobID, code)
|
||||
_ = send("job_error", map[string]any{"job_id": frame.JobID, "code": code})
|
||||
return
|
||||
}
|
||||
up := int64(len(frame.URL) + 256)
|
||||
down := int64(len(result.Body))
|
||||
s.limiter.AddBytes(up + down)
|
||||
s.stats.Job(up, down)
|
||||
fields := map[string]any{
|
||||
"job_id": frame.JobID,
|
||||
"status": result.Status,
|
||||
"headers": result.Headers,
|
||||
"body_b64": base64.StdEncoding.EncodeToString(result.Body),
|
||||
"truncated": result.Truncated,
|
||||
"elapsed_ms": int(result.Elapsed.Milliseconds()),
|
||||
}
|
||||
if err := send("job_result", fields); err != nil {
|
||||
s.log.Printf("job %s result could not be sent: %v", frame.JobID, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testSecret = "dpn_testsecret0123456789"
|
||||
|
||||
type harness struct {
|
||||
server *httptest.Server
|
||||
content *httptest.Server
|
||||
session *Session
|
||||
policy *Policy
|
||||
conns atomic.Int32
|
||||
tickets atomic.Int32
|
||||
failure atomic.Value
|
||||
}
|
||||
|
||||
func readFrame(t *testing.T, c *Conn) map[string]any {
|
||||
t.Helper()
|
||||
_ = c.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
raw, err := c.ReadMessage()
|
||||
if err != nil {
|
||||
t.Errorf("fake server read: %v", err)
|
||||
return map[string]any{"type": "read_error"}
|
||||
}
|
||||
frame := map[string]any{}
|
||||
if err := json.Unmarshal(raw, &frame); err != nil {
|
||||
t.Errorf("fake server decode: %v", err)
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
func writeFrame(t *testing.T, c *Conn, frame map[string]any) {
|
||||
t.Helper()
|
||||
data, _ := json.Marshal(frame)
|
||||
if err := c.WriteText(string(data)); err != nil {
|
||||
t.Errorf("fake server write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func welcome() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "welcome",
|
||||
"device_uid": "dev1",
|
||||
"limits": map[string]any{
|
||||
"max_body_bytes": 1000, "ping_seconds": 20, "reconnect_min_seconds": 1, "reconnect_max_seconds": 2,
|
||||
"max_concurrent_per_node": 2, "max_donor_mb_per_day": 20480, "job_timeout_seconds": 25,
|
||||
},
|
||||
"destinations": []any{
|
||||
map[string]any{"suffix": "reddit.com", "adapter": "reddit", "methods": []string{"GET"}, "ports": []int{80, 443}, "modes": []string{"tunnel", "fetch"}, "max_rps": 0.5},
|
||||
map[string]any{"suffix": "redd.it", "adapter": "reddit", "methods": []string{"GET"}, "ports": []int{80, 443}, "modes": []string{"tunnel", "fetch"}, "max_rps": 0.5},
|
||||
},
|
||||
"settings": map[string]any{"enabled": true, "bandwidth_mb_day": 500, "max_concurrent": 2, "active_from": "", "active_to": "", "timezone": "UTC"},
|
||||
}
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T, script func(n int, c *Conn)) *harness {
|
||||
t.Helper()
|
||||
h := &harness{}
|
||||
h.content = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Cookie") != "" || r.Header.Get("Authorization") != "" {
|
||||
h.failure.Store("identity header reached the destination")
|
||||
}
|
||||
_, _ = w.Write([]byte(strings.Repeat("r", 5000)))
|
||||
}))
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/nodes/ticket", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer "+testSecret {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
n := h.tickets.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ticket": "t" + string(rune('0'+n)), "expires_in": 30, "ws_path": "/nodes/ws"})
|
||||
})
|
||||
mux.HandleFunc("/nodes/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Query().Get("ticket"), "t") || strings.Contains(r.URL.RawQuery, testSecret) {
|
||||
h.failure.Store("bad ticket query")
|
||||
}
|
||||
conn, err := upgrade(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
script(int(h.conns.Add(1)), conn)
|
||||
})
|
||||
h.server = httptest.NewServer(mux)
|
||||
t.Cleanup(h.server.Close)
|
||||
t.Cleanup(h.content.Close)
|
||||
cfg := DefaultConfig()
|
||||
cfg.Server = h.server.URL
|
||||
cfg.Secret = testSecret
|
||||
cfg.Timezone = "UTC"
|
||||
h.policy = testPolicy(publicAnswer)
|
||||
h.policy.dial = func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, "tcp", h.content.Listener.Addr().String())
|
||||
}
|
||||
limiter, err := NewLimiter(cfg, "", time.Now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.session = NewSession(cfg, h.policy, limiter, NewFetcher(h.policy), NewStats(time.Now()), NewLogger(testWriter{t}, true))
|
||||
h.session.jitter = func() float64 { return 0 }
|
||||
return h
|
||||
}
|
||||
|
||||
type testWriter struct{ t *testing.T }
|
||||
|
||||
func (w testWriter) Write(p []byte) (int, error) {
|
||||
w.t.Log(strings.TrimSpace(string(p)))
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (h *harness) run(t *testing.T) error {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
err := h.session.Run(ctx)
|
||||
if failure := h.failure.Load(); failure != nil {
|
||||
t.Error(failure)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func TestSessionServesJobsRefusesBadOnesAndStopsOnKick(t *testing.T) {
|
||||
h := newHarness(t, func(n int, c *Conn) {
|
||||
hello := readFrame(t, c)
|
||||
if hello["type"] != "hello" || hello["protocol"] != float64(1) || hello["kind"] != "native" || hello["client_version"] != Version {
|
||||
t.Errorf("hello %v", hello)
|
||||
}
|
||||
if modes, _ := hello["modes"].([]any); len(modes) == 0 {
|
||||
t.Errorf("hello modes %v", hello["modes"])
|
||||
}
|
||||
if n == 2 {
|
||||
writeFrame(t, c, welcome())
|
||||
writeFrame(t, c, map[string]any{"type": "kick", "reason": "revoked"})
|
||||
return
|
||||
}
|
||||
writeFrame(t, c, welcome())
|
||||
writeFrame(t, c, map[string]any{"type": "ping"})
|
||||
if frame := readFrame(t, c); frame["type"] != "pong" {
|
||||
t.Errorf("expected pong, got %v", frame)
|
||||
}
|
||||
writeFrame(t, c, map[string]any{"type": "job", "job_id": "ok1", "method": "GET", "url": "http://www.reddit.com/r/go.json", "timeout": 5})
|
||||
result := readFrame(t, c)
|
||||
if result["type"] != "job_result" || result["job_id"] != "ok1" || result["status"] != float64(200) || result["truncated"] != true {
|
||||
t.Errorf("result %v", result)
|
||||
} else {
|
||||
body, err := base64.StdEncoding.DecodeString(result["body_b64"].(string))
|
||||
if err != nil || len(body) != 1000 {
|
||||
t.Errorf("body %d %v", len(body), err)
|
||||
}
|
||||
}
|
||||
refused := map[string]string{
|
||||
"bad1": "http://evilreddit.com/", "bad2": "http://reddit.com:8080/", "bad3": "http://127.0.0.1/",
|
||||
}
|
||||
for id, target := range refused {
|
||||
writeFrame(t, c, map[string]any{"type": "job", "job_id": id, "method": "GET", "url": target, "timeout": 5})
|
||||
answer := readFrame(t, c)
|
||||
want := RefuseHost
|
||||
if id == "bad2" {
|
||||
want = RefusePort
|
||||
}
|
||||
if answer["type"] != "job_error" || answer["job_id"] != id || answer["code"] != want {
|
||||
t.Errorf("%s: %v", id, answer)
|
||||
}
|
||||
}
|
||||
writeFrame(t, c, map[string]any{"type": "job", "job_id": "post", "method": "POST", "url": "http://reddit.com/", "timeout": 5})
|
||||
if answer := readFrame(t, c); answer["code"] != RefuseMethod {
|
||||
t.Errorf("post: %v", answer)
|
||||
}
|
||||
writeFrame(t, c, map[string]any{"type": "tunnel_open", "conn_id": "c1", "host": "evil.com", "port": 443})
|
||||
if answer := readFrame(t, c); answer["type"] != "tunnel_opened" || answer["ok"] != false || answer["code"] != RefuseHost {
|
||||
t.Errorf("tunnel: %v", answer)
|
||||
}
|
||||
_ = c.WriteClose(CloseRetryOwner, "owner")
|
||||
})
|
||||
started := time.Now()
|
||||
err := h.run(t)
|
||||
var fatal *FatalError
|
||||
if !errors.As(err, &fatal) || fatal.Reason != "revoked" {
|
||||
t.Fatalf("run returned %v", err)
|
||||
}
|
||||
if h.conns.Load() != 2 || h.tickets.Load() != 2 {
|
||||
t.Fatalf("connections %d tickets %d", h.conns.Load(), h.tickets.Load())
|
||||
}
|
||||
if time.Since(started) > 5*time.Second {
|
||||
t.Fatal("4013 was not retried quickly")
|
||||
}
|
||||
if line := h.session.stats.Line(); !strings.Contains(line, "jobs=1") || !strings.Contains(line, "refused=") {
|
||||
t.Fatalf("stats %s", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRelaysTunnelBothWays(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
buffer := make([]byte, 64)
|
||||
for {
|
||||
n, err := conn.Read(buffer)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = conn.Write([]byte("echo:" + string(buffer[:n])))
|
||||
}
|
||||
}()
|
||||
h := newHarness(t, func(n int, c *Conn) {
|
||||
readFrame(t, c)
|
||||
writeFrame(t, c, welcome())
|
||||
if n == 2 {
|
||||
writeFrame(t, c, map[string]any{"type": "kick", "reason": "kill_switch"})
|
||||
return
|
||||
}
|
||||
writeFrame(t, c, map[string]any{"type": "tunnel_open", "conn_id": "c1", "host": "www.reddit.com", "port": 443})
|
||||
if answer := readFrame(t, c); answer["type"] != "tunnel_opened" || answer["ok"] != true {
|
||||
t.Errorf("opened: %v", answer)
|
||||
return
|
||||
}
|
||||
writeFrame(t, c, map[string]any{"type": "tunnel_data", "conn_id": "c1", "b64": base64.StdEncoding.EncodeToString([]byte("hi"))})
|
||||
data := readFrame(t, c)
|
||||
decoded, _ := base64.StdEncoding.DecodeString(data["b64"].(string))
|
||||
if data["type"] != "tunnel_data" || string(decoded) != "echo:hi" {
|
||||
t.Errorf("data: %v %q", data, decoded)
|
||||
}
|
||||
writeFrame(t, c, map[string]any{"type": "tunnel_close", "conn_id": "c1"})
|
||||
_ = c.WriteClose(CloseGoingAway, "restart")
|
||||
})
|
||||
h.policy.dial = func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, "tcp", listener.Addr().String())
|
||||
}
|
||||
err = h.run(t)
|
||||
var fatal *FatalError
|
||||
if !errors.As(err, &fatal) || fatal.Reason != "kill_switch" {
|
||||
t.Fatalf("run returned %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLimitsAnswerWithDonorCodes(t *testing.T) {
|
||||
h := newHarness(t, func(n int, c *Conn) {
|
||||
readFrame(t, c)
|
||||
writeFrame(t, c, welcome())
|
||||
if n == 1 {
|
||||
writeFrame(t, c, map[string]any{"type": "job", "job_id": "a", "method": "GET", "url": "http://reddit.com/", "timeout": 5})
|
||||
if answer := readFrame(t, c); answer["code"] != RefuseBandwidth {
|
||||
t.Errorf("bandwidth: %v", answer)
|
||||
}
|
||||
_ = c.WriteClose(CloseGoingAway, "bye")
|
||||
return
|
||||
}
|
||||
writeFrame(t, c, map[string]any{"type": "kick", "reason": "suspended"})
|
||||
})
|
||||
h.session.limiter.AddBytes(1 << 40)
|
||||
err := h.run(t)
|
||||
var fatal *FatalError
|
||||
if !errors.As(err, &fatal) || fatal.Reason != "suspended" {
|
||||
t.Fatalf("run returned %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFatalTicketStatuses(t *testing.T) {
|
||||
cases := []struct {
|
||||
status int
|
||||
body string
|
||||
fatal string
|
||||
}{
|
||||
{401, "", "unauthorized"},
|
||||
{403, `{"error":{"status":403,"message":"x","reason":"no_consent"}}`, "no_consent"},
|
||||
{403, `{"error":{"status":403,"message":"x","reason":"suspended"}}`, "suspended"},
|
||||
}
|
||||
for _, item := range cases {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(item.status)
|
||||
_, _ = w.Write([]byte(item.body))
|
||||
}))
|
||||
cfg := DefaultConfig()
|
||||
cfg.Server, cfg.Secret = server.URL, testSecret
|
||||
limiter, _ := NewLimiter(cfg, "", time.Now)
|
||||
policy := testPolicy(publicAnswer)
|
||||
session := NewSession(cfg, policy, limiter, NewFetcher(policy), NewStats(time.Now()), NewLogger(testWriter{t}, true))
|
||||
err := session.Run(context.Background())
|
||||
var fatal *FatalError
|
||||
if !errors.As(err, &fatal) || !strings.Contains(fatal.Reason, item.fatal) {
|
||||
t.Errorf("%d: %v", item.status, err)
|
||||
}
|
||||
server.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransientTicketFailureRetries(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if calls.Add(1) < 3 {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := DefaultConfig()
|
||||
cfg.Server, cfg.Secret = server.URL, testSecret
|
||||
limiter, _ := NewLimiter(cfg, "", time.Now)
|
||||
policy := testPolicy(publicAnswer)
|
||||
session := NewSession(cfg, policy, limiter, NewFetcher(policy), NewStats(time.Now()), NewLogger(testWriter{t}, true))
|
||||
session.jitter = func() float64 { return 0 }
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
var fatal *FatalError
|
||||
if err := session.Run(ctx); !errors.As(err, &fatal) || calls.Load() != 3 {
|
||||
t.Fatalf("err %v calls %d", err, calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackoffStaysInsideBounds(t *testing.T) {
|
||||
session := &Session{limits: ServerLimits{ReconnectMinSeconds: 1, ReconnectMaxSeconds: 60}}
|
||||
for _, jitter := range []float64{0, 0.5, 0.999999} {
|
||||
session.jitter = func() float64 { return jitter }
|
||||
for attempt := 0; attempt < 40; attempt++ {
|
||||
d := session.Backoff(attempt)
|
||||
if d < time.Second || d > 60*time.Second {
|
||||
t.Fatalf("attempt %d jitter %v: %v", attempt, jitter, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
session.jitter = func() float64 { return 0.999999 }
|
||||
if first, later := session.Backoff(0), session.Backoff(5); first > time.Second+time.Millisecond || later <= first {
|
||||
t.Fatalf("growth: %v %v", first, later)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFatalReasonsMatchPlan(t *testing.T) {
|
||||
for _, reason := range []string{"revoked", "suspended", "kill_switch", "no_consent"} {
|
||||
if !FatalReasons[reason] {
|
||||
t.Errorf("%s must stop reconnecting", reason)
|
||||
}
|
||||
}
|
||||
for _, reason := range []string{"heartbeat", "ticket", "capacity"} {
|
||||
if FatalReasons[reason] {
|
||||
t.Errorf("%s must reconnect", reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Stats struct {
|
||||
mu sync.Mutex
|
||||
day string
|
||||
jobs int
|
||||
refused int
|
||||
failed int
|
||||
tunnels int
|
||||
up int64
|
||||
down int64
|
||||
sentUp int64
|
||||
sentDown int64
|
||||
}
|
||||
|
||||
func NewStats(now time.Time) *Stats {
|
||||
return &Stats{day: now.Format("2006-01-02")}
|
||||
}
|
||||
|
||||
func (s *Stats) Job(up, down int64) {
|
||||
s.mu.Lock()
|
||||
s.jobs++
|
||||
s.up += up
|
||||
s.down += down
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Stats) Refused() {
|
||||
s.mu.Lock()
|
||||
s.refused++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Stats) Failed() {
|
||||
s.mu.Lock()
|
||||
s.failed++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Stats) Tunnel() {
|
||||
s.mu.Lock()
|
||||
s.tunnels++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Stats) Bytes(up, down int64) {
|
||||
s.mu.Lock()
|
||||
s.up += up
|
||||
s.down += down
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Stats) Take() (up, down int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
up, down = s.up-s.sentUp, s.down-s.sentDown
|
||||
s.sentUp, s.sentDown = s.up, s.down
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Stats) line() string {
|
||||
return fmt.Sprintf("totals day=%s jobs=%d refused=%d failed=%d tunnels=%d bytes_up=%d bytes_down=%d",
|
||||
s.day, s.jobs, s.refused, s.failed, s.tunnels, s.up, s.down)
|
||||
}
|
||||
|
||||
func (s *Stats) Line() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.line()
|
||||
}
|
||||
|
||||
func (s *Stats) Roll(now time.Time) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
today := now.Format("2006-01-02")
|
||||
if today == s.day {
|
||||
return ""
|
||||
}
|
||||
line := s.line()
|
||||
s.day = today
|
||||
s.jobs, s.refused, s.failed, s.tunnels = 0, 0, 0, 0
|
||||
s.up, s.down, s.sentUp, s.sentDown = 0, 0, 0, 0
|
||||
return line
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Sender func(frameType string, fields map[string]any) error
|
||||
|
||||
type tunnel struct {
|
||||
cancel context.CancelFunc
|
||||
out chan []byte
|
||||
remote bool
|
||||
}
|
||||
|
||||
type Tunnels struct {
|
||||
policy *Policy
|
||||
limiter *Limiter
|
||||
stats *Stats
|
||||
send Sender
|
||||
max int
|
||||
mu sync.Mutex
|
||||
items map[string]*tunnel
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewTunnels(policy *Policy, limiter *Limiter, stats *Stats, send Sender, max int) *Tunnels {
|
||||
return &Tunnels{policy: policy, limiter: limiter, stats: stats, send: send, max: max, items: map[string]*tunnel{}}
|
||||
}
|
||||
|
||||
func (t *Tunnels) deny(id, code string) {
|
||||
_ = t.send("tunnel_opened", map[string]any{"conn_id": id, "ok": false, "code": code})
|
||||
}
|
||||
|
||||
func (t *Tunnels) Open(ctx context.Context, frame Frame) {
|
||||
id := frame.ConnID
|
||||
if id == "" || len(id) > MaxIDBytes {
|
||||
return
|
||||
}
|
||||
if _, err := t.policy.CheckHostPort(frame.Host, frame.Port, "tunnel"); err != nil {
|
||||
t.deny(id, ErrorCode(err))
|
||||
t.stats.Refused()
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
_, exists := t.items[id]
|
||||
full := len(t.items) >= t.max
|
||||
t.mu.Unlock()
|
||||
if exists || full {
|
||||
t.deny(id, RefuseConcurrency)
|
||||
t.stats.Refused()
|
||||
return
|
||||
}
|
||||
if code := t.limiter.Acquire(); code != "" {
|
||||
t.deny(id, code)
|
||||
t.stats.Refused()
|
||||
return
|
||||
}
|
||||
runCtx, cancel := context.WithTimeout(ctx, time.Duration(TunnelLifetimeSeconds)*time.Second)
|
||||
item := &tunnel{cancel: cancel, out: make(chan []byte, TunnelQueueDepth)}
|
||||
t.mu.Lock()
|
||||
if _, taken := t.items[id]; taken || len(t.items) >= t.max {
|
||||
t.mu.Unlock()
|
||||
cancel()
|
||||
t.limiter.Release()
|
||||
t.deny(id, RefuseConcurrency)
|
||||
t.stats.Refused()
|
||||
return
|
||||
}
|
||||
t.items[id] = item
|
||||
t.mu.Unlock()
|
||||
t.wg.Add(1)
|
||||
go t.run(runCtx, id, frame.Host, frame.Port, item)
|
||||
}
|
||||
|
||||
func (t *Tunnels) finish(id string, item *tunnel, announce bool) {
|
||||
t.mu.Lock()
|
||||
if t.items[id] == item {
|
||||
delete(t.items, id)
|
||||
}
|
||||
t.mu.Unlock()
|
||||
item.cancel()
|
||||
t.limiter.Release()
|
||||
if announce {
|
||||
_ = t.send("tunnel_close", map[string]any{"conn_id": id})
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tunnels) run(ctx context.Context, id, host string, port int, item *tunnel) {
|
||||
defer t.wg.Done()
|
||||
conn, err := t.policy.DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
t.finish(id, item, false)
|
||||
t.deny(id, ErrorCode(err))
|
||||
t.stats.Failed()
|
||||
return
|
||||
}
|
||||
t.stats.Tunnel()
|
||||
if err := t.send("tunnel_opened", map[string]any{"conn_id": id, "ok": true}); err != nil {
|
||||
conn.Close()
|
||||
t.finish(id, item, false)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
conn.Close()
|
||||
}()
|
||||
var writers sync.WaitGroup
|
||||
writers.Add(1)
|
||||
go func() {
|
||||
defer writers.Done()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case chunk := <-item.out:
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if _, err := conn.Write(chunk); err != nil {
|
||||
item.cancel()
|
||||
return
|
||||
}
|
||||
t.stats.Bytes(int64(len(chunk)), 0)
|
||||
t.limiter.AddBytes(int64(len(chunk)))
|
||||
}
|
||||
}
|
||||
}()
|
||||
buffer := make([]byte, TunnelChunkBytes)
|
||||
for ctx.Err() == nil {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(time.Duration(TunnelIdleSeconds) * time.Second))
|
||||
n, err := conn.Read(buffer)
|
||||
if n > 0 {
|
||||
t.stats.Bytes(0, int64(n))
|
||||
t.limiter.AddBytes(int64(n))
|
||||
encoded := base64.StdEncoding.EncodeToString(buffer[:n])
|
||||
if sendErr := t.send("tunnel_data", map[string]any{"conn_id": id, "b64": encoded}); sendErr != nil {
|
||||
break
|
||||
}
|
||||
if t.limiter.Remaining() == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
conn.Close()
|
||||
item.cancel()
|
||||
writers.Wait()
|
||||
t.mu.Lock()
|
||||
announce := !item.remote
|
||||
t.mu.Unlock()
|
||||
t.finish(id, item, announce)
|
||||
}
|
||||
|
||||
func (t *Tunnels) Data(frame Frame) {
|
||||
t.mu.Lock()
|
||||
item := t.items[frame.ConnID]
|
||||
t.mu.Unlock()
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
if len(frame.B64) > MaxInboundMessage {
|
||||
item.cancel()
|
||||
return
|
||||
}
|
||||
chunk, err := base64.StdEncoding.Strict().DecodeString(frame.B64)
|
||||
if err != nil {
|
||||
item.cancel()
|
||||
return
|
||||
}
|
||||
select {
|
||||
case item.out <- chunk:
|
||||
default:
|
||||
item.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tunnels) Close(id string) {
|
||||
t.mu.Lock()
|
||||
item := t.items[id]
|
||||
if item != nil {
|
||||
item.remote = true
|
||||
}
|
||||
t.mu.Unlock()
|
||||
if item != nil {
|
||||
item.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tunnels) CloseAll() {
|
||||
t.mu.Lock()
|
||||
for _, item := range t.items {
|
||||
item.cancel()
|
||||
}
|
||||
t.mu.Unlock()
|
||||
t.wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
opContinuation = 0x0
|
||||
opText = 0x1
|
||||
opBinary = 0x2
|
||||
opClose = 0x8
|
||||
opPing = 0x9
|
||||
opPong = 0xA
|
||||
|
||||
wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||
wsWriteTimeout = 30 * time.Second
|
||||
wsHandshakeLimit = 15 * time.Second
|
||||
maxControlBytes = 125
|
||||
)
|
||||
|
||||
type CloseError struct {
|
||||
Code int
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *CloseError) Error() string {
|
||||
return fmt.Sprintf("websocket closed code=%d reason=%q", e.Code, e.Reason)
|
||||
}
|
||||
|
||||
type Conn struct {
|
||||
rwc net.Conn
|
||||
br *bufio.Reader
|
||||
client bool
|
||||
maxMessage int
|
||||
wmu sync.Mutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newConn(rwc net.Conn, br *bufio.Reader, client bool) *Conn {
|
||||
return &Conn{rwc: rwc, br: br, client: client, maxMessage: MaxInboundMessage}
|
||||
}
|
||||
|
||||
func Dial(ctx context.Context, rawURL string) (*Conn, error) {
|
||||
target, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, errors.New("websocket url cannot be parsed")
|
||||
}
|
||||
secure := false
|
||||
switch target.Scheme {
|
||||
case "wss", "https":
|
||||
secure = true
|
||||
case "ws", "http":
|
||||
default:
|
||||
return nil, errors.New("websocket url scheme must be ws, wss, http or https")
|
||||
}
|
||||
host := target.Hostname()
|
||||
port := target.Port()
|
||||
if port == "" {
|
||||
port = "80"
|
||||
if secure {
|
||||
port = "443"
|
||||
}
|
||||
}
|
||||
address := net.JoinHostPort(host, port)
|
||||
dialer := &net.Dialer{Timeout: wsHandshakeLimit}
|
||||
raw, err := dialer.DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("websocket connect to %s failed: %w", address, stripURL(err))
|
||||
}
|
||||
var rwc net.Conn = raw
|
||||
if secure {
|
||||
tlsConn := tls.Client(raw, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12})
|
||||
_ = raw.SetDeadline(time.Now().Add(wsHandshakeLimit))
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
raw.Close()
|
||||
return nil, fmt.Errorf("websocket tls handshake failed: %w", err)
|
||||
}
|
||||
rwc = tlsConn
|
||||
}
|
||||
_ = rwc.SetDeadline(time.Now().Add(wsHandshakeLimit))
|
||||
keyBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(keyBytes); err != nil {
|
||||
rwc.Close()
|
||||
return nil, err
|
||||
}
|
||||
key := base64.StdEncoding.EncodeToString(keyBytes)
|
||||
path := target.RequestURI()
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
request := fmt.Sprintf(
|
||||
"GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\nUser-Agent: devplace-node/%s\r\n\r\n",
|
||||
path, target.Host, key, Version,
|
||||
)
|
||||
if _, err := io.WriteString(rwc, request); err != nil {
|
||||
rwc.Close()
|
||||
return nil, fmt.Errorf("websocket handshake write failed: %w", err)
|
||||
}
|
||||
br := bufio.NewReaderSize(rwc, 16*1024)
|
||||
response, err := http.ReadResponse(br, &http.Request{Method: "GET"})
|
||||
if err != nil {
|
||||
rwc.Close()
|
||||
return nil, fmt.Errorf("websocket handshake read failed: %w", err)
|
||||
}
|
||||
if response.StatusCode != http.StatusSwitchingProtocols {
|
||||
rwc.Close()
|
||||
return nil, fmt.Errorf("websocket handshake refused with http status %d", response.StatusCode)
|
||||
}
|
||||
if !strings.EqualFold(response.Header.Get("Upgrade"), "websocket") ||
|
||||
response.Header.Get("Sec-WebSocket-Accept") != acceptKey(key) {
|
||||
rwc.Close()
|
||||
return nil, errors.New("websocket handshake answer is invalid")
|
||||
}
|
||||
_ = rwc.SetDeadline(time.Time{})
|
||||
return newConn(rwc, br, true), nil
|
||||
}
|
||||
|
||||
func stripURL(err error) error {
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) {
|
||||
return urlErr.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func acceptKey(key string) string {
|
||||
sum := sha1.Sum([]byte(key + wsGUID))
|
||||
return base64.StdEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (c *Conn) SetReadDeadline(t time.Time) error { return c.rwc.SetReadDeadline(t) }
|
||||
|
||||
func (c *Conn) Close() error {
|
||||
c.wmu.Lock()
|
||||
c.closed = true
|
||||
c.wmu.Unlock()
|
||||
return c.rwc.Close()
|
||||
}
|
||||
|
||||
func (c *Conn) writeFrame(fin bool, op byte, payload []byte) error {
|
||||
header := make([]byte, 0, 14)
|
||||
first := op
|
||||
if fin {
|
||||
first |= 0x80
|
||||
}
|
||||
header = append(header, first)
|
||||
maskBit := byte(0)
|
||||
if c.client {
|
||||
maskBit = 0x80
|
||||
}
|
||||
size := len(payload)
|
||||
switch {
|
||||
case size <= 125:
|
||||
header = append(header, maskBit|byte(size))
|
||||
case size <= 0xFFFF:
|
||||
header = append(header, maskBit|126, 0, 0)
|
||||
binary.BigEndian.PutUint16(header[len(header)-2:], uint16(size))
|
||||
default:
|
||||
header = append(header, maskBit|127, 0, 0, 0, 0, 0, 0, 0, 0)
|
||||
binary.BigEndian.PutUint64(header[len(header)-8:], uint64(size))
|
||||
}
|
||||
body := payload
|
||||
if c.client {
|
||||
mask := make([]byte, 4)
|
||||
if _, err := rand.Read(mask); err != nil {
|
||||
return err
|
||||
}
|
||||
header = append(header, mask...)
|
||||
body = make([]byte, size)
|
||||
for i := range payload {
|
||||
body[i] = payload[i] ^ mask[i%4]
|
||||
}
|
||||
}
|
||||
if err := c.rwc.SetWriteDeadline(time.Now().Add(wsWriteTimeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.rwc.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.rwc.Write(body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Conn) WriteText(text string) error {
|
||||
data := []byte(text)
|
||||
c.wmu.Lock()
|
||||
defer c.wmu.Unlock()
|
||||
if c.closed {
|
||||
return net.ErrClosed
|
||||
}
|
||||
op := byte(opText)
|
||||
for first := true; first || len(data) > 0; first = false {
|
||||
chunk := data
|
||||
if len(chunk) > MaxWSFramePayload {
|
||||
chunk = data[:MaxWSFramePayload]
|
||||
}
|
||||
data = data[len(chunk):]
|
||||
if err := c.writeFrame(len(data) == 0, op, chunk); err != nil {
|
||||
return err
|
||||
}
|
||||
op = opContinuation
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Conn) writeControl(op byte, payload []byte) error {
|
||||
if len(payload) > maxControlBytes {
|
||||
payload = payload[:maxControlBytes]
|
||||
}
|
||||
c.wmu.Lock()
|
||||
defer c.wmu.Unlock()
|
||||
if c.closed {
|
||||
return net.ErrClosed
|
||||
}
|
||||
return c.writeFrame(true, op, payload)
|
||||
}
|
||||
|
||||
func (c *Conn) WriteClose(code int, reason string) error {
|
||||
if len(reason) > 100 {
|
||||
reason = reason[:100]
|
||||
}
|
||||
payload := make([]byte, 2, 2+len(reason))
|
||||
binary.BigEndian.PutUint16(payload, uint16(code))
|
||||
payload = append(payload, reason...)
|
||||
return c.writeControl(opClose, payload)
|
||||
}
|
||||
|
||||
func (c *Conn) readFrame() (fin bool, op byte, payload []byte, err error) {
|
||||
head := make([]byte, 2)
|
||||
if _, err = io.ReadFull(c.br, head); err != nil {
|
||||
return
|
||||
}
|
||||
fin = head[0]&0x80 != 0
|
||||
if head[0]&0x70 != 0 {
|
||||
err = &CloseError{Code: CloseProtocolError, Reason: "reserved bits set"}
|
||||
return
|
||||
}
|
||||
op = head[0] & 0x0F
|
||||
masked := head[1]&0x80 != 0
|
||||
if masked == c.client {
|
||||
err = &CloseError{Code: CloseProtocolError, Reason: "bad masking"}
|
||||
return
|
||||
}
|
||||
length := uint64(head[1] & 0x7F)
|
||||
switch length {
|
||||
case 126:
|
||||
ext := make([]byte, 2)
|
||||
if _, err = io.ReadFull(c.br, ext); err != nil {
|
||||
return
|
||||
}
|
||||
length = uint64(binary.BigEndian.Uint16(ext))
|
||||
case 127:
|
||||
ext := make([]byte, 8)
|
||||
if _, err = io.ReadFull(c.br, ext); err != nil {
|
||||
return
|
||||
}
|
||||
length = binary.BigEndian.Uint64(ext)
|
||||
}
|
||||
if op&0x8 != 0 && (length > maxControlBytes || !fin) {
|
||||
err = &CloseError{Code: CloseProtocolError, Reason: "bad control frame"}
|
||||
return
|
||||
}
|
||||
if length > uint64(c.maxMessage) || length > uint64(MaxResultMessage) {
|
||||
err = &CloseError{Code: CloseTooLarge, Reason: "frame too large"}
|
||||
return
|
||||
}
|
||||
var mask []byte
|
||||
if masked {
|
||||
mask = make([]byte, 4)
|
||||
if _, err = io.ReadFull(c.br, mask); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
payload = make([]byte, length)
|
||||
if _, err = io.ReadFull(c.br, payload); err != nil {
|
||||
return
|
||||
}
|
||||
if masked {
|
||||
for i := range payload {
|
||||
payload[i] ^= mask[i%4]
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Conn) fail(code int, reason string) error {
|
||||
_ = c.WriteClose(code, reason)
|
||||
return &CloseError{Code: code, Reason: reason}
|
||||
}
|
||||
|
||||
func (c *Conn) ReadMessage() ([]byte, error) {
|
||||
var message []byte
|
||||
inProgress := false
|
||||
for {
|
||||
fin, op, payload, err := c.readFrame()
|
||||
if err != nil {
|
||||
var closeErr *CloseError
|
||||
if errors.As(err, &closeErr) {
|
||||
return nil, c.fail(closeErr.Code, closeErr.Reason)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
switch op {
|
||||
case opPing:
|
||||
if err := c.writeControl(opPong, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case opPong:
|
||||
case opClose:
|
||||
code, reason := CloseNormal, ""
|
||||
if len(payload) >= 2 {
|
||||
code = int(binary.BigEndian.Uint16(payload))
|
||||
reason = string(payload[2:])
|
||||
}
|
||||
_ = c.WriteClose(code, "")
|
||||
return nil, &CloseError{Code: code, Reason: reason}
|
||||
case opText, opBinary, opContinuation:
|
||||
if op == opContinuation && !inProgress {
|
||||
return nil, c.fail(CloseProtocolError, "unexpected continuation")
|
||||
}
|
||||
if op != opContinuation && inProgress {
|
||||
return nil, c.fail(CloseProtocolError, "interleaved data frames")
|
||||
}
|
||||
if op == opBinary {
|
||||
return nil, c.fail(CloseUnsupported, "binary frames are not supported")
|
||||
}
|
||||
inProgress = !fin
|
||||
if len(message)+len(payload) > c.maxMessage {
|
||||
return nil, c.fail(CloseTooLarge, "message too large")
|
||||
}
|
||||
message = append(message, payload...)
|
||||
if fin {
|
||||
if !utf8.Valid(message) {
|
||||
return nil, c.fail(CloseInvalidPayload, "invalid utf-8")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
default:
|
||||
return nil, c.fail(CloseProtocolError, "unknown opcode")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func upgrade(w http.ResponseWriter, r *http.Request) (*Conn, error) {
|
||||
hijacker := w.(http.Hijacker)
|
||||
rwc, brw, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer := "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " +
|
||||
acceptKey(r.Header.Get("Sec-WebSocket-Key")) + "\r\n\r\n"
|
||||
if _, err := rwc.Write([]byte(answer)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn := newConn(rwc, brw.Reader, false)
|
||||
conn.maxMessage = MaxResultMessage
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func wsServer(t *testing.T, handler func(*Conn)) *httptest.Server {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrade(w, r)
|
||||
if err != nil {
|
||||
t.Errorf("upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
handler(conn)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
func dialTest(t *testing.T, server *httptest.Server) *Conn {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
conn, err := Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http")+"/socket")
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
return conn
|
||||
}
|
||||
|
||||
func TestAcceptKeyVector(t *testing.T) {
|
||||
if got := acceptKey("dGhlIHNhbXBsZSBub25jZQ=="); got != "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" {
|
||||
t.Fatalf("accept key %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTripAndFragmentation(t *testing.T) {
|
||||
sizes := []int{0, 1, 125, 126, 65535, 65536, MaxWSFramePayload, MaxWSFramePayload + 1, 700 * 1024}
|
||||
server := wsServer(t, func(c *Conn) {
|
||||
for range sizes {
|
||||
message, err := c.ReadMessage()
|
||||
if err != nil {
|
||||
t.Errorf("server read: %v", err)
|
||||
return
|
||||
}
|
||||
if len(message) <= MaxInboundMessage {
|
||||
if err := c.WriteText(string(message)); err != nil {
|
||||
t.Errorf("server write: %v", err)
|
||||
return
|
||||
}
|
||||
} else if err := c.WriteText("big"); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
conn := dialTest(t, server)
|
||||
for _, size := range sizes {
|
||||
text := strings.Repeat("a", size)
|
||||
if err := conn.WriteText(text); err != nil {
|
||||
t.Fatalf("write %d: %v", size, err)
|
||||
}
|
||||
got, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("read %d: %v", size, err)
|
||||
}
|
||||
want := text
|
||||
if size > MaxInboundMessage {
|
||||
want = "big"
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Fatalf("size %d: got %d bytes", size, len(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingAnsweredWithPong(t *testing.T) {
|
||||
done := make(chan []byte, 1)
|
||||
server := wsServer(t, func(c *Conn) {
|
||||
_ = c.writeControl(opPing, []byte("probe"))
|
||||
_ = c.WriteText("after")
|
||||
_, op, payload, err := c.readFrame()
|
||||
if err != nil || op != opPong {
|
||||
t.Errorf("expected pong, got op %d err %v", op, err)
|
||||
}
|
||||
done <- payload
|
||||
})
|
||||
conn := dialTest(t, server)
|
||||
message, err := conn.ReadMessage()
|
||||
if err != nil || string(message) != "after" {
|
||||
t.Fatalf("message %q err %v", message, err)
|
||||
}
|
||||
if got := <-done; !bytes.Equal(got, []byte("probe")) {
|
||||
t.Fatalf("pong payload %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientFramesAreMasked(t *testing.T) {
|
||||
masked := make(chan bool, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrade(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
head := make([]byte, 2)
|
||||
if _, err := conn.br.Read(head); err != nil {
|
||||
return
|
||||
}
|
||||
masked <- head[1]&0x80 != 0
|
||||
}))
|
||||
defer server.Close()
|
||||
conn := dialTest(t, server)
|
||||
if err := conn.WriteText("x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !<-masked {
|
||||
t.Fatal("client frame was not masked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseCodeSurfaces(t *testing.T) {
|
||||
server := wsServer(t, func(c *Conn) { _ = c.WriteClose(CloseRetryOwner, "owner") })
|
||||
conn := dialTest(t, server)
|
||||
_, err := conn.ReadMessage()
|
||||
var closeErr *CloseError
|
||||
if !errors.As(err, &closeErr) || closeErr.Code != CloseRetryOwner || closeErr.Reason != "owner" {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizeFrameRejectedWithClose(t *testing.T) {
|
||||
server := wsServer(t, func(c *Conn) {
|
||||
_ = c.writeFrame(true, opText, []byte(strings.Repeat("b", MaxWSFramePayload+1)))
|
||||
_, _, _, _ = c.readFrame()
|
||||
})
|
||||
conn := dialTest(t, server)
|
||||
_, err := conn.ReadMessage()
|
||||
var closeErr *CloseError
|
||||
if !errors.As(err, &closeErr) || closeErr.Code != CloseTooLarge {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizeMessageAcrossFragmentsRejected(t *testing.T) {
|
||||
server := wsServer(t, func(c *Conn) {
|
||||
chunk := []byte(strings.Repeat("c", MaxWSFramePayload))
|
||||
_ = c.writeFrame(false, opText, chunk)
|
||||
_ = c.writeFrame(true, opContinuation, chunk)
|
||||
_, _, _, _ = c.readFrame()
|
||||
})
|
||||
conn := dialTest(t, server)
|
||||
_, err := conn.ReadMessage()
|
||||
var closeErr *CloseError
|
||||
if !errors.As(err, &closeErr) || closeErr.Code != CloseTooLarge {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBinaryAndBadUTF8Rejected(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
op byte
|
||||
payload []byte
|
||||
code int
|
||||
}{
|
||||
{"binary", opBinary, []byte("x"), CloseUnsupported},
|
||||
{"utf8", opText, []byte{0xff, 0xfe}, CloseInvalidPayload},
|
||||
{"continuation", opContinuation, []byte("x"), CloseProtocolError},
|
||||
}
|
||||
for _, item := range cases {
|
||||
server := wsServer(t, func(c *Conn) {
|
||||
_ = c.writeFrame(true, item.op, item.payload)
|
||||
_, _, _, _ = c.readFrame()
|
||||
})
|
||||
conn := dialTest(t, server)
|
||||
_, err := conn.ReadMessage()
|
||||
var closeErr *CloseError
|
||||
if !errors.As(err, &closeErr) || closeErr.Code != item.code {
|
||||
t.Fatalf("%s: got %v", item.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandshakeRefusal(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "no", http.StatusForbidden)
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := Dial(context.Background(), "ws"+strings.TrimPrefix(server.URL, "http"))
|
||||
if err == nil || !strings.Contains(err.Error(), "403") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user