Redoing program to center around JSONRPC instead of a more REST-like API

This commit is contained in:
Ben 2025-07-28 08:10:06 -07:00
parent ed4bbeb608
commit be8936a665
Signed by: webmaster
GPG key ID: A5FCBAF34E6E8B50
15 changed files with 209 additions and 170 deletions

View file

@ -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 */

9
auth-sample.json Normal file
View file

@ -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"}}}
]
}

37
auth/auth.go Normal file
View file

@ -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;
}

60
auth/json.go Normal file
View file

@ -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)
}
}

35
auth/readme.md Normal file
View file

@ -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 <bearerToken>` 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`

View file

@ -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;
}

View file

@ -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.

View file

@ -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;
}

View file

@ -1,3 +0,0 @@
WGV99fSwgKhdQSa89HQIGxas /+16028675309/room/roomID/*
WGV99fSwgKhdQSa89HQIGxas /+16028675309/direct/username.69/send
ZQR3T6lqsvnXcgcWhpPOWWdv +16028675309/direct/username.69/send/

28
main.go
View file

@ -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();
}

View file

@ -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.
* [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.

5
subprocess/readme.md Normal file
View file

@ -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.

View file

@ -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) {

View file

@ -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)

17
web/readme.md Normal file
View file

@ -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 <token>` 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"}
```