From be8936a665d9491c6e9ff0598e826f7d3cc87903 Mon Sep 17 00:00:00 2001 From: 50501AZ Webmaster Date: Mon, 28 Jul 2025 08:10:06 -0700 Subject: [PATCH] Redoing program to center around JSONRPC instead of a more REST-like API --- args/args.go | 10 ++--- auth-sample.json | 9 ++++ auth/auth.go | 37 ++++++++++++++++ auth/json.go | 60 ++++++++++++++++++++++++++ auth/readme.md | 35 +++++++++++++++ conf/conf.go | 61 --------------------------- conf/readme.md | 22 ---------- conf/regex.go | 54 ------------------------ config-sample.txt | 3 -- main.go | 28 ++++++++---- readme.md | 15 +++---- subprocess/readme.md | 5 +++ subprocess/{command.go => request.go} | 2 +- {http => web}/listen.go | 21 +++++---- web/readme.md | 17 ++++++++ 15 files changed, 209 insertions(+), 170 deletions(-) create mode 100644 auth-sample.json create mode 100644 auth/auth.go create mode 100644 auth/json.go create mode 100644 auth/readme.md delete mode 100644 conf/conf.go delete mode 100644 conf/readme.md delete mode 100644 conf/regex.go delete mode 100644 config-sample.txt create mode 100644 subprocess/readme.md rename subprocess/{command.go => request.go} (83%) rename {http => web}/listen.go (79%) create mode 100644 web/readme.md diff --git a/args/args.go b/args/args.go index 5403154..4d1bda8 100644 --- a/args/args.go +++ b/args/args.go @@ -22,12 +22,12 @@ func Parse() { /* module-specific variable to avoid re-parsing flags */ var flagsParsed bool = false; -/* what JSON file to read config values from */ -var confLocation = flag.String("conf", "./config.txt", "Config file to read from") +/* what file to read the configuration */ +var authJson = flag.String("auth", "./auth.json", "Authorization file to read from") /* @return set boolean will be true if argument is not nil */ -func GetConfLocation() (location string, set bool) { - if confLocation == nil {return "", false} - return *confLocation, true; +func GetAuthJson() (location string, set bool) { + if authJson == nil {return "", false} + return *authJson, true; } /* TCP port to bind to */ diff --git a/auth-sample.json b/auth-sample.json new file mode 100644 index 0000000..d6d9ff6 --- /dev/null +++ b/auth-sample.json @@ -0,0 +1,9 @@ +{ + "WGV99fSwgKhdQSa89HQIGxas": [ + {"method":"send","params":{"recipient":["+16028675309"]}}, + {"method":"send","params":{"groupID":["67a13c3e-8d29-2539-ce8e-41129c349d6d"]}} + ], + "ZQR3T6lqsvnXcgcWhpPOWWdv": [ + {"method":"receive","params":{"envelope":{"source":"67a13c3e-8d29-2539-ce8e-41129c349d6d"}}} + ] +} \ No newline at end of file diff --git a/auth/auth.go b/auth/auth.go new file mode 100644 index 0000000..2498dc6 --- /dev/null +++ b/auth/auth.go @@ -0,0 +1,37 @@ +package auth + +/* This file contains the AuthAuthConfig object and its methods, which handle reading + from a config file and matching requests to the whitelist. */ + +import ( + "errors" + "os" +) + +/* Stores a map between a string (bearer token) and a list of unmarshaled JSONS */ +var authConfig any; +var authConfigSetup bool = false; + +/* Opens and reads a file at the path */ +func SetupAuthConfig(filePath string) (err error) { + if authConfigSetup {return errors.New("Auth configuration already set up!")} + + // Open and read file contents + fileContents, err := os.ReadFile(filePath); + if err != nil {return} + + // Unmarshal + authConfig = unmarshalJSON(fileContents); + if authConfig == nil {return errors.New("Invalid JSON config!");} + + print(match(authConfig, authConfig), "\n") + + // Finish setup + authConfigSetup = true; + return nil; +} + +/* Gets a reference copy to the config data */ +func GetAuthConfigData() (any, bool) { + return authConfig, authConfigSetup; +} \ No newline at end of file diff --git a/auth/json.go b/auth/json.go new file mode 100644 index 0000000..de2e32b --- /dev/null +++ b/auth/json.go @@ -0,0 +1,60 @@ +package auth + +/* This file contains some JSON helper functions */ + +import ( + "encoding/json" + "reflect" +) + +/* Unmarshals a JSON into a recursive map. Returns nil on error */ +func unmarshalJSON(marshaledJSON []byte) (unmarshaled any) { + json.Unmarshal(marshaledJSON, &unmarshaled); + return; +} + +/* Meat and bones of determining if a request is allowed by a filter */ +func match(request any, filter any) bool { + // Check that the types are the same + if reflect.TypeOf(request) != reflect.TypeOf(filter) {return false} + + // Can safely switch on type of one object at this point since they're equal + switch filter.(type) { + + // Key-value pairs + case map[string]any: + // Check for every key that's in the filter + for key := range filter.(map[string]any) { + // that it's in the request + if _, ok := request.(map[string]any)[key]; !ok {return false} + + // And recursively check that the value is equal + if !match(request.(map[string]any)[key], filter.(map[string]any)[key]) { + return false; + } + } + return true; + + /* Arrays attempt to match every item in the filter to ANY item in the + request. Duplicates in the filter are treated as one */ + case []any: + // Check that for every item in the filter + for i := 0; i < len(filter.([]any)); i ++ { + foundMatch := false; + // That something matches in the request + for j := 0; j < len(request.([]any)); j ++ { + if match(filter.([]any)[i], request.([]any)[j]) { + foundMatch = true; + break + } + } + // Cannot find a match for something in the filter + if !foundMatch {return false} + } + + return true; + + // Otherwise compare the objects directly using reflect + default: return reflect.DeepEqual(request, filter) + } +} \ No newline at end of file diff --git a/auth/readme.md b/auth/readme.md new file mode 100644 index 0000000..b35afc5 --- /dev/null +++ b/auth/readme.md @@ -0,0 +1,35 @@ +# Auth - Signal-CLI HTTP + +This module handles the reading and parsing of the auth JSON file. It also acts as a verifier in relation to that information. The file is a JSON object. It acts as a whitelist for which bearer token can do what action. It is passed to the HTTP endpoint via the `Authorization: Bearer ` header. + +Here's a sample auth JSON: + +```json +{ + "WGV99fSwgKhdQSa89HQIGxas": [ + {"method":"send","params":{"recipient":["+16028675309"]}}, + {"method":"send","params":{"groupID":["67a13c3e-8d29-2539-ce8e-41129c349d6d"]}}, + ], + "ZQR3T6lqsvnXcgcWhpPOWWdv": [ + {"method":"receive","params":{"envelope":{"source":"67a13c3e-8d29-2539-ce8e-41129c349d6d"}}} + ] +} +``` + +When an HTTP request comes in, this software will do the following: + +1. Check that there's an `Authorization` header +2. Get the authorization header's value (bearer token) +3. Read the JSON array corresponding to the bearer token. +4. See if any JSON object in that array (called a filter) does not have any data the request JSON doesn't. +5. If the statement in step 4 is true, forward the request into the signal-cli process and return the response. + +So for example, the reqest `{"method":"send","params":{"recipient":["+16028675309"],"message":"message"},"id":"SomeID"},` would be allowed by the filter `{"method":"send","params":{"recipient":["+16028675309"]}}` because the filter does not have any data the request does not. But `{"method":"send","params":{"recipient":["+5555555555"],"message":"message"},"id":"SomeID"},` would not because the phone number differs. + +These filters can be as granular as you want. + +Here's what each filter JSON object in the above sample JSON does: + +`{"method":"send","params":{"recipient":["+16028675309"]}}` allows sending to `+16028675309` (any message, timestamp, etc.) +`{"method":"send","params":{"groupID":["67a13c3e-8d29-2539-ce8e-41129c349d6d"]}}`: allows sending to group `67a13c3e-8d29-2539-ce8e-41129c349d6d` (any message, timestamp, etc.) +`{"method":"receive","params":{"envelope":{"source":"67a13c3e-8d29-2539-ce8e-41129c349d6d"}}}` allows receiving from group `67a13c3e-8d29-2539-ce8e-41129c349d6d` \ No newline at end of file diff --git a/conf/conf.go b/conf/conf.go deleted file mode 100644 index fb81fb7..0000000 --- a/conf/conf.go +++ /dev/null @@ -1,61 +0,0 @@ -package conf - -/* This file contains the Config object and its methods, which handle reading - from a config file and matching requests to the whitelist. */ - -import ( - "bufio" - "errors" - "os" - "strings" -) - -/* Object to handle what is in a JSON config */ -type Config struct { - configData map[string][]string; -} - -/* Default Config object */ -var GlobalConfig * Config; - -/* Opens and reads a file at the path */ -func NewConfig(filePath string) (newConfig *Config, err error) { - // Open file - file, err := os.Open(filePath) - if err != nil {return} - defer file.Close() - - // Create configuration - newConfigData := make(map[string][]string); - - // Read lines into newConfigData - scanner := bufio.NewScanner(file) - for scanner.Scan() { - parts := strings.SplitN(scanner.Text(), " ", 2); - if len(parts) != 2 {err = errors.New("Bad config file!"); return;} - newConfigData[parts[0]] = append(newConfigData[parts[0]], parts[1]); - } - - // Create Config object and copy a reference to newConfigData into it - return &Config{configData: newConfigData}, nil; -} - -/* Gets a reference copy to the config data */ -func (config * Config) GetConfigData() map[string][]string { - return config.configData; -} - -/* Returns if a bearer key is authorized for the path in this Config object - @return false for any situation that isn't a valid match */ -func (config * Config) ValidateBearerKey(bearerKey string, request string) bool { - paths, exists := config.configData[bearerKey]; - if !exists {return false} - - for _, matchTo := range paths { - if match(request, matchTo) { - return true; - } - } - - return false; -} \ No newline at end of file diff --git a/conf/readme.md b/conf/readme.md deleted file mode 100644 index 5e0863f..0000000 --- a/conf/readme.md +++ /dev/null @@ -1,22 +0,0 @@ -# Conf - Signal-CLI HTTP - -This module handles reading and parsing the config file, and acting as a verifier for the `Authorization` header on the HTTP requests. - -The config file is made up of multiple lines. The first token in each line is the `Authorization` bearer token. This cannot have spaces but can be any string. Choose wisely. The remainder of the line contains a path that the `Authorization` header is checked against. It does not matter if you include a leading or trailing slash. - -Here's a sample config: - -``` -WGV99fSwgKhdQSa89HQIGxas /+16028675309/room/roomID/* -WGV99fSwgKhdQSa89HQIGxas /+16028675309/direct/username.69/send -ZQR3T6lqsvnXcgcWhpPOWWdv +16028675309/direct/username.69/send/ -``` - -The config file is a **whitelist** for each bearer token to access a specific endpoint (or set of endpoints). The endpoints for this program are granular enough to only allow one action for each endpoint, so this level of whitelisting should™ be okay. - -There is a regex-like behavior to these paths using the `*` and `?` characters. For the regex-like behavior to be triggered these characters must be by themselves per path segment (no other characters not separated by a `/` or a start or end of string). - -The `*` character matches to any number of path segments. The `?` character matches to only one segment. Here's some examples: - -* `HZJWwB0TAjz6pjAHosII5ofR /+16028675309/*` will allow the bearer token to access any endpoint with the phone number `+16028675309`. -* `HZJWwB0TAjz6pjAHosII5ofR /+16028675309/direct/?/send` will allow the bearer token to send a direct message to anyone on that phone number. \ No newline at end of file diff --git a/conf/regex.go b/conf/regex.go deleted file mode 100644 index 32524df..0000000 --- a/conf/regex.go +++ /dev/null @@ -1,54 +0,0 @@ -package conf - -/* This file contains regex helper functions for parsing configs */ - -import ( - "strings" -) - -/* Splits and normalises */ -func splitPath(path string) []string { - return strings.Split(strings.Trim(path, "/"), "/") -} - -/* Attempts to match a request path to a set of whitelisted paths - @return false for anything other than a valid match */ -func match(request string, matchTo string) bool { - return matchSegments(splitPath(request), splitPath(matchTo)); -} - -/* Returns false for anything other than a valid match */ -func matchSegments(request []string, matchTo []string) bool { - /* This is a recursive function which, at each recursion level, matches the - path segments that are in the front of the request and matchTo lists - It matches identical strings, anything to &, and splits in two when - matching anything to *, to account for consuming or not consuming the * - at the current recursion level. */ - - // Recursion base case for perfect match - if len(request) == 0 && len(matchTo) == 0 {return true} - // End of path for one but not the other - if (len(request) & len(matchTo)) == 0 {return false} - - // Grab current path segments - requestCurrent := request[0]; - matchToCurrent := matchTo[0]; - - // & character and direct match have the same behavior - if (matchToCurrent == "&") || (requestCurrent == matchToCurrent) { - return matchSegments(request[1:], matchTo[1:]); - } - - // * character - if (matchToCurrent == "*") { - // These are split for performance - // Usually the * only refers to 1 or 2 things so putting consumption - // first is probably a better choice - if (matchSegments(request[1:], matchTo[1:])) {return true} - if (matchSegments(request[1:], matchTo)) {return true} - return false; - } - - // Code will reach here if there's no match for the current segment - return false; -} \ No newline at end of file diff --git a/config-sample.txt b/config-sample.txt deleted file mode 100644 index 589afac..0000000 --- a/config-sample.txt +++ /dev/null @@ -1,3 +0,0 @@ -WGV99fSwgKhdQSa89HQIGxas /+16028675309/room/roomID/* -WGV99fSwgKhdQSa89HQIGxas /+16028675309/direct/username.69/send -ZQR3T6lqsvnXcgcWhpPOWWdv +16028675309/direct/username.69/send/ \ No newline at end of file diff --git a/main.go b/main.go index 006eec7..34cbb19 100644 --- a/main.go +++ b/main.go @@ -4,24 +4,36 @@ package main import ( "signal-cli-http/args" - "signal-cli-http/conf" - "signal-cli-http/http" + "signal-cli-http/auth" + "signal-cli-http/web" "log" + "sync" ) func main() { + var wg sync.WaitGroup; wg.Add(1); + // Read arguments args.Parse(); - configLocation, confLocationSet := args.GetConfLocation(); - if !confLocationSet {log.Default().Print("No config value!"); return} - log.Default().Print("Reading config value from ", configLocation); + configLocation, confLocationSet := args.GetAuthJson(); + if !confLocationSet {log.Default().Print("No auth config value!"); return;} + log.Default().Print("Reading auth config value from ", configLocation); // Set up config - conf.GlobalConfig, _ = conf.NewConfig(configLocation); - if conf.GlobalConfig == nil {log.Default().Print("Error reading config"); return} + err := auth.SetupAuthConfig(configLocation); + if err != nil {log.Default().Print("Error reading config: ", err); return;} + log.Default().Print(auth.GetAuthConfigData()); port, portSet := args.GetHTTPPort(); if !portSet {log.Default().Print("No port value!"); return;} - http.StartWebserver(port) + log.Default().Print("Listening on port ", port); + + go func() { + defer wg.Done(); + web.StartWebserver(port); + }() + + log.Default().Print("Startup tasks complete!"); + wg.Wait(); } \ No newline at end of file diff --git a/readme.md b/readme.md index 36814b9..cb0c61b 100644 --- a/readme.md +++ b/readme.md @@ -2,14 +2,13 @@ **Very** early in development. -Very simple HTTP frontend to [signal-cli](https://github.com/AsamK/signal-cli). +Very simple HTTP frontend to [signal-cli](https://github.com/AsamK/signal-cli) JSON RPC. -Please also read the following README files for the individual modules: +Please see the JSONRPC documentation for `signal-cli`: [https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-jsonrpc.5.adoc](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-jsonrpc.5.adoc) -* [args](args/readme.md) - handles command line arguments. -* [conf](conf/readme.md) - handles the config file. +Please also read the following README files for the individual modules to understand how to configure and interact with this program: -Too be implemented: - -* subprocess - handles running the binaries which communicate with the daemon. -* web - handles the incoming http requests. \ No newline at end of file +* [args](args/readme.md) handles command line arguments. +* [auth](auth/readme.md) handles the authentication JSON and checking requests. +* [subprocess](subprocess/readme.md) manages the underlying `signal-cli` JSONRPC process, along with caching incoming messages. +* [web](web/readme.md) - handles the HTTP requests to this program, including the necessary edge cases. \ No newline at end of file diff --git a/subprocess/readme.md b/subprocess/readme.md new file mode 100644 index 0000000..829dc96 --- /dev/null +++ b/subprocess/readme.md @@ -0,0 +1,5 @@ +# Subprocess - Signal-CLI HTTP + +This module spawns and handles IO for the signal-cli process. + +Do not pass an object with the "id" key into this module's methods. It will reject the request for that reason. \ No newline at end of file diff --git a/subprocess/command.go b/subprocess/request.go similarity index 83% rename from subprocess/command.go rename to subprocess/request.go index 2b53020..bd2a50b 100644 --- a/subprocess/command.go +++ b/subprocess/request.go @@ -1,6 +1,6 @@ package subprocess -/* This file manages creating the command line arguments to the subprocess */ +/* This file manages verifying/sanatizing the request and response and forwarding it to the subprocess */ /* Method which module http calls to create the subprocess */ func Run(path string, body []byte) (status int, bodyContents []byte, err error) { diff --git a/http/listen.go b/web/listen.go similarity index 79% rename from http/listen.go rename to web/listen.go index 1a0934e..04c7434 100644 --- a/http/listen.go +++ b/web/listen.go @@ -1,9 +1,9 @@ -package http +package web /* This file handles listening to HTTP requests */ import ( - "signal-cli-http/conf" + //"signal-cli-http/auth" "signal-cli-http/subprocess" "fmt" @@ -30,14 +30,13 @@ func getRoot(w http.ResponseWriter, r *http.Request) { bearer := authArr[0]; // Check that the request is allowed for the path - if !conf.GlobalConfig.ValidateBearerKey(bearer, r.URL.Path) { + /*if !conf.GlobalConfig.ValidateBearerKey(bearer, r.URL.Path) { w.WriteHeader(403); w.Write([]byte("Bearer key not whitelisted for this path\n")) return; - } - - // OK authentication wise + }*/ + // Read request body body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(500); @@ -56,8 +55,14 @@ func getRoot(w http.ResponseWriter, r *http.Request) { } // Respond to client with status - w.WriteHeader(status); - w.Write(bodyContent); + if status == 0 { + w.WriteHeader(200); + w.Write(bodyContent); + } else { + w.WriteHeader(400); + w.Write([]byte("Program exited with status " + fmt.Sprint(status))); + + } // Log the request log.Default().Print("HTTP Request: ", bearer, " " , r.URL.Path, " ", status) diff --git a/web/readme.md b/web/readme.md new file mode 100644 index 0000000..590a962 --- /dev/null +++ b/web/readme.md @@ -0,0 +1,17 @@ +# Web - Signal-CLI HTTP + +This module handles the HTTP requests. The path used for requests genuinely does not matter. However, every request must have a JSON object body, and an `Authorization: bearer ` header. The response will also always be a JSON. + +Possible response codes: + +* 400 Bad Request: Bad JSON, missing Authorization header, etc. +* 401 Unauthorized: bearer token not allowed for presented request. +* 500 Internal Server Error: Self explanitory. +* 501 Not Implemented: Will be returned for message receiving. +* 200 OK: Request was forwarded to JSONRPC subprocess and that process returned something, including if the "error" key is present in the returned JSON. + +Any non-200 response code will come with a JSON with the following format: + +```json +{"error":"Error message string"} +``` \ No newline at end of file