diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..bac3af0 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml new file mode 100644 index 0000000..00812be --- /dev/null +++ b/.github/workflows/docker-hub.yml @@ -0,0 +1,47 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Publish Docker image + +on: + release: + types: [published] + +jobs: + push_to_registry: + name: Push Docker image to Docker Hub + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + with: + platforms: 'amd64,arm64' + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to Docker Hub + uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + images: interaapps/punyshort-redirect-proxy + + - name: Build and push Docker image + uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30f80e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ + + +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..8afe0bb --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/redirect-proxy.iml b/.idea/redirect-proxy.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/redirect-proxy.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dc18a06 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.19 + +# Set the Current Working Directory inside the container +WORKDIR /punyshort-proxy + +# Copy everything from the current directory to the PWD (Present Working Directory) inside the container +COPY . . + +# Download all the dependencies +RUN go get -d -v ./... + +# Install the package +RUN go install -v ./... + +# Run the executable +CMD ["punyshort-redirect-proxy"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..98fc4c3 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# Punyshort Redirection Proxy + +```bash +docker run -p 80:80 \ + -e PUNYSHORT_BASE_URL='https://api.punyshort.ga' \ + -e PUNYSHORT_ERROR_URL='https://punyshort.ga/error-page' \ + -e PUNYSHORT_KEY='xxx' \ + -e PUNYSHORT_IP_FORWARDING='true' \ + interaapps/punyshort-redirect-proxy +``` + +## Environment Variables +- PUNYSHORT_BASE_URL +- PUNYSHORT_KEY +- PUNYSHORT_IP_FORWARDING - Allow x-forwarded-for +- PUNYSHORT_ERROR_URL +- PUNYSHORT_USE_SSL: !! Experimental !! - Generating SSL Certificate with Let's Encrypt automatically \ No newline at end of file diff --git a/apiclient/apiclient.go b/apiclient/apiclient.go new file mode 100644 index 0000000..a6bd7d0 --- /dev/null +++ b/apiclient/apiclient.go @@ -0,0 +1,72 @@ +package apiclient + +import ( + "bytes" + "encoding/json" + "io" + "io/ioutil" + "net/http" +) + +type PunyshortAPI struct { + baseUrl string + apiToken string + httpClient http.Client +} + +func NewClient(baseUrl string, key string) PunyshortAPI { + api := PunyshortAPI{ + baseUrl: "https://api.punyshort.ga", + httpClient: http.Client{}, + } + api.SetBaseURL(baseUrl) + api.SetApiToken(key) + return api +} + +func (apiClient *PunyshortAPI) SetApiToken(token string) { + apiClient.apiToken = token +} + +func (apiClient *PunyshortAPI) SetBaseURL(baseURL string) { + apiClient.baseUrl = baseURL +} + +func (apiClient PunyshortAPI) Request(method string, url string, body interface{}) (*http.Response, error) { + var bodyReader io.Reader = nil + + if body != nil { + bodyJson, _ := json.Marshal(body) + bodyReader = bytes.NewReader(bodyJson) + } + + req, err := http.NewRequest(method, apiClient.baseUrl+url, bodyReader) + + if err != nil { + return nil, err + } + if apiClient.apiToken != "" { + req.Header.Set("Authorization", "Bearer "+apiClient.apiToken) + } + res, err := apiClient.httpClient.Do(req) + + return res, err +} + +func (apiClient PunyshortAPI) RequestMap(method string, url string, body interface{}, ma interface{}) (*http.Response, error) { + response, err := apiClient.Request(method, url, body) + if err != nil { + return response, err + } + all, err := ioutil.ReadAll(response.Body) + + if err != nil { + return nil, err + } + + err2 := json.Unmarshal(all, &ma) + if err2 != nil { + return nil, err2 + } + return response, err +} diff --git a/apiclient/shortenlink.go b/apiclient/shortenlink.go new file mode 100644 index 0000000..1b0e11f --- /dev/null +++ b/apiclient/shortenlink.go @@ -0,0 +1,26 @@ +package apiclient + +type ShortenLink struct { + Error bool `json:"error"` + Exception string `json:"exception"` + Domain string `json:"path"` + Path string `json:"path"` + LongLink string `json:"long_link"` +} + +type RedirectionData struct { + Domain string `json:"domain"` + Referrer string `json:"referrer"` + Ip string `json:"ip"` + Path string `json:"path"` + UserAgent string `json:"user_agent"` +} + +func (apiClient PunyshortAPI) FollowRedirection(data RedirectionData) (ShortenLink, error) { + proxyConfig := ShortenLink{} + _, err := apiClient.RequestMap("POST", "/v1/follow", data, &proxyConfig) + if err != nil { + return ShortenLink{}, err + } + return proxyConfig, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..da0fd66 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module punyshort-redirect-proxy + +go 1.19 + +require golang.org/x/crypto v0.5.0 + +require ( + golang.org/x/net v0.5.0 // indirect + golang.org/x/text v0.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bc5ca45 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE= +golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= +golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= +golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= +golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= diff --git a/helper/redirect_proxy.go b/helper/redirect_proxy.go new file mode 100644 index 0000000..9545fe8 --- /dev/null +++ b/helper/redirect_proxy.go @@ -0,0 +1,42 @@ +package helper + +import ( + "errors" + "net" + "net/http" + "strings" +) + +func GetIP(r *http.Request, allowForwarded bool) (string, error) { + ips := "" + + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" && allowForwarded { + ips = forwarded + } + + splitIps := strings.Split(ips, ",") + + if len(splitIps) > 0 { + // get last IP in list since ELB prepends other user defined IPs, meaning the last one is the actual client IP. + netIP := net.ParseIP(splitIps[len(splitIps)-1]) + if netIP != nil { + return netIP.String(), nil + } + } + + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return "", err + } + + netIP := net.ParseIP(ip) + if netIP != nil { + ip := netIP.String() + if ip == "::1" { + return "127.0.0.1", nil + } + return ip, nil + } + + return "", errors.New("IP not found") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..bf54afa --- /dev/null +++ b/main.go @@ -0,0 +1,111 @@ +package main + +import ( + "crypto/tls" + "golang.org/x/crypto/acme/autocert" + "log" + "net/http" + "os" + "punyshort-redirect-proxy/apiclient" + "punyshort-redirect-proxy/helper" + "strings" + "time" +) + +func main() { + println("Starting...") + apiClient := apiclient.NewClient(os.Getenv("PUNYSHORT_BASE_URL"), os.Getenv("PUNYSHORT_KEY")) + + followRedirect := func(writer http.ResponseWriter, request *http.Request) { + ip, _ := helper.GetIP(request, os.Getenv("PUNYSHORT_IP_FORWARDING") == "true") + + shorten, err := apiClient.FollowRedirection(apiclient.RedirectionData{ + Domain: request.Host, + Path: request.URL.Path[1:], + Ip: ip, + UserAgent: request.UserAgent(), + Referrer: request.Referer(), + }) + + if err != nil { + log.Fatalln(err) + return + } + + if shorten.Error { + errorUrl := os.Getenv("PUNYSHORT_ERROR_URL") + if errorUrl != "" { + writer.Header().Set("Location", errorUrl+"?error="+shorten.Exception) + writer.WriteHeader(307) + } else { + writer.Write([]byte(errorUrl + "Error: " + strings.Replace(shorten.Exception, "Exception", "", 1))) + } + return + } + + writer.Header().Set("Location", shorten.LongLink) + writer.WriteHeader(307) + } + + useSSL := false + + if useSSL && os.Getenv("PUNYSHORT_USE_SSL") == "true" { + useSSL = true + } + + mux := http.NewServeMux() + + domains := []string{} + + mux.HandleFunc("/", followRedirect) + + certManager := &autocert.Manager{} + + if useSSL { + certManager.Prompt = autocert.AcceptTOS + certManager.Cache = autocert.DirCache("letsencrypt") + + server := http.Server{ + Addr: ":443", + Handler: mux, + TLSConfig: &tls.Config{GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) { + + println("Serving " + info.ServerName) + for _, name := range domains { + if name == info.ServerName { + return certManager.GetCertificate(info) + } + } + + println("Adding " + info.ServerName) + domains = append(domains, info.ServerName) + certManager.HostPolicy = autocert.HostWhitelist(domains...) + + return certManager.GetCertificate(info) + }}, + ReadHeaderTimeout: 60 * time.Second, + } + + go server.ListenAndServeTLS("", "") + } + + httpMux := http.NewServeMux() + + httpMux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) { + followRedirect(writer, request) + + if useSSL { + host := request.Host + + for _, name := range domains { + if name == host { + return + } + } + domains = append(domains, host) + certManager.HostPolicy = autocert.HostWhitelist(domains...) + } + }) + + log.Fatal(http.ListenAndServe(":80", httpMux)) +}