From 4609d34e59d9b21c499579396313199188fe9daf Mon Sep 17 00:00:00 2001 From: "Arthur K." Date: Fri, 6 Mar 2026 14:59:59 +0300 Subject: [PATCH] init --- .gitignore | 1 + Dockerfile | 24 +++++++ go.mod | 5 ++ go.sum | 2 + main.go | 182 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 214 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95423cb --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/pp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5c4c6d5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM golang:alpine AS builder + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/pp . + +FROM alpine + +RUN apk add --no-cache tzdata ca-certificates + +WORKDIR /app + +COPY --from=builder /out/pp /usr/local/bin/pp + +EXPOSE 80 + +HEALTHCHECK --start-period=5s --start-interval=1s CMD \ + test "$(wget -q -O - http://127.0.0.1/health)" = "ok" + +ENTRYPOINT ["/usr/local/bin/pp"] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8261f51 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.wzray.com/ai/pp + +go 1.25.0 + +require golang.org/x/net v0.51.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a9d0e99 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= diff --git a/main.go b/main.go new file mode 100644 index 0000000..97b534e --- /dev/null +++ b/main.go @@ -0,0 +1,182 @@ +package main + +import ( + "bufio" + "encoding/base64" + "fmt" + "io" + "log" + "net" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +func getEnvDefault(name, def string) string { + v := os.Getenv(name) + if v != "" { + return v + } + return def +} + +func parseProxy() *url.URL { + raw := strings.TrimPrefix(strings.TrimSpace(os.Getenv("PROXY")), "http://") + if raw == "" { + log.Fatal("missing PROXY (format: login:password@ip:port)") + } + + creds, hostPort, ok := strings.Cut(raw, "@") + if !ok { + log.Fatal("expected login:password@ip:port") + } + + login, password, ok := strings.Cut(creds, ":") + if !ok || strings.TrimSpace(login) == "" || strings.TrimSpace(password) == "" { + log.Fatal("expected login:password before @") + } + + hostPort = strings.TrimSpace(hostPort) + if _, _, err := net.SplitHostPort(hostPort); err != nil { + log.Fatalf("invalid ip:port: %v", err) + } + + return &url.URL{Scheme: "http", User: url.UserPassword(login, password), Host: hostPort} +} + +func proxyHandler(transport *http.Transport, upstream *url.URL) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/health" { + w.Write([]byte("ok")) + return + } + + if r.Method == http.MethodConnect { + handleTunneling(w, r, upstream) + return + } + + handleHTTP(w, r, transport) + } +} + +func handleHTTP(w http.ResponseWriter, r *http.Request, transport *http.Transport) { + target := r.URL + if !target.IsAbs() { + target = &(*r.URL) + target.Scheme = "http" + target.Host = r.Host + } + + outReq, err := http.NewRequestWithContext(r.Context(), r.Method, target.String(), r.Body) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + outReq.Header = r.Header.Clone() + outReq.RequestURI = "" + + resp, err := transport.RoundTrip(outReq) + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer resp.Body.Close() + + copyHeader(w.Header(), resp.Header) + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) +} + +func handleTunneling(w http.ResponseWriter, r *http.Request, upstream *url.URL) { + targetAddr := r.Host + if _, _, err := net.SplitHostPort(targetAddr); err != nil { + targetAddr = net.JoinHostPort(targetAddr, "443") + } + + upstreamConn, err := net.DialTimeout("tcp", upstream.Host, 10*time.Second) + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + + if _, err := fmt.Fprintf( + upstreamConn, + "CONNECT %s HTTP/1.1\r\nHost: %s\r\nProxy-Authorization: %s\r\n\r\n", + targetAddr, + targetAddr, + basicAuthHeader(upstream.User), + ); err != nil { + _ = upstreamConn.Close() + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + + br := bufio.NewReader(upstreamConn) + resp, err := http.ReadResponse(br, &http.Request{Method: http.MethodConnect}) + if err != nil { + _ = upstreamConn.Close() + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + _ = upstreamConn.Close() + http.Error(w, resp.Status, http.StatusBadGateway) + return + } + _ = resp.Body.Close() + + hijacker, ok := w.(http.Hijacker) + if !ok { + _ = upstreamConn.Close() + http.Error(w, "hijacking not supported", http.StatusInternalServerError) + return + } + + clientConn, _, err := hijacker.Hijack() + if err != nil { + _ = upstreamConn.Close() + return + } + + _, _ = clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) + + go transfer(upstreamConn, clientConn) + go transfer(clientConn, upstreamConn) +} + +func basicAuthHeader(user *url.Userinfo) string { + if user == nil { + return "" + } + password, _ := user.Password() + token := base64.StdEncoding.EncodeToString([]byte(user.Username() + ":" + password)) + return "Basic " + token +} + +func transfer(dst io.WriteCloser, src io.ReadCloser) { + defer dst.Close() + defer src.Close() + _, _ = io.Copy(dst, src) +} + +func copyHeader(dst, src http.Header) { + for k, vv := range src { + for _, v := range vv { + dst.Add(k, v) + } + } +} + +func main() { + listenAddr := ":" + getEnvDefault("PORT", "80") + upstream := parseProxy() + + transport := &http.Transport{Proxy: http.ProxyURL(upstream)} + + log.Printf("Proxy running on %s -> http://%s@%s", listenAddr, upstream.User.Username(), upstream.Host) + log.Fatal(http.ListenAndServe(listenAddr, proxyHandler(transport, upstream))) +}