Zo doen wijdat in oost

This commit is contained in:
2026-10-16 07:20:44 +02:00
parent 25d9cb1c7d
commit 6f80a820da
229 changed files with 23894 additions and 74 deletions
+62
View File
@@ -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 ./...`
+54
View File
@@ -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"
+177
View File
@@ -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
}
+116
View File
@@ -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)
}
}
+156
View File
@@ -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
}
+198
View File
@@ -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)
}
}
}
+3
View File
@@ -0,0 +1,3 @@
module devplace-node
go 1.22
+238
View File
@@ -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()
}
+162
View File
@@ -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)
}
}
+26
View File
@@ -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...))
}
}
+211
View File
@@ -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
}
+15
View File
@@ -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"
+317
View File
@@ -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
}
+233
View File
@@ -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)
}
}
+146
View File
@@ -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
}
+445
View File
@@ -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)
}
}
+362
View File
@@ -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)
}
}
}
+90
View File
@@ -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
}
+201
View File
@@ -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()
}
+358
View File
@@ -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")
}
}
}
+220
View File
@@ -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)
}
}