diff --git a/.github/workflows/docker-build-publish.yaml b/.github/workflows/docker-build-publish.yaml new file mode 100644 index 0000000..ef0fef9 --- /dev/null +++ b/.github/workflows/docker-build-publish.yaml @@ -0,0 +1,25 @@ +name: Docker Build & Publish + +# Trigger on all push events, new semantic version tags, and all PRs +on: + push: + branches: + - "main" + - "v[0-9].[0-9].x" + - "v[0-9].[0-9][0-9].x" + - "v[0-9].x" + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-alpha.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-beta.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-rc[0-9]+" + pull_request: + +jobs: + docker-security-build: + permissions: + contents: write + packages: write + uses: celestiaorg/.github/.github/workflows/reusable_dockerfile_pipeline.yml@v0.2.8 # yamllint disable-line rule:line-length + with: + dockerfile: Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a4d2956 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Use the official Golang image to create a build artifact. +FROM golang:1.21.1 AS builder + +# Set the working directory inside the container. +WORKDIR /app + +# Copy go mod and sum files +#COPY go.mod go.sum ./ +COPY go.mod ./ + +# Download all dependencies. +RUN go mod download + +# Copy the source code into the container. +COPY . . + +# Build the Go app +RUN CGO_ENABLED=0 GOOS=linux go build -o main . + +# Use a lightweight image for the final image. +FROM alpine:3 + +# Copy the binary. +COPY --from=builder /app/main /app/main + +# Run the binary. +CMD ["/app/main"] diff --git a/README.md b/README.md index 82a341c..70cd774 100644 --- a/README.md +++ b/README.md @@ -1 +1,5 @@ -# autoscale-proxy \ No newline at end of file +# autoscale-proxy + +## ToDo + +- use internal services instead of domain names diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f8ef008 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/celestiaorg/autoscale-proxy + +go 1.21.1 + +require ( + github.com/andybalholm/brotli v1.0.6 + github.com/gorilla/websocket v1.5.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..93968f7 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/main.go b/main.go new file mode 100644 index 0000000..1dca873 --- /dev/null +++ b/main.go @@ -0,0 +1,236 @@ +package main + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "io" + "log" + "net/http" + "os" + "strings" + + "github.com/andybalholm/brotli" + "github.com/gorilla/websocket" +) + +var ( + debugLog *log.Logger + infoLog *log.Logger + errorLog *log.Logger +) + +func init() { + debugLog = log.New(os.Stdout, "DEBUG: ", log.Ldate|log.Ltime|log.Lshortfile) + infoLog = log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile) + errorLog = log.New(os.Stderr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile) +} + +func replaceDomainInResponse(originalSubdomain, replaceSubdomain, originalDomain string, buffer *bytes.Buffer) { + body := buffer.String() + fullReplace := replaceSubdomain + "." + "lunaroasis.net" // We know that statescale and snapscale are under this domain + fullOriginal := originalSubdomain + "." + originalDomain // Original domain can vary + replacedBody := strings.ReplaceAll(body, fullReplace, fullOriginal) + buffer.Reset() + buffer.WriteString(replacedBody) +} + +func proxyRequest(fullSubdomain, path string, buffer *bytes.Buffer, r *http.Request) (int, map[string]string, error) { + client := &http.Client{} + target := "https://" + fullSubdomain + ".lunaroasis.net" + path + newReq, err := http.NewRequest(r.Method, target, r.Body) + if err != nil { + errorLog.Printf("Failed to create request: %v", err) + return 0, nil, err + } + newReq.Header = r.Header + + resp, err := client.Do(newReq) + if err != nil { + errorLog.Printf("Failed to send request: %v", err) + return 0, nil, err + } + defer resp.Body.Close() + + headers := make(map[string]string) + for key, values := range resp.Header { + for _, value := range values { + headers[key] = value + } + } + + encoding := resp.Header.Get("Content-Encoding") + var reader io.Reader + switch encoding { + case "br": + // Decompress Brotli data + reader = brotli.NewReader(resp.Body) + case "gzip": + // Decompress Gzip data + reader, err = gzip.NewReader(resp.Body) + if err != nil { + errorLog.Printf("Failed to create gzip reader: %v", err) + return 0, nil, err + } + case "deflate": + // Decompress Deflate data + reader = flate.NewReader(resp.Body) + default: + reader = resp.Body + } + io.Copy(buffer, reader) + + return resp.StatusCode, headers, nil +} + +func handleHttpRequest(w http.ResponseWriter, r *http.Request) { + infoLog.Printf("Received request from %s", r.Host) + + hostParts := strings.Split(r.Host, ".") + if len(hostParts) < 3 { + errorLog.Printf("Invalid domain: %s", r.Host) + http.Error(w, "Invalid domain", http.StatusBadRequest) + return + } + + subdomain := hostParts[0] // Extract original domain + originalDomain := strings.Join(hostParts[1:], ".") + + // Check for WebSocket upgrade headers + if strings.ToLower(r.Header.Get("Upgrade")) == "websocket" { + // Handle WebSocket requests by proxying to snapscale + proxyWebSocketRequest(subdomain, w, r) + return + } + + buffer := new(bytes.Buffer) + backupBuffer := new(bytes.Buffer) + + debugLog.Printf("Proxying request to %s", subdomain+"-statescale") + statusCode, headers, err := proxyRequest(subdomain+"-statescale", r.RequestURI, buffer, r) + debugLog.Printf("Received status code %d", statusCode) + if err != nil || statusCode >= 400 { + debugLog.Printf("Proxying request to %s", subdomain+"-snapscale") + backupStatusCode, backupHeaders, _ := proxyRequest(subdomain+"-snapscale", r.RequestURI, backupBuffer, r) + debugLog.Printf("Received status code %d", backupStatusCode) + + replaceDomainInResponse(subdomain, subdomain+"-snapscale", originalDomain, backupBuffer) + + for key, value := range backupHeaders { + w.Header().Set(key, value) + } + w.WriteHeader(backupStatusCode) + encoding := headers["Content-Encoding"] + buffer = compressData(buffer, encoding) + io.Copy(w, backupBuffer) + return + } + + replaceDomainInResponse(subdomain, subdomain+"-statescale", originalDomain, buffer) + for key, value := range headers { + w.Header().Set(key, value) + } + w.WriteHeader(statusCode) + // If the original response was Brotli-compressed, recompress the data + encoding := headers["Content-Encoding"] + buffer = compressData(buffer, encoding) + io.Copy(w, buffer) +} + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // Allow all connections + }, +} + +func proxyWebSocketRequest(subdomain string, w http.ResponseWriter, r *http.Request) { + // Build target URL + fullSubdomain := subdomain + "-snapscale" + target := "wss://" + fullSubdomain + ".lunaroasis.net" + r.RequestURI + + // Create a new WebSocket connection to the target + dialer := websocket.Dialer{} + targetConn, resp, err := dialer.Dial(target, nil) + if err != nil { + errorLog.Printf("Failed to connect to target: %v", err) + if resp != nil { + errorLog.Printf("Handshake response status: %s", resp.Status) + // Log all response headers for debugging + for k, v := range resp.Header { + errorLog.Printf("%s: %s", k, v) + } + } + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer targetConn.Close() + + // Upgrade the client connection to a WebSocket connection + clientConn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + errorLog.Printf("Failed to upgrade client connection: %v", err) + return // No need to send an error response, Upgrade already did if there was an error + } + defer clientConn.Close() + + // Start goroutines to copy data between the client and target + go func() { + for { + messageType, message, err := targetConn.ReadMessage() + if err != nil { + errorLog.Printf("Failed to read from target: %v", err) + return + } + err = clientConn.WriteMessage(messageType, message) + if err != nil { + errorLog.Printf("Failed to write to client: %v", err) + return + } + } + }() + go func() { + for { + messageType, message, err := clientConn.ReadMessage() + if err != nil { + errorLog.Printf("Failed to read from client: %v", err) + return + } + err = targetConn.WriteMessage(messageType, message) + if err != nil { + errorLog.Printf("Failed to write to target: %v", err) + return + } + } + }() + + // The goroutines will run until one of the connections is closed + select {} +} + +func compressData(buffer *bytes.Buffer, encoding string) *bytes.Buffer { + var compressedData bytes.Buffer + var writer io.WriteCloser + switch encoding { + case "br": + writer = brotli.NewWriterLevel(&compressedData, brotli.DefaultCompression) + case "gzip": + writer = gzip.NewWriter(&compressedData) + case "deflate": + writer, _ = flate.NewWriter(&compressedData, flate.DefaultCompression) + default: + return buffer + } + io.Copy(writer, buffer) + writer.Close() + return &compressedData +} + +func handleRequest(w http.ResponseWriter, r *http.Request) { + handleHttpRequest(w, r) +} + +func main() { + infoLog.Println("Starting server on :8080") + http.HandleFunc("/", handleRequest) + http.ListenAndServe(":8080", nil) +}