diff --git a/Dockerfile b/Dockerfile index cad81c2..7496a5c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,17 @@ -FROM python:3.9-alpine - -WORKDIR /app - -RUN pip install flask requests - -COPY . . - -ENV PORT=8880 - -EXPOSE ${PORT} - -CMD ["python", "app.py"] \ No newline at end of file +FROM golang:1.24 + +WORKDIR /app + +COPY go.mod go.sum ./ + +RUN go mod download + +COPY *.go ./ + +RUN CGO_ENABLED=0 GOOS=linux go build -o /secured-signal-api + +ENV PORT=8880 + +EXPOSE ${PORT} + +CMD ["/secured-signal-api"] \ No newline at end of file diff --git a/app.py b/app.py deleted file mode 100644 index c6047f0..0000000 --- a/app.py +++ /dev/null @@ -1,186 +0,0 @@ -from flask import Flask, Response, request, jsonify, make_response, g -import os -import json -import requests -import re -import base64 -import logging -from urllib.parse import unquote, urlencode, parse_qs - -app = Flask("Secured Signal Api") - -app.logger.setLevel(logging.INFO) - -DEFAULT_BLOCKED_ENDPOINTS = [ - "/v1/about", - "/v1/configuration", - "/v1/devices", - "/v1/register", - "/v1/unregister", - "/v1/qrcodelink", - "/v1/accounts", - "/v1/contacts" -] - -SENDER = os.getenv("SENDER") -DEFAULT_RECIPIENTS = os.getenv("DEFAULT_RECIPIENTS") -SIGNAL_API_URL = os.getenv("SIGNAL_API_URL") -API_TOKEN = os.getenv("API_TOKEN") - -BLOCKED_ENDPOINTS = os.getenv("BLOCKED_ENDPOINTS") -VARIABLES = os.getenv("VARIABLES") - -secure = False - -def fillInVars(obj): - if isinstance(obj, dict): - for key, value in obj.items(): - obj[key] = fillInVars(value) - elif isinstance(obj, list): - for i in range(len(obj)): - obj[i] = fillInVars(obj[i]) - elif isinstance(obj, str): - matches = re.findall(r"\${(.*?)}", obj) - for match in matches: - if match in VARIABLES: - value = VARIABLES[match] - - if isinstance(value, str): - newValue = obj.replace(f"${{{match}}}", str(value)) - return newValue - else: - return value - return obj - -def UnauthorizedResponse(prompt=None): - headers = {} - - if prompt: - headers = { - "WWW-Authenticate": 'Basic realm="Login Required", Bearer realm="Access Token Required"' - } - - return Response( - "Unauthorized", 401, - headers - ) - -@app.before_request -def middlewares(): - for blockedPath in BLOCKED_ENDPOINTS: - if request.path.startswith(blockedPath): - infoLog(f"Client tried to access Blocked Endpoint [{blockedPath}]") - return Response("Forbidden", 401) - - query_string = request.query_string.decode() - - if secure: - auth_header = request.headers.get("Authorization", "") - - if auth_header.startswith("Bearer "): - token = auth_header.split(" ", 1)[1] - - if token != API_TOKEN: - infoLog(f"Client failed Bearer Auth [token: {token}]") - return UnauthorizedResponse() - elif auth_header.startswith("Basic "): - try: - decoded = base64.b64decode(auth_header.split(" ", 1)[1]).decode() - username, password = decoded.split(":", 1) - - if username != "api" or password != API_TOKEN: - infoLog(f"Client failed Basic Auth [user: {username}, pw:{password}]") - return UnauthorizedResponse() - except Exception as error: - errorLog(f"Unexpected Error during Basic Auth: {error}") - return UnauthorizedResponse() - elif request.args.get("authorization", None): - token = request.args.get("authorization", "") - - token = unquote(token) - - if token != API_TOKEN: - infoLog(f"Client failed Query Auth [query: {token}]") - return UnauthorizedResponse() - - args = parse_qs(query_string) - - args.pop('authorization', None) - query_string = urlencode(args, doseq=True) - else: - infoLog(f"Client did not provide any Auth Method") - return UnauthorizedResponse(True) - - g.query_string = query_string - -@app.route('/', defaults={'path': ''}, methods=['GET', 'POST', 'PUT']) -@app.route('/', methods=['GET', 'POST', 'PUT']) -def proxy(path): - method = request.method - incomingJSON = request.get_json(force=True, silent=True) - jsonData = incomingJSON - headers = {k: v for k, v in request.headers if k.lower() != 'host'} - - if incomingJSON: - if not incomingJSON.get("recipients", None): - incomingJSON["recipients"] = "${RECIPIENTS}" - - jsonData = fillInVars(incomingJSON) - - if "${NUMBER}" in path: - path = path.replace("${NUMBER}", SENDER) - - query_string = g.query_string - - if query_string: - query_string = "?" + query_string - - targetURL = f"{SIGNAL_API_URL}/{path}{query_string}" - - resp = requests.request( - method=method, - url=targetURL, - headers=headers, - json=jsonData - ) - - infoLog(f"Forwarded {jsonData} to {targetURL} [{method}]") - - # return Response(resp.content, status=resp.status_code, headers=dict(resp.headers)) - - response = make_response(resp.json()) - response.status_code = resp.status_code - - return response - -def infoLog(msg): - app.logger.info(msg) - -def errorLog(msg): - app.logger.error(msg) - -if __name__ == '__main__': - if SENDER and SIGNAL_API_URL: - if API_TOKEN == None or API_TOKEN == "": - infoLog("No API Token set (API_TOKEN), this is not recommended!") - else: - secure = True - infoLog("API Token set, use Bearer or Basic Auth (user: api) for authentication") - - if DEFAULT_RECIPIENTS != None and DEFAULT_RECIPIENTS != "": - DEFAULT_RECIPIENTS = json.loads(DEFAULT_RECIPIENTS) - - if BLOCKED_ENDPOINTS != None and BLOCKED_ENDPOINTS != "": - BLOCKED_ENDPOINTS = json.loads(BLOCKED_ENDPOINTS) - else: - BLOCKED_ENDPOINTS = DEFAULT_BLOCKED_ENDPOINTS - - if VARIABLES != None and VARIABLES != "": - VARIABLES = json.loads(VARIABLES) - else: - VARIABLES = { - "NUMBER": SENDER, - "RECIPIENTS": DEFAULT_RECIPIENTS - } - - app.run(debug=False, port=8880, host='0.0.0.0') \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9a932b8 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/codeshelldev/secured-signal-api + +go 1.24.4 + +require go.uber.org/zap v1.27.0 + +require go.uber.org/multierr v1.10.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e1a34f3 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= diff --git a/internals/proxy/proxy.go b/internals/proxy/proxy.go new file mode 100644 index 0000000..435c3c8 --- /dev/null +++ b/internals/proxy/proxy.go @@ -0,0 +1,167 @@ +package proxy + +import ( + "bytes" + "encoding/base64" + "io" + "net/http" + "net/http/httputil" + "net/url" + "os" + "slices" + "strings" + "text/template" + + log "github.com/codeshelldev/secured-signal-api/utils" +) + +type AuthType string + +const ( + Bearer AuthType = "Bearer" + Basic AuthType = "Basic" + Query AuthType = "Query" + None AuthType = "" +) + +func getAuthType(str string) AuthType { + switch str { + case "Bearer": + return Bearer + case "Basic": + return Basic + default: + return None + } +} + +func renderTemplate(name string, tmplStr string, data any) (string, error) { + tmpl, err := template.New(name).Parse(tmplStr) + + if err != nil { + return "", err + } + var buf bytes.Buffer + + err = tmpl.Execute(&buf, data) + + if err != nil { + return "", err + } + return buf.String(), nil +} + +func AuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + log.Info("Request:", req.Method, req.URL.Path) + + token := os.Getenv("API_TOKEN") + user := "api" + + authHeader := req.Header.Get("Authorization") + + authQuery := req.URL.Query().Get("authorization") + + var authType AuthType + + success := false + + if authHeader != "" { + authBody := strings.Split(authHeader, " ") + + authType = getAuthType(authBody[0]) + authToken := authBody[1] + + switch authType { + case Bearer: + if authToken == token { + success = true + } + + case Basic: + basicAuthBody, err := base64.StdEncoding.DecodeString(authToken) + + if err != nil { + log.Error("Could not decode Basic Auth Payload: ", err.Error()) + } + + basicAuth := string(basicAuthBody) + basicAuthParams := strings.Split(basicAuth, ":") + + if basicAuthParams[0] == user && basicAuthParams[1] == token { + success = true + } + } + + } else if authQuery != "" { + authType = Query + + authToken, _ := url.QueryUnescape(authQuery) + + if authToken == token { + success = true + } + } + + if !success { + log.Warn("User failed ", string(authType), " Auth") + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, req) + }) +} + +func BlockedEndpointMiddleware(next http.Handler, BLOCKED_ENDPOINTS []string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + reqPath := req.URL.Path + + if slices.Contains(BLOCKED_ENDPOINTS, reqPath) { + log.Warn("User tried to access blocked endpoint: ", reqPath) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, req) + }) +} + +func TemplatingMiddleware(next http.Handler, VARIABLES map[string]string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.Body != nil { + bodyBytes, err := io.ReadAll(req.Body) + + if err != nil { + log.Error("Could not decode Body: ", err.Error()) + http.Error(w, "Internal Error", http.StatusInternalServerError) + return + } + + req.Body.Close() + + modifiedBody := string(bodyBytes) + + modifiedBody, _ = renderTemplate("json", modifiedBody, VARIABLES) + modifiedBodyBytes := []byte(modifiedBody) + + req.Body = io.NopCloser(bytes.NewReader(modifiedBodyBytes)) + } + + reqPath := req.URL.Path + + modifiedReqPath, _ := renderTemplate("path", reqPath, VARIABLES) + + req.URL.Path = modifiedReqPath + + next.ServeHTTP(w, req) + }) +} + +func Create(targetUrl string) *httputil.ReverseProxy { + url, _ := url.Parse(targetUrl) + + proxy := httputil.NewSingleHostReverseProxy(url) + + return proxy +} \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..ab08c11 --- /dev/null +++ b/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httputil" + "os" + + proxy "github.com/codeshelldev/secured-signal-api/internals/proxy" + log "github.com/codeshelldev/secured-signal-api/utils" +) + +var handler *httputil.ReverseProxy + +var VARIABLES map[string]string = map[string]string{ + "RECIPIENTS": os.Getenv("DEFAULT_RECIPIENTS"), + "NUMBER": os.Getenv("SENDER"), +} + +var BLOCKED_ENDPOINTS []string = []string{ + "/v1/about", + "/v1/configuration", + "/v1/devices", + "/v1/register", + "/v1/unregister", + "/v1/qrcodelink", + "/v1/accounts", + "/v1/contacts", +} + +func main() { + logLevel := os.Getenv("LOG_LEVEL") + + log.Init(logLevel) + + port := os.Getenv("PORT") + signalUrl := os.Getenv("SIGNAL_API_URL") + + blockedEndpointJSON := os.Getenv("BLOCKED_ENDPOINTS") + + if blockedEndpointJSON != "" { + var blockedEndpoints []string + + err := json.Unmarshal([]byte(blockedEndpointJSON), &blockedEndpoints) + + if err != nil { + log.Error("Could not decode Blocked Endpoints: ", blockedEndpointJSON) + } + + BLOCKED_ENDPOINTS = blockedEndpoints + } + + handler = proxy.Create(signalUrl) + + finalHandler := proxy.TemplatingMiddleware( + proxy.BlockedEndpointMiddleware( + proxy.AuthMiddleware(handler), + + BLOCKED_ENDPOINTS), + VARIABLES) + + http.ListenAndServe("0.0.0.0:" + port, finalHandler) +} \ No newline at end of file diff --git a/utils/logger.go b/utils/logger.go new file mode 100644 index 0000000..b145cad --- /dev/null +++ b/utils/logger.go @@ -0,0 +1,82 @@ +package logger + +import ( + "strings" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +var log *zap.Logger + +func Init(level string) { + logLevel := getLogLevel(level) + + cfg := zap.Config{ + Level: zap.NewAtomicLevelAt(logLevel), + Development: false, + Sampling: nil, + Encoding: "console", + EncoderConfig: zapcore.EncoderConfig{ + TimeKey: "time", + LevelKey: "level", + NameKey: "logger", + CallerKey: "caller", + MessageKey: "msg", + StacktraceKey: "stacktrace", + LineEnding: zapcore.DefaultLineEnding, + EncodeLevel: zapcore.CapitalColorLevelEncoder, + EncodeTime: zapcore.TimeEncoderOfLayout("02.01 15:04"), + EncodeDuration: zapcore.StringDurationEncoder, + EncodeCaller: zapcore.ShortCallerEncoder, + }, + OutputPaths: []string{"stdout"}, + ErrorOutputPaths: []string{"stderr"}, + } + + var err error + + log, err = cfg.Build(zap.AddCaller(), zap.AddCallerSkip(1)) + + if err != nil { + panic(err) + } + + Info("Initialized Logger with Level of", logLevel.String()) +} + +func getLogLevel(level string) zapcore.Level { + switch level { + case "info": + return zapcore.InfoLevel + case "debug": + return zapcore.DebugLevel + case "warn": + return zapcore.WarnLevel + case "error": + return zapcore.ErrorLevel + default: + return zapcore.InfoLevel + } +} + +func Info(msg ...string) { + log.Info(strings.Join(msg, " ")) +} + +func Debug(msg ...string) { + log.Debug(strings.Join(msg, " ")) +} + +func Error(msg ...string) { + log.Error(strings.Join(msg, " ")) +} + +func Warn(msg ...string) { + log.Warn(strings.Join(msg, " ")) +} + + +func Sync() { + _ = log.Sync() +} \ No newline at end of file