Redoing program to center around JSONRPC instead of a more REST-like API
This commit is contained in:
parent
ed4bbeb608
commit
be8936a665
15 changed files with 209 additions and 170 deletions
10
args/args.go
10
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 */
|
||||
|
|
9
auth-sample.json
Normal file
9
auth-sample.json
Normal 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
37
auth/auth.go
Normal 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
60
auth/json.go
Normal 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
35
auth/readme.md
Normal 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`
|
61
conf/conf.go
61
conf/conf.go
|
@ -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;
|
||||
}
|
|
@ -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.
|
|
@ -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;
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
WGV99fSwgKhdQSa89HQIGxas /+16028675309/room/roomID/*
|
||||
WGV99fSwgKhdQSa89HQIGxas /+16028675309/direct/username.69/send
|
||||
ZQR3T6lqsvnXcgcWhpPOWWdv +16028675309/direct/username.69/send/
|
28
main.go
28
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();
|
||||
}
|
15
readme.md
15
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.
|
||||
* [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
5
subprocess/readme.md
Normal 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.
|
|
@ -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) {
|
|
@ -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
17
web/readme.md
Normal 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"}
|
||||
```
|
Loading…
Add table
Add a link
Reference in a new issue