diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..740dbdc --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +GITHUB_USER=neondatabase +IMAGE_NAME=wsproxy +PLATFORMS=linux/amd64,linux/arm64 +BUILDER_NAME=$(IMAGE_NAME)-builder +REMOTE_TAG=ghcr.io/$(GITHUB_USER)/$(IMAGE_NAME) + +build: + docker build -t $(IMAGE_NAME) . + +run: + docker run --rm -p 80:80 -p 2112:2112 --name $(IMAGE_NAME) $(IMAGE_NAME) + +publish: + docker buildx create --use --name $(BUILDER_NAME) || true + docker buildx build --platform $(PLATFORMS) -t $(REMOTE_TAG) --push . + docker buildx rm $(BUILDER_NAME) \ No newline at end of file diff --git a/README.md b/README.md index 602bed2..6ca11c4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Lightweight websocket->TCP proxy. Look at `main.go` for available configuration options. +Read more about how to deploy with Neon Serverless proxy: https://github.com/neondatabase/serverless/blob/main/DEPLOY.md + Run: ```bash diff --git a/examples/with-unix-socket/.gitignore b/examples/with-unix-socket/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/examples/with-unix-socket/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/examples/with-unix-socket/README.md b/examples/with-unix-socket/README.md new file mode 100644 index 0000000..afa756a --- /dev/null +++ b/examples/with-unix-socket/README.md @@ -0,0 +1,24 @@ +# with-unix-socket + +Run Postgres instance with Unix socket and connect to it using Neon's WS Proxy and Drizzle ORM. + +This project was created using `bun init` in bun v1.1.27. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +docker compose up -d +NEON_WS_PROXY_HOST=$(docker compose port neon 80) bun run index.ts +``` + +To clean up: + +```bash +docker compose down +``` diff --git a/examples/with-unix-socket/bun.lockb b/examples/with-unix-socket/bun.lockb new file mode 100755 index 0000000..c42cd66 Binary files /dev/null and b/examples/with-unix-socket/bun.lockb differ diff --git a/examples/with-unix-socket/docker-compose.yaml b/examples/with-unix-socket/docker-compose.yaml new file mode 100644 index 0000000..705b567 --- /dev/null +++ b/examples/with-unix-socket/docker-compose.yaml @@ -0,0 +1,28 @@ +services: + pgsql: + image: timescale/timescaledb:latest-pg16 + restart: always + tmpfs: + - /var/lib/postgresql/data + volumes: + - pg_socket:/var/run/postgresql + environment: + POSTGRES_DB: neon-db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: neon-password + + neon: + image: ghcr.io/flexchar/wsproxy:latest + restart: always + depends_on: + - pgsql + environment: + LOG_CONN_INFO: true + UNIX_SOCKET_PATH: "/var/run/postgresql/.s.PGSQL.5432" + volumes: + - pg_socket:/var/run/postgresql + ports: + - 80 + +volumes: + pg_socket: diff --git a/examples/with-unix-socket/index.ts b/examples/with-unix-socket/index.ts new file mode 100644 index 0000000..702be86 --- /dev/null +++ b/examples/with-unix-socket/index.ts @@ -0,0 +1,39 @@ +import { Pool, neonConfig } from '@neondatabase/serverless'; +import { drizzle } from 'drizzle-orm/neon-serverless'; +import { sql } from 'drizzle-orm'; + +// Docs +// https://orm.drizzle.team/docs/get-started-postgresql#neon-postgres +// Test with +// NEON_WS_PROXY_HOST=$(docker compose port neon 80) bun run index.ts + +const NEON_WS_PROXY_HOST = process.env.NEON_WS_PROXY_HOST; +console.log('NEON_WS_PROXY_HOST', NEON_WS_PROXY_HOST); + +if (!NEON_WS_PROXY_HOST) { + throw new Error('NEON_WS_PROXY_HOST is not set'); +} + +// Set the WebSocket proxy to work with the local instance +neonConfig.wsProxy = () => `${process.env.NEON_WS_PROXY_HOST}/v1`; + +// Disable TLS when running on local machine +neonConfig.useSecureWebSocket = false; // or true, if you, for example, expose using Cloudflare Tunnel +// Disable all authentication and encryption +neonConfig.pipelineTLS = false; +neonConfig.pipelineConnect = false; + +const connectionString = `pgsql://postgres:neon-password@placeholder/neon-db`; +// "placeholder" can be any string just to satisfy Neon's connection string format, +// because we're using Unix socket, we don't need to specify the host and port at all. + +const pool = new Pool({ connectionString }); +const db = drizzle(pool); + +const result = await db.execute(sql`SELECT version()`); +const res = result.rows[0]; + +console.log(res); + +await pool.end(); +process.exit(0); diff --git a/examples/with-unix-socket/package.json b/examples/with-unix-socket/package.json new file mode 100644 index 0000000..cc34411 --- /dev/null +++ b/examples/with-unix-socket/package.json @@ -0,0 +1,16 @@ +{ + "name": "with-unix-socket", + "module": "index.ts", + "type": "module", + "devDependencies": { + "@types/bun": "latest", + "drizzle-kit": "^0.24.2" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@neondatabase/serverless": "^0.9.5", + "drizzle-orm": "^0.33.0" + } +} \ No newline at end of file diff --git a/examples/with-unix-socket/tsconfig.json b/examples/with-unix-socket/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/examples/with-unix-socket/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/main.go b/main.go index d14d6a2..7626297 100644 --- a/main.go +++ b/main.go @@ -42,6 +42,7 @@ type Config struct { UseHostHeader bool `env:"USE_HOST_HEADER" envDefault:"false"` LogTraffic bool `env:"LOG_TRAFFIC" envDefault:"false"` LogConnInfo bool `env:"LOG_CONN_INFO" envDefault:"true"` + UnixSocketPath string `env:"UNIX_SOCKET_PATH" envDefault:""` } var upgrader = websocket.Upgrader{ @@ -81,7 +82,11 @@ func NewProxyHandler(config *Config) (*ProxyHandler, error) { }, nil } -func (h *ProxyHandler) ExtractProxyDest(r *http.Request) (string, error) { +func (h *ProxyHandler) ExtractProxyDest(r *http.Request) (string, string, error) { + if h.cfg.UnixSocketPath != "" { + return "unix", h.cfg.UnixSocketPath, nil + } + addressArg := r.URL.Query().Get("address") hostHeader := r.Host @@ -106,10 +111,10 @@ func (h *ProxyHandler) ExtractProxyDest(r *http.Request) (string, error) { } if !allowed { - return "", fmt.Errorf("proxying to specified address not allowed") + return "", "", fmt.Errorf("proxying to specified address not allowed") } - return addr, nil + return "tcp", addr, nil } func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -117,7 +122,7 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { log.Printf("Got request from %s", r.RemoteAddr) } - addr, err := h.ExtractProxyDest(r) + network, addr, err := h.ExtractProxyDest(r) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -130,27 +135,26 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } defer conn.Close() - err = h.HandleWS(conn, addr) + err = h.HandleWS(conn, network, addr) if err != nil { log.Printf("failed to handle websocket: %v\n", err) return } } -func (h *ProxyHandler) HandleWS(conn *websocket.Conn, addr string) error { +func (h *ProxyHandler) HandleWS(conn *websocket.Conn, network, addr string) error { connectionsProcessed.Inc() activeConnections.Inc() defer activeConnections.Dec() - socket, err := net.Dial("tcp", addr) + socket, err := net.Dial(network, addr) if err != nil { return err } defer socket.Close() go func() { - message := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "TCP connection is closed") - // Close the websocket connection when TCP connection loop is finished. + message := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "Connection is closed") defer func() { err := conn.WriteControl(websocket.CloseMessage, message, time.Now().Add(time.Second)) if err != nil { @@ -163,11 +167,13 @@ func (h *ProxyHandler) HandleWS(conn *websocket.Conn, addr string) error { for { n, err := socket.Read(buf) if err != nil { - log.Printf("failed to read from socket: %v\n", err) + if err != io.EOF { + log.Printf("failed to read from socket: %v\n", err) + } return } - proxiedBytes.WithLabelValues("tcp").Add(float64(n)) + proxiedBytes.WithLabelValues(network).Add(float64(n)) if h.cfg.LogTraffic { log.Printf("Got %d bytes pg->client: %s\n", n, base64.StdEncoding.EncodeToString(buf[:n])) @@ -184,7 +190,10 @@ func (h *ProxyHandler) HandleWS(conn *websocket.Conn, addr string) error { for { _, b, err := conn.ReadMessage() if err != nil { - return err + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("websocket error: %v", err) + } + return nil } proxiedBytes.WithLabelValues("ws").Add(float64(len(b))) @@ -232,7 +241,9 @@ func main() { log.Fatalf("Failed to parse config: %v", err) } - if cfg.AllowAddrRegex == "" { + if cfg.UnixSocketPath != "" { + log.Printf("Using Unix socket path: %s", cfg.UnixSocketPath) + } else if cfg.AllowAddrRegex == "" { log.Printf("WARN: No regex for allowed addresses, allowing all") } else { log.Printf("Using regex for allowed addresses: %v", cfg.AllowAddrRegex)