diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 3d4695e..115a9e6 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -39,10 +39,11 @@ body: required: true attributes: label: In which mode are you using the docker container? - description: If you have `USE_NATIVE=1` set in your `docker-compose.yml` file then you are using the native mode. + description: Please have a look at the `MODE` parameter in you `docker-compose.yml` file. If you do not have the `MODE` parameter explicitly set, then you are running in normal mode. options: - Normal Mode - Native Mode + - JSON-RPC Mode - type: dropdown validations: required: true diff --git a/Dockerfile b/Dockerfile index 274e6b7..93db634 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,12 @@ -ARG SIGNAL_CLI_VERSION=0.8.5 +ARG SIGNAL_CLI_VERSION=0.9.0 ARG ZKGROUP_VERSION=0.7.0 -ARG LIBSIGNAL_CLIENT_VERSION=0.8.1 +ARG LIBSIGNAL_CLIENT_VERSION=0.9.0 ARG SWAG_VERSION=1.6.7 ARG GRAALVM_JAVA_VERSION=11 -ARG GRAALVM_VERSION=21.0.0 +ARG GRAALVM_VERSION=21.2.0 -FROM golang:1.14-buster AS buildcontainer +FROM golang:1.17-bullseye AS buildcontainer ARG SIGNAL_CLI_VERSION ARG ZKGROUP_VERSION @@ -125,16 +125,22 @@ RUN cd /tmp/signal-cli-${SIGNAL_CLI_VERSION}/build/distributions/ \ COPY src/api /tmp/signal-cli-rest-api-src/api +COPY src/client /tmp/signal-cli-rest-api-src/client COPY src/utils /tmp/signal-cli-rest-api-src/utils +COPY src/scripts /tmp/signal-cli-rest-api-src/scripts COPY src/main.go /tmp/signal-cli-rest-api-src/ COPY src/go.mod /tmp/signal-cli-rest-api-src/ COPY src/go.sum /tmp/signal-cli-rest-api-src/ +# build signal-cli-rest-api RUN cd /tmp/signal-cli-rest-api-src && swag init && go build +# build supervisorctl_config_creator +RUN cd /tmp/signal-cli-rest-api-src/scripts && go build -o jsonrpc2-helper + # Start a fresh container for release container -FROM adoptopenjdk:11-jre-hotspot-bionic +FROM eclipse-temurin:11-jre-focal ENV GIN_MODE=release @@ -143,12 +149,13 @@ ENV PORT=8080 ARG SIGNAL_CLI_VERSION RUN apt-get update \ - && apt-get install -y --no-install-recommends setpriv \ + && apt-get install -y --no-install-recommends util-linux supervisor netcat \ && rm -rf /var/lib/apt/lists/* COPY --from=buildcontainer /tmp/signal-cli-rest-api-src/signal-cli-rest-api /usr/bin/signal-cli-rest-api COPY --from=buildcontainer /tmp/signal-cli-${SIGNAL_CLI_VERSION}/build/distributions/signal-cli-${SIGNAL_CLI_VERSION}.tar /tmp/signal-cli-${SIGNAL_CLI_VERSION}.tar COPY --from=buildcontainer /tmp/signal-cli-${SIGNAL_CLI_VERSION}/build/native-image/signal-cli /tmp/signal-cli-native +COPY --from=buildcontainer /tmp/signal-cli-rest-api-src/scripts/jsonrpc2-helper /usr/bin/jsonrpc2-helper COPY entrypoint.sh /entrypoint.sh RUN tar xf /tmp/signal-cli-${SIGNAL_CLI_VERSION}.tar -C /opt diff --git a/README.md b/README.md index 50504b2..2e1d6a2 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,41 @@ At the moment, the following functionality is exposed via REST: and [many more](https://bbernhard.github.io/signal-cli-rest-api/) -## Examples +## Modes + +The `signal-cli-rest-api` supports three different modes: + +* Normal Mode: In normal mode, the `signal-cli` executable is invoked for every REST API request. As `signal-cli` is a Java application, a significant amount of time is spent in the JVM (Java Virtual Machine) startup - which makes this mode pretty slow. +* Native Mode: Instead of calling a Java executable for every REST API request, a native image (compiled with GraalVM) is called. This mode therefore usually performs better than the normal mode. +* JSON-RPC Mode: In JSON-RPC mode, a single `signal-cli` instance is spawned in daemon mode. The communication happens via JSON-RPC. This mode is usually the fastest. + + +| architecture | normal mode | native mode | json-rpc mode | +|--------------|:-----------:|:-----------:|---------------| +| x86-64 | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | +| armv7 | :heavy_check_mark: | ❌ 1 | :heavy_check_mark: | +| arm64 | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | + + +| mode | speed | +|-------------:|:------------| +| json-rpc | :heavy_check_mark: :heavy_check_mark: :heavy_check_mark: | +| native | :heavy_check_mark: :heavy_check_mark: | +| normal | :heavy_check_mark: | + + +Notes: +1. If the signal-cli-rest-api docker container is started on an armv7 system in native mode, it automatically falls back to the normal mode. + +## Auto Receive Schedule + +> :warning: This setting is only needed in normal/native mode! + +[signal-cli](https://github.com/AsamK/signal-cli), which this REST API wrapper is based on, recommends to call `receive` on a regular basis. So, if you are not already calling the `receive` endpoint regularily, it is recommended to set the `AUTO_RECEIVE_SCHEDULE` parameter in the docker-compose.yml file. The `AUTO_RECEIVE_SCHEDULE` accepts cron schedule expressions and automatically calls the `receive` endpoint at the given time. e.g: `0 22 * * *` calls `receive` daily at 10pm. If you are not familiar with cron schedule expressions, you can use this [website](https://crontab.guru). + +**WARNING** Calling `receive` will fetch all the messages for the registered Signal number from the Signal Server! So, if you are using the REST API for receiving messages, it's _not_ a good idea to use the `AUTO_RECEIVE_SCHEDULE` parameter, as you might lose some messages that way. + +## Example Sample `docker-compose.yml`file: @@ -25,7 +59,7 @@ services: signal-cli-rest-api: image: bbernhard/signal-cli-rest-api:latest environment: - - USE_NATIVE=0 + - MODE=normal #supported modes: json-rpc, native, normal #- AUTO_RECEIVE_SCHEDULE=0 22 * * * #enable this parameter on demand (see description below) ports: - "8080:8080" #map docker port 8080 to host port 8080. @@ -34,21 +68,6 @@ services: ``` -## Auto Receive Schedule - -[signal-cli](https://github.com/AsamK/signal-cli), which this REST API wrapper is based on, recommends to call `receive` on a regular basis. So, if you are not already calling the `receive` endpoint regularily, it is recommended to set the `AUTO_RECEIVE_SCHEDULE` parameter in the docker-compose.yml file. The `AUTO_RECEIVE_SCHEDULE` accepts cron schedule expressions and automatically calls the `receive` endpoint at the given time. e.g: `0 22 * * *` calls `receive` daily at 10pm. If you are not familiar with cron schedule expressions, you can use this [website](https://crontab.guru). - -**WARNING** Calling `receive` will fetch all the messages for the registered Signal number from the Signal Server! So, if you are using the REST API for receiving messages, it's _not_ a good idea to use the `AUTO_RECEIVE_SCHEDULE` parameter, as you might lose some messages that way. - - -## Native Image (EXPERIMENTAL) - -On Systems like the Raspberry Pi, some operations like sending messages can take quite a while. That's because signal-cli is a Java application and a significant amount of time is spent in the JVM (Java Virtual Machine) startup. signal-cli recently added the possibility to compile the Java application to a native binary (done via GraalVM). - -By adding `USE_NATIVE=1` as environmental variable to the `docker-compose.yml` file the native mode will be enabled. - -In case there's no native binary available (e.g on a 32 bit Raspian OS), it will fall back to the signal-cli Java application. The native mode only works on a 64bit system, when the native mode is enabled on a 32bit system, it falls back to the Java application. - ## Documentation ### API Reference diff --git a/docker-compose.yml b/docker-compose.yml index ae752bb..194f112 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,9 +3,8 @@ services: signal-cli-rest-api: image: bbernhard/signal-cli-rest-api:latest environment: - - USE_NATIVE=0 - #- AUTO_RECEIVE_SCHEDULE=0 22 * * * - - PORT=8080 + - MODE=normal #supported modes: json-rpc, native, normal + #- AUTO_RECEIVE_SCHEDULE=0 22 * * * #enable this parameter on demand (see description below) ports: - "8080:8080" #map docker port 8080 to host port 8080. volumes: diff --git a/entrypoint.sh b/entrypoint.sh index 41b4b68..3037044 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -18,5 +18,13 @@ EOF cap_prefix="-cap_" caps="$cap_prefix$(seq -s ",$cap_prefix" 0 $(cat /proc/sys/kernel/cap_last_cap))" +# TODO: check mode +if [ "$MODE" = "json-rpc" ] +then +/usr/bin/jsonrpc2-helper +service supervisor start +supervisorctl start all +fi + # Start API as signal-api user exec setpriv --reuid=1000 --regid=1000 --init-groups --inh-caps=$caps signal-cli-rest-api -signal-cli-config=${SIGNAL_CLI_CONFIG_DIR} diff --git a/ext/libraries/libsignal-client/v0.8.4/arm64/libsignal_jni.so b/ext/libraries/libsignal-client/v0.8.4/arm64/libsignal_jni.so new file mode 100644 index 0000000..03f66ba Binary files /dev/null and b/ext/libraries/libsignal-client/v0.8.4/arm64/libsignal_jni.so differ diff --git a/ext/libraries/libsignal-client/v0.8.4/armv7/libsignal_jni.so b/ext/libraries/libsignal-client/v0.8.4/armv7/libsignal_jni.so new file mode 100644 index 0000000..3386b3e Binary files /dev/null and b/ext/libraries/libsignal-client/v0.8.4/armv7/libsignal_jni.so differ diff --git a/ext/libraries/libsignal-client/v0.8.4/x86-64/libsignal_jni.so b/ext/libraries/libsignal-client/v0.8.4/x86-64/libsignal_jni.so new file mode 100644 index 0000000..de3b033 Binary files /dev/null and b/ext/libraries/libsignal-client/v0.8.4/x86-64/libsignal_jni.so differ diff --git a/ext/libraries/libsignal-client/v0.9.0/arm64/libsignal_jni.so b/ext/libraries/libsignal-client/v0.9.0/arm64/libsignal_jni.so new file mode 100644 index 0000000..50ad8f9 Binary files /dev/null and b/ext/libraries/libsignal-client/v0.9.0/arm64/libsignal_jni.so differ diff --git a/ext/libraries/libsignal-client/v0.9.0/armv7/libsignal_jni.so b/ext/libraries/libsignal-client/v0.9.0/armv7/libsignal_jni.so new file mode 100644 index 0000000..9eed8cc Binary files /dev/null and b/ext/libraries/libsignal-client/v0.9.0/armv7/libsignal_jni.so differ diff --git a/ext/libraries/libsignal-client/v0.9.0/x86-64/libsignal_jni.so b/ext/libraries/libsignal-client/v0.9.0/x86-64/libsignal_jni.so new file mode 100644 index 0000000..e99af29 Binary files /dev/null and b/ext/libraries/libsignal-client/v0.9.0/x86-64/libsignal_jni.so differ diff --git a/src/api/api.go b/src/api/api.go index d88587e..e7ef1ee 100644 --- a/src/api/api.go +++ b/src/api/api.go @@ -1,44 +1,31 @@ package api import ( - "bufio" "bytes" - "encoding/base64" "encoding/json" - "errors" - "io/ioutil" "net/http" - "os" - "os/exec" - "path/filepath" "strconv" - "strings" "time" - "github.com/cyphar/filepath-securejoin" "github.com/gabriel-vasile/mimetype" "github.com/gin-gonic/gin" - uuid "github.com/gofrs/uuid" - "github.com/h2non/filetype" log "github.com/sirupsen/logrus" - qrcode "github.com/skip2/go-qrcode" + "github.com/gorilla/websocket" + + "github.com/bbernhard/signal-cli-rest-api/client" utils "github.com/bbernhard/signal-cli-rest-api/utils" ) -const signalCliV2GroupError = "Cannot create a V2 group as self does not have a versioned profile" +const ( + // Time allowed to write the file to the client. + writeWait = 10 * time.Second -const groupPrefix = "group." + // Time allowed to read the next pong message from the client. + pongWait = 60 * time.Second -type GroupEntry struct { - Name string `json:"name"` - Id string `json:"id"` - InternalId string `json:"internal_id"` - Members []string `json:"members"` - Blocked bool `json:"blocked"` - PendingInvites []string `json:"pending_invites"` - PendingRequests []string `json:"pending_requests"` - InviteLink string `json:"invite_link"` -} + // Send pings to client with this period. Must be less than pongWait. + pingPeriod = (pongWait * 9) / 10 +) type GroupPermissions struct { AddMembers string `json:"add_members" enums:"only-admins,every-member"` @@ -61,25 +48,6 @@ type Configuration struct { Logging LoggingConfiguration `json:"logging"` } -type SignalCliGroupEntry struct { - Name string `json:"name"` - Id string `json:"id"` - IsMember bool `json:"isMember"` - IsBlocked bool `json:"isBlocked"` - Members []string `json:"members"` - PendingMembers []string `json:"pendingMembers"` - RequestingMembers []string `json:"requestingMembers"` - GroupInviteLink string `json:"groupInviteLink"` -} - -type IdentityEntry struct { - Number string `json:"number"` - Status string `json:"status"` - Fingerprint string `json:"fingerprint"` - Added string `json:"added"` - SafetyNumber string `json:"safety_number"` -} - type RegisterNumberRequest struct { UseVoice bool `json:"use_voice"` Captcha string `json:"captcha"` @@ -108,10 +76,7 @@ type Error struct { Msg string `json:"error"` } -type About struct { - SupportedApiVersions []string `json:"versions"` - BuildNr int `json:"build"` -} + type CreateGroupResponse struct { Id string `json:"id"` @@ -130,293 +95,19 @@ type SendMessageResponse struct { Timestamp string `json:"timestamp"` } -func convertInternalGroupIdToGroupId(internalId string) string { - return groupPrefix + base64.StdEncoding.EncodeToString([]byte(internalId)) -} - -func convertGroupIdToInternalGroupId(id string) (string, error) { - groupIdWithoutPrefix := strings.TrimPrefix(id, groupPrefix) - internalGroupId, err := base64.StdEncoding.DecodeString(groupIdWithoutPrefix) - if err != nil { - return "", errors.New("Invalid group id") - } - - return string(internalGroupId), err -} - -func getStringInBetween(str string, start string, end string) (result string) { - i := strings.Index(str, start) - if i == -1 { - return - } - i += len(start) - j := strings.Index(str[i:], end) - if j == -1 { - return - } - return str[i : i+j] -} - -func cleanupTmpFiles(paths []string) { - for _, path := range paths { - os.Remove(path) - } -} - -func getContainerId() (string, error) { - data, err := ioutil.ReadFile("/proc/1/cpuset") - if err != nil { - return "", err - } - lines := strings.Split(string(data), "\n") - if len(lines) == 0 { - return "", errors.New("Couldn't get docker container id (empty)") - } - containerId := strings.Replace(lines[0], "/docker/", "", -1) - return containerId, nil -} - -func send(c *gin.Context, attachmentTmpDir string, signalCliConfig string, number string, message string, - recipients []string, base64Attachments []string, isGroup bool) { - cmd := []string{"--config", signalCliConfig, "-u", number, "send"} - - if len(recipients) == 0 { - c.JSON(400, gin.H{"error": "Please specify at least one recipient"}) - return - } - - if !isGroup { - cmd = append(cmd, recipients...) - } else { - if len(recipients) > 1 { - c.JSON(400, gin.H{"error": "More than one recipient is currently not allowed"}) - return - } - - groupId, err := base64.StdEncoding.DecodeString(recipients[0]) - if err != nil { - c.JSON(400, gin.H{"error": "Invalid group id"}) - return - } - - cmd = append(cmd, []string{"-g", string(groupId)}...) - } - - attachmentTmpPaths := []string{} - for _, base64Attachment := range base64Attachments { - u, err := uuid.NewV4() - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - dec, err := base64.StdEncoding.DecodeString(base64Attachment) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - mimeType := mimetype.Detect(dec) - - attachmentTmpPath := attachmentTmpDir + u.String() + mimeType.Extension() - attachmentTmpPaths = append(attachmentTmpPaths, attachmentTmpPath) - - f, err := os.Create(attachmentTmpPath) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - defer f.Close() - - if _, err := f.Write(dec); err != nil { - cleanupTmpFiles(attachmentTmpPaths) - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if err := f.Sync(); err != nil { - cleanupTmpFiles(attachmentTmpPaths) - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - f.Close() - } - - if len(attachmentTmpPaths) > 0 { - cmd = append(cmd, "-a") - cmd = append(cmd, attachmentTmpPaths...) - } - - resp, err := runSignalCli(true, cmd, message) - if err != nil { - cleanupTmpFiles(attachmentTmpPaths) - if strings.Contains(err.Error(), signalCliV2GroupError) { - c.JSON(400, Error{Msg: "Cannot send message to group - please first update your profile."}) - } else { - c.JSON(400, Error{Msg: err.Error()}) - } - return - } - - sendMessageResponse := SendMessageResponse{Timestamp: strings.TrimSuffix(resp, "\n")} - - cleanupTmpFiles(attachmentTmpPaths) - c.JSON(201, sendMessageResponse) -} - -func parseWhitespaceDelimitedKeyValueStringList(in string, keys []string) []map[string]string { - l := []map[string]string{} - lines := strings.Split(in, "\n") - for _, line := range lines { - if line == "" { - continue - } - - m := make(map[string]string) - - temp := line - for i, key := range keys { - if i == 0 { - continue - } - - idx := strings.Index(temp, " "+key+": ") - pair := temp[:idx] - value := strings.TrimPrefix(pair, key+": ") - temp = strings.TrimLeft(temp[idx:], " "+key+": ") - - m[keys[i-1]] = value - } - m[keys[len(keys)-1]] = temp - - l = append(l, m) - } - return l -} - -func getGroups(number string, signalCliConfig string) ([]GroupEntry, error) { - groupEntries := []GroupEntry{} - - out, err := runSignalCli(true, []string{"--config", signalCliConfig, "--output", "json", "-u", number, "listGroups", "-d"}, "") - if err != nil { - return groupEntries, err - } - - var signalCliGroupEntries []SignalCliGroupEntry - - err = json.Unmarshal([]byte(out), &signalCliGroupEntries) - if err != nil { - return groupEntries, err - } - - for _, signalCliGroupEntry := range signalCliGroupEntries { - var groupEntry GroupEntry - groupEntry.InternalId = signalCliGroupEntry.Id - groupEntry.Name = signalCliGroupEntry.Name - groupEntry.Id = convertInternalGroupIdToGroupId(signalCliGroupEntry.Id) - groupEntry.Blocked = signalCliGroupEntry.IsBlocked - groupEntry.Members = signalCliGroupEntry.Members - groupEntry.PendingRequests = signalCliGroupEntry.PendingMembers - groupEntry.PendingInvites = signalCliGroupEntry.RequestingMembers - groupEntry.InviteLink = signalCliGroupEntry.GroupInviteLink - - groupEntries = append(groupEntries, groupEntry) - } - - return groupEntries, nil -} - -func runSignalCli(wait bool, args []string, stdin string) (string, error) { - containerId, err := getContainerId() - - log.Debug("If you want to run this command manually, run the following steps on your host system:") - if err == nil { - log.Debug("*) docker exec -it ", containerId, " /bin/bash") - } else { - log.Debug("*) docker exec -it /bin/bash") - } - - signalCliBinary := "signal-cli" - if utils.GetEnv("USE_NATIVE", "0") == "1" { - if utils.GetEnv("SUPPORTS_NATIVE", "0") == "1" { - signalCliBinary = "signal-cli-native" - } else { - log.Error("signal-cli-native is not support on this system...falling back to signal-cli") - signalCliBinary = "signal-cli" - } - } - - fullCmd := "" - if(stdin != "") { - fullCmd += "echo '" + stdin + "' | " - } - fullCmd += signalCliBinary + " " + strings.Join(args, " ") - - log.Debug("*) su signal-api") - log.Debug("*) ", fullCmd) - - cmdTimeout, err := utils.GetIntEnv("SIGNAL_CLI_CMD_TIMEOUT", 120) - if err != nil { - log.Error("Env variable 'SIGNAL_CLI_CMD_TIMEOUT' contains an invalid timeout...falling back to default timeout (120 seconds)") - cmdTimeout = 120 - } - - cmd := exec.Command(signalCliBinary, args...) - if stdin != "" { - cmd.Stdin = strings.NewReader(stdin) - } - if wait { - var errBuffer bytes.Buffer - var outBuffer bytes.Buffer - cmd.Stderr = &errBuffer - cmd.Stdout = &outBuffer - - err := cmd.Start() - if err != nil { - return "", err - } - - done := make(chan error, 1) - go func() { - done <- cmd.Wait() - }() - select { - case <-time.After(time.Duration(cmdTimeout) * time.Second): - err := cmd.Process.Kill() - if err != nil { - return "", err - } - return "", errors.New("process killed as timeout reached") - case err := <-done: - if err != nil { - return "", errors.New(errBuffer.String()) - } - } - - return outBuffer.String(), nil - } else { - stdout, err := cmd.StdoutPipe() - if err != nil { - return "", err - } - cmd.Start() - buf := bufio.NewReader(stdout) // Notice that this is not in a loop - line, _, _ := buf.ReadLine() - return string(line), nil - } +var connectionUpgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, } type Api struct { - signalCliConfig string - attachmentTmpDir string - avatarTmpDir string + signalClient *client.SignalClient } -func NewApi(signalCliConfig string, attachmentTmpDir string, avatarTmpDir string) *Api { +func NewApi(signalClient *client.SignalClient) *Api { return &Api{ - signalCliConfig: signalCliConfig, - attachmentTmpDir: attachmentTmpDir, - avatarTmpDir: avatarTmpDir, + signalClient: signalClient, } } @@ -424,12 +115,10 @@ func NewApi(signalCliConfig string, attachmentTmpDir string, avatarTmpDir string // @Tags General // @Description Returns the supported API versions and the internal build nr // @Produce json -// @Success 200 {object} About +// @Success 200 {object} client.About // @Router /v1/about [get] func (a *Api) About(c *gin.Context) { - - about := About{SupportedApiVersions: []string{"v1", "v2"}, BuildNr: 2} - c.JSON(200, about) + c.JSON(200, a.signalClient.About()) } // @Summary Register a phone number. @@ -466,17 +155,7 @@ func (a *Api) RegisterNumber(c *gin.Context) { return } - command := []string{"--config", a.signalCliConfig, "-u", number, "register"} - - if req.UseVoice == true { - command = append(command, "--voice") - } - - if req.Captcha != "" { - command = append(command, []string{"--captcha", req.Captcha}...) - } - - _, err := runSignalCli(true, command, "") + err := a.signalClient.RegisterNumber(number, req.UseVoice, req.Captcha) if err != nil { c.JSON(400, gin.H{"error": err.Error()}) return @@ -523,13 +202,7 @@ func (a *Api) VerifyRegisteredNumber(c *gin.Context) { return } - cmd := []string{"--config", a.signalCliConfig, "-u", number, "verify", token} - if pin != "" { - cmd = append(cmd, "--pin") - cmd = append(cmd, pin) - } - - _, err := runSignalCli(true, cmd, "") + err := a.signalClient.VerifyRegisteredNumber(number, token, pin) if err != nil { c.JSON(400, gin.H{"error": err.Error()}) return @@ -552,7 +225,7 @@ func (a *Api) Send(c *gin.Context) { var req SendMessageV1 err := c.BindJSON(&req) if err != nil { - c.JSON(400, gin.H{"error": "Couldn't process request - invalid request"}) + c.JSON(400, Error{Msg: "Couldn't process request - invalid request"}) return } @@ -561,7 +234,12 @@ func (a *Api) Send(c *gin.Context) { base64Attachments = append(base64Attachments, req.Base64Attachment) } - send(c, a.signalCliConfig, a.signalCliConfig, req.Number, req.Message, req.Recipients, base64Attachments, req.IsGroup) + timestamp, err := a.signalClient.SendV1(req.Number, req.Message, req.Recipients, base64Attachments, req.IsGroup) + if err != nil { + c.JSON(400, Error{Msg: err.Error()}) + return + } + c.JSON(201, SendMessageResponse{Timestamp: strconv.FormatInt(timestamp.Timestamp, 10)}) } // @Summary Send a signal message. @@ -587,39 +265,72 @@ func (a *Api) SendV2(c *gin.Context) { return } - groups := []string{} - recipients := []string{} + if req.Number == "" { + c.JSON(400, gin.H{"error": "Couldn't process request - please provide a valid number"}) + return + } - for _, recipient := range req.Recipients { - if strings.HasPrefix(recipient, groupPrefix) { - groups = append(groups, strings.TrimPrefix(recipient, groupPrefix)) + timestamps, err := a.signalClient.SendV2(req.Number, req.Message, req.Recipients, req.Base64Attachments) + if err != nil { + c.JSON(400, Error{Msg: err.Error()}) + return + } + + c.JSON(201, SendMessageResponse{Timestamp: strconv.FormatInt((*timestamps)[0].Timestamp, 10)}) +} + + +func (a *Api) handleSignalReceive(ws *websocket.Conn, number string) { + for { + data, err := a.signalClient.Receive(number, 0) + if err == nil { + err = ws.WriteMessage(websocket.TextMessage, []byte(data)) + if err != nil { + log.Error("Couldn't write message: " + err.Error()) + return + } } else { - recipients = append(recipients, recipient) + errorMsg := Error{Msg: err.Error()} + errorMsgBytes, err := json.Marshal(errorMsg) + if err != nil { + log.Error("Couldn't serialize error message: " + err.Error()) + return + } + err = ws.WriteMessage(websocket.TextMessage, errorMsgBytes) + if err != nil { + log.Error("Couldn't write message: " + err.Error()) + return + } } } +} - if len(recipients) > 0 && len(groups) > 0 { - c.JSON(400, gin.H{"error": "Signal Messenger Groups and phone numbers cannot be specified together in one request! Please split them up into multiple REST API calls."}) - return +func wsPong(ws *websocket.Conn) { + ws.SetReadLimit(512) + ws.SetPongHandler(func(string) error { log.Debug("Received pong"); return nil }) + for { + _, _, err := ws.ReadMessage() + if err != nil { + break + } } +} - if len(groups) > 1 { - c.JSON(400, gin.H{"error": "A signal message cannot be sent to more than one group at once! Please use multiple REST API calls for that."}) - return - } - - for _, group := range groups { - send(c, a.attachmentTmpDir, a.signalCliConfig, req.Number, req.Message, []string{group}, req.Base64Attachments, true) - } - - if len(recipients) > 0 { - send(c, a.attachmentTmpDir, a.signalCliConfig, req.Number, req.Message, recipients, req.Base64Attachments, false) +func wsPing(ws *websocket.Conn) { + pingTicker := time.NewTicker(pingPeriod) + for { + select { + case <-pingTicker.C: + if err := ws.WriteMessage(websocket.PingMessage, []byte{}); err != nil { + return + } + } } } // @Summary Receive Signal Messages. // @Tags Messages -// @Description Receives Signal Messages from the Signal Network. +// @Description Receives Signal Messages from the Signal Network. If you are running the docker container in normal/native mode, this is a GET endpoint. In json-rpc mode this is a websocket endpoint. // @Accept json // @Produce json // @Success 200 {object} []string @@ -630,28 +341,32 @@ func (a *Api) SendV2(c *gin.Context) { func (a *Api) Receive(c *gin.Context) { number := c.Param("number") - timeout := c.DefaultQuery("timeout", "1") - - command := []string{"--config", a.signalCliConfig, "--output", "json", "-u", number, "receive", "-t", timeout} - out, err := runSignalCli(true, command, "") - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - out = strings.Trim(out, "\n") - lines := strings.Split(out, "\n") - - jsonStr := "[" - for i, line := range lines { - jsonStr += line - if i != (len(lines) - 1) { - jsonStr += "," + if a.signalClient.GetSignalCliMode() == client.JsonRpc { + ws, err := connectionUpgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + c.JSON(400, Error{Msg: err.Error()}) + return + } + defer ws.Close() + go a.handleSignalReceive(ws, number) + go wsPing(ws) + wsPong(ws) + } else { + timeout := c.DefaultQuery("timeout", "1") + timeoutInt, err := strconv.ParseInt(timeout, 10, 32) + if err != nil { + c.JSON(400, Error{Msg: "Couldn't process request - timeout needs to be numeric!"}) + return } - } - jsonStr += "]" - c.String(200, jsonStr) + jsonStr, err := a.signalClient.Receive(number, timeoutInt) + if err != nil { + c.JSON(400, Error{Msg: err.Error()}) + return + } + + c.String(200, jsonStr) + } } // @Summary Create a new Signal Group. @@ -670,57 +385,36 @@ func (a *Api) CreateGroup(c *gin.Context) { var req CreateGroupRequest err := c.BindJSON(&req) if err != nil { - c.JSON(400, gin.H{"error": "Couldn't process request - invalid request"}) - log.Error(err.Error()) + c.JSON(400, Error{Msg: "Couldn't process request - invalid request"}) return } if req.Permissions.AddMembers != "" && !utils.StringInSlice(req.Permissions.AddMembers, []string{"every-member", "only-admins"}) { - c.JSON(400, gin.H{"error": "Invalid add members permission provided - only 'every-member' and 'only-admins' allowed!"}) + c.JSON(400, Error{Msg: "Invalid add members permission provided - only 'every-member' and 'only-admins' allowed!"}) return } if req.Permissions.EditGroup != "" && !utils.StringInSlice(req.Permissions.EditGroup, []string{"every-member", "only-admins"}) { - c.JSON(400, gin.H{"error": "Invalid edit group permissions provided - only 'every-member' and 'only-admins' allowed!"}) + c.JSON(400, Error{Msg: "Invalid edit group permissions provided - only 'every-member' and 'only-admins' allowed!"}) return } if req.GroupLinkState != "" && !utils.StringInSlice(req.GroupLinkState, []string{"enabled", "enabled-with-approval", "disabled"}) { - c.JSON(400, gin.H{"error": "Invalid group link provided - only 'enabled', 'enabled-with-approval' and 'disabled' allowed!" }) + c.JSON(400, Error{Msg: "Invalid group link provided - only 'enabled', 'enabled-with-approval' and 'disabled' allowed!" }) return } - cmd := []string{"--config", a.signalCliConfig, "-u", number, "updateGroup", "-n", req.Name, "-m"} - cmd = append(cmd, req.Members...) + editGroupPermission := client.DefaultGroupPermission + addMembersPermission := client.DefaultGroupPermission + groupLinkState := client.DefaultGroupLinkState - if req.Permissions.AddMembers != "" { - cmd = append(cmd, []string{"--set-permission-add-member", req.Permissions.AddMembers}...) - } - - if req.Permissions.EditGroup != "" { - cmd = append(cmd, []string{"--set-permission-edit-details", req.Permissions.EditGroup}...) - } - - if req.GroupLinkState != "" { - cmd = append(cmd, []string{"--link", req.GroupLinkState}...) - } - - if req.Description != "" { - cmd = append(cmd, []string{"--description", req.Description}...) - } - - out, err := runSignalCli(true, cmd, "") + groupId, err := a.signalClient.CreateGroup(number, req.Name, req.Members, req.Description, editGroupPermission, addMembersPermission, groupLinkState) if err != nil { - if strings.Contains(err.Error(), signalCliV2GroupError) { - c.JSON(400, Error{Msg: "Cannot create group - please first update your profile."}) - } else { - c.JSON(400, Error{Msg: err.Error()}) - } + c.JSON(400, Error{Msg: err.Error()}) return } - internalGroupId := getStringInBetween(out, `"`, `"`) - c.JSON(201, CreateGroupResponse{Id: convertInternalGroupIdToGroupId(internalGroupId)}) + c.JSON(201, CreateGroupResponse{Id: groupId}) } // @Summary List all Signal Groups. @@ -728,16 +422,16 @@ func (a *Api) CreateGroup(c *gin.Context) { // @Description List all Signal Groups. // @Accept json // @Produce json -// @Success 200 {object} []GroupEntry +// @Success 200 {object} []client.GroupEntry // @Failure 400 {object} Error // @Param number path string true "Registered Phone Number" // @Router /v1/groups/{number} [get] func (a *Api) GetGroups(c *gin.Context) { number := c.Param("number") - groups, err := getGroups(number, a.signalCliConfig) + groups, err := a.signalClient.GetGroups(number) if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -749,7 +443,7 @@ func (a *Api) GetGroups(c *gin.Context) { // @Description List a specific Signal Group. // @Accept json // @Produce json -// @Success 200 {object} GroupEntry +// @Success 200 {object} client.GroupEntry // @Failure 400 {object} Error // @Param number path string true "Registered Phone Number" // @Param groupid path string true "Group ID" @@ -758,20 +452,17 @@ func (a *Api) GetGroup(c *gin.Context) { number := c.Param("number") groupId := c.Param("groupid") - groups, err := getGroups(number, a.signalCliConfig) + groupEntry, err := a.signalClient.GetGroup(number, groupId) if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(400, Error{Msg: err.Error()}) return } - for _, group := range groups { - if group.Id == groupId { - c.JSON(200, group) - return - } + if groupEntry != nil { + c.JSON(200, groupEntry) + } else { + c.JSON(404, Error{Msg: "No group with that id found"}) } - - c.JSON(404, Error{Msg: "No group with that id found"}) } // @Summary Delete a Signal Group. @@ -789,19 +480,19 @@ func (a *Api) DeleteGroup(c *gin.Context) { number := c.Param("number") if base64EncodedGroupId == "" { - c.JSON(400, gin.H{"error": "Please specify a group id"}) + c.JSON(400, Error{Msg: "Please specify a group id"}) return } - groupId, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(base64EncodedGroupId, groupPrefix)) + groupId, err := client.ConvertGroupIdToInternalGroupId(base64EncodedGroupId) if err != nil { - c.JSON(400, gin.H{"error": "Invalid group id"}) + c.JSON(400, Error{Msg: err.Error()}) return } - _, err = runSignalCli(true, []string{"--config", a.signalCliConfig, "-u", number, "quitGroup", "-g", string(groupId)}, "") + err = a.signalClient.DeleteGroup(number, groupId) if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(400, Error{Msg: err.Error()}) return } } @@ -818,32 +509,13 @@ func (a *Api) GetQrCodeLink(c *gin.Context) { deviceName := c.Query("device_name") if deviceName == "" { - c.JSON(400, gin.H{"error": "Please provide a name for the device"}) + c.JSON(400, Error{Msg: "Please provide a name for the device"}) return } - command := []string{"--config", a.signalCliConfig, "link", "-n", deviceName} - - tsdeviceLink, err := runSignalCli(false, command, "") + png, err := a.signalClient.GetQrCodeLink(deviceName) if err != nil { - log.Error("Couldn't create QR code: ", err.Error()) - c.JSON(400, Error{Msg: "Couldn't create QR code: " + err.Error()}) - return - } - - q, err := qrcode.New(string(tsdeviceLink), qrcode.Medium) - if err != nil { - log.Error("Couldn't create QR code: ", err.Error()) - c.JSON(400, Error{Msg: "Couldn't create QR code: " + err.Error()}) - return - } - - q.DisableBorder = false - var png []byte - png, err = q.PNG(256) - if err != nil { - log.Error("Couldn't create QR code: ", err.Error()) - c.JSON(400, Error{Msg: "Couldn't create QR code: " + err.Error()}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -858,15 +530,7 @@ func (a *Api) GetQrCodeLink(c *gin.Context) { // @Failure 400 {object} Error // @Router /v1/attachments [get] func (a *Api) GetAttachments(c *gin.Context) { - files := []string{} - err := filepath.Walk(a.signalCliConfig+"/attachments/", func(path string, info os.FileInfo, err error) error { - if info.IsDir() { - return nil - } - files = append(files, filepath.Base(path)) - return nil - }) - + files, err := a.signalClient.GetAttachments() if err != nil { c.JSON(500, Error{Msg: "Couldn't get list of attachments: " + err.Error()}) return @@ -885,21 +549,25 @@ func (a *Api) GetAttachments(c *gin.Context) { // @Router /v1/attachments/{attachment} [delete] func (a *Api) RemoveAttachment(c *gin.Context) { attachment := c.Param("attachment") - path, err := securejoin.SecureJoin(a.signalCliConfig+"/attachments/", attachment) + + err := a.signalClient.RemoveAttachment(attachment) if err != nil { - c.JSON(400, Error{Msg: "Please provide a valid attachment name"}) - return + switch err.(type) { + case *client.InvalidNameError: + c.JSON(400, Error{Msg: err.Error()}) + return + case *client.NotFoundError: + c.JSON(404, Error{Msg: err.Error()}) + return + case *client.InternalError: + c.JSON(500, Error{Msg: err.Error()}) + return + default: + c.JSON(500, Error{Msg: err.Error()}) + return + } } - if _, err := os.Stat(path); os.IsNotExist(err) { - c.JSON(404, Error{Msg: "No attachment with that name found"}) - return - } - err = os.Remove(path) - if err != nil { - c.JSON(500, Error{Msg: "Couldn't delete attachment - please try again later"}) - return - } c.Status(http.StatusNoContent) } @@ -913,32 +581,34 @@ func (a *Api) RemoveAttachment(c *gin.Context) { // @Router /v1/attachments/{attachment} [get] func (a *Api) ServeAttachment(c *gin.Context) { attachment := c.Param("attachment") - path, err := securejoin.SecureJoin(a.signalCliConfig+"/attachments/", attachment) + + attachmentBytes, err := a.signalClient.GetAttachment(attachment) if err != nil { - c.JSON(400, Error{Msg: "Please provide a valid attachment name"}) - return + switch err.(type) { + case *client.InvalidNameError: + c.JSON(400, Error{Msg: err.Error()}) + return + case *client.NotFoundError: + c.JSON(404, Error{Msg: err.Error()}) + return + case *client.InternalError: + c.JSON(500, Error{Msg: err.Error()}) + return + default: + c.JSON(500, Error{Msg: err.Error()}) + return + } } - if _, err := os.Stat(path); os.IsNotExist(err) { - c.JSON(404, Error{Msg: "No attachment with that name found"}) - return - } - - imgBytes, err := ioutil.ReadFile(path) - if err != nil { - c.JSON(500, Error{Msg: "Couldn't read attachment - please try again later"}) - return - } - - mime, err := mimetype.DetectReader(bytes.NewReader(imgBytes)) + mime, err := mimetype.DetectReader(bytes.NewReader(attachmentBytes)) if err != nil { c.JSON(500, Error{Msg: "Couldn't detect MIME type for attachment"}) return } c.Writer.Header().Set("Content-Type", mime.String()) - c.Writer.Header().Set("Content-Length", strconv.Itoa(len(imgBytes))) - _, err = c.Writer.Write(imgBytes) + c.Writer.Header().Set("Content-Length", strconv.Itoa(len(attachmentBytes))) + _, err = c.Writer.Write(attachmentBytes) if err != nil { c.JSON(500, Error{Msg: "Couldn't serve attachment - please try again later"}) return @@ -974,63 +644,13 @@ func (a *Api) UpdateProfile(c *gin.Context) { c.JSON(400, Error{Msg: "Couldn't process request - profile name missing"}) return } - cmd := []string{"--config", a.signalCliConfig, "-u", number, "updateProfile", "--name", req.Name} - avatarTmpPaths := []string{} - if req.Base64Avatar == "" { - cmd = append(cmd, "--remove-avatar") - } else { - u, err := uuid.NewV4() - if err != nil { - c.JSON(400, Error{Msg: err.Error()}) - return - } - - avatarBytes, err := base64.StdEncoding.DecodeString(req.Base64Avatar) - if err != nil { - c.JSON(400, Error{Msg: "Couldn't decode base64 encoded avatar"}) - return - } - - fType, err := filetype.Get(avatarBytes) - if err != nil { - c.JSON(400, Error{Msg: err.Error()}) - return - } - - avatarTmpPath := a.avatarTmpDir + u.String() + "." + fType.Extension - - f, err := os.Create(avatarTmpPath) - if err != nil { - c.JSON(400, Error{Msg: err.Error()}) - return - } - defer f.Close() - - if _, err := f.Write(avatarBytes); err != nil { - cleanupTmpFiles(avatarTmpPaths) - c.JSON(400, Error{Msg: err.Error()}) - return - } - if err := f.Sync(); err != nil { - cleanupTmpFiles(avatarTmpPaths) - c.JSON(400, Error{Msg: err.Error()}) - return - } - f.Close() - - cmd = append(cmd, []string{"--avatar", avatarTmpPath}...) - avatarTmpPaths = append(avatarTmpPaths, avatarTmpPath) - } - - _, err = runSignalCli(true, cmd, "") + err = a.signalClient.UpdateProfile(number, req.Name, req.Base64Avatar) if err != nil { - cleanupTmpFiles(avatarTmpPaths) c.JSON(400, Error{Msg: err.Error()}) return } - cleanupTmpFiles(avatarTmpPaths) c.Status(http.StatusNoContent) } @@ -1048,7 +668,7 @@ func (a *Api) Health(c *gin.Context) { // @Tags Identities // @Description List all identities for the given number. // @Produce json -// @Success 200 {object} []IdentityEntry +// @Success 200 {object} []client.IdentityEntry // @Param number path string true "Registered Phone Number" // @Router /v1/identities/{number} [get] func (a *Api) ListIdentities(c *gin.Context) { @@ -1059,27 +679,12 @@ func (a *Api) ListIdentities(c *gin.Context) { return } - out, err := runSignalCli(true, []string{"--config", a.signalCliConfig, "-u", number, "listIdentities"}, "") + identityEntries, err := a.signalClient.ListIdentities(number) if err != nil { c.JSON(500, Error{Msg: err.Error()}) return } - identityEntries := []IdentityEntry{} - keyValuePairs := parseWhitespaceDelimitedKeyValueStringList(out, []string{"NumberAndTrustStatus", "Added", "Fingerprint", "Safety Number"}) - for _, keyValuePair := range keyValuePairs { - numberAndTrustStatus := keyValuePair["NumberAndTrustStatus"] - numberAndTrustStatusSplitted := strings.Split(numberAndTrustStatus, ":") - - identityEntry := IdentityEntry{Number: strings.Trim(numberAndTrustStatusSplitted[0], " "), - Status: strings.Trim(numberAndTrustStatusSplitted[1], " "), - Added: keyValuePair["Added"], - Fingerprint: strings.Trim(keyValuePair["Fingerprint"], " "), - SafetyNumber: strings.Trim(keyValuePair["Safety Number"], " "), - } - identityEntries = append(identityEntries, identityEntry) - } - c.JSON(200, identityEntries) } @@ -1119,12 +724,12 @@ func (a *Api) TrustIdentity(c *gin.Context) { return } - cmd := []string{"--config", a.signalCliConfig, "-u", number, "trust", numberToTrust, "--verified-safety-number", req.VerifiedSafetyNumber} - _, err = runSignalCli(true, cmd, "") + err = a.signalClient.TrustIdentity(number, numberToTrust, req.VerifiedSafetyNumber) if err != nil { c.JSON(400, Error{Msg: err.Error()}) return } + c.Status(http.StatusNoContent) } @@ -1201,17 +806,18 @@ func (a *Api) BlockGroup(c *gin.Context) { } groupId := c.Param("groupid") - internalGroupId, err := convertGroupIdToInternalGroupId(groupId) + internalGroupId, err := client.ConvertGroupIdToInternalGroupId(groupId) if err != nil { c.JSON(400, Error{Msg: err.Error()}) return } - _, err = runSignalCli(true, []string{"--config", a.signalCliConfig, "-u", number, "block", "-g", internalGroupId}, "") + err = a.signalClient.BlockGroup(number, internalGroupId) if err != nil { c.JSON(400, Error{Msg: err.Error()}) return } + c.Status(http.StatusNoContent) } @@ -1233,17 +839,18 @@ func (a *Api) JoinGroup(c *gin.Context) { } groupId := c.Param("groupid") - internalGroupId, err := convertGroupIdToInternalGroupId(groupId) + internalGroupId, err := client.ConvertGroupIdToInternalGroupId(groupId) if err != nil { c.JSON(400, Error{Msg: err.Error()}) return } - _, err = runSignalCli(true, []string{"--config", a.signalCliConfig, "-u", number, "updateGroup", "-g", internalGroupId}, "") + err = a.signalClient.JoinGroup(number, internalGroupId) if err != nil { c.JSON(400, Error{Msg: err.Error()}) return } + c.Status(http.StatusNoContent) } @@ -1265,13 +872,13 @@ func (a *Api) QuitGroup(c *gin.Context) { } groupId := c.Param("groupid") - internalGroupId, err := convertGroupIdToInternalGroupId(groupId) + internalGroupId, err := client.ConvertGroupIdToInternalGroupId(groupId) if err != nil { c.JSON(400, Error{Msg: err.Error()}) return } - _, err = runSignalCli(true, []string{"--config", a.signalCliConfig, "-u", number, "quitGroup", "-g", internalGroupId}, "") + err = a.signalClient.QuitGroup(number, internalGroupId) if err != nil { c.JSON(400, Error{Msg: err.Error()}) return diff --git a/src/client/client.go b/src/client/client.go new file mode 100644 index 0000000..6dc2147 --- /dev/null +++ b/src/client/client.go @@ -0,0 +1,974 @@ +package client + +import ( + "bufio" + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/cyphar/filepath-securejoin" + "github.com/gabriel-vasile/mimetype" + "github.com/h2non/filetype" + //"github.com/sourcegraph/jsonrpc2"//"net/rpc/jsonrpc" + log "github.com/sirupsen/logrus" + + uuid "github.com/gofrs/uuid" + qrcode "github.com/skip2/go-qrcode" + + utils "github.com/bbernhard/signal-cli-rest-api/utils" +) + +const groupPrefix = "group." + +const signalCliV2GroupError = "Cannot create a V2 group as self does not have a versioned profile" + +const endpointNotSupportedInJsonRpcMode = "This endpoint is not supported in JSON-RCP mode." + +type GroupPermission int + +const ( + DefaultGroupPermission GroupPermission = iota + 1 + EveryMember + OnlyAdmins +) + +type SignalCliMode int + +const ( + Normal SignalCliMode = iota + 1 + Native + JsonRpc +) + +type GroupLinkState int + +const ( + DefaultGroupLinkState GroupLinkState = iota + 1 + Enabled + EnabledWithApproval + Disabled +) + +func (g GroupPermission) String() string { + return []string{"", "default", "every-member", "only-admins"}[g] +} + +func (g GroupLinkState) String() string { + return []string{"", "enabled", "enabled-with-approval", "disabled"}[g] +} + +type GroupEntry struct { + Name string `json:"name"` + Id string `json:"id"` + InternalId string `json:"internal_id"` + Members []string `json:"members"` + Blocked bool `json:"blocked"` + PendingInvites []string `json:"pending_invites"` + PendingRequests []string `json:"pending_requests"` + InviteLink string `json:"invite_link"` +} + +type IdentityEntry struct { + Number string `json:"number"` + Status string `json:"status"` + Fingerprint string `json:"fingerprint"` + Added string `json:"added"` + SafetyNumber string `json:"safety_number"` +} + +type SignalCliGroupMember struct { + Number string `json:"number"` + Uuid string `json:"uuid"` +} + +type SignalCliGroupEntry struct { + Name string `json:"name"` + Id string `json:"id"` + IsMember bool `json:"isMember"` + IsBlocked bool `json:"isBlocked"` + Members []SignalCliGroupMember `json:"members"` + PendingMembers []string `json:"pendingMembers"` + RequestingMembers []string `json:"requestingMembers"` + GroupInviteLink string `json:"groupInviteLink"` +} + +type SignalCliIdentityEntry struct { + Number string `json:"number"` + Uuid string `json:"uuid"` + Fingerprint string `json:"fingerprint"` + SafetyNumber string `json:"safetyNumber"` + ScannableSafetyNumber string `json:"scannableSafetyNumber"` + TrustLevel string `json:"trustLevel"` + AddedTimestamp int64 `json:"addedTimestamp"` +} + +type SendResponse struct { + Timestamp int64 `json:"timestamp"` +} + +type About struct { + SupportedApiVersions []string `json:"versions"` + BuildNr int `json:"build"` +} + +func cleanupTmpFiles(paths []string) { + for _, path := range paths { + os.Remove(path) + } +} + +func convertInternalGroupIdToGroupId(internalId string) string { + return groupPrefix + base64.StdEncoding.EncodeToString([]byte(internalId)) +} + +func getStringInBetween(str string, start string, end string) (result string) { + i := strings.Index(str, start) + if i == -1 { + return + } + i += len(start) + j := strings.Index(str[i:], end) + if j == -1 { + return + } + return str[i : i+j] +} + +func parseWhitespaceDelimitedKeyValueStringList(in string, keys []string) []map[string]string { + l := []map[string]string{} + lines := strings.Split(in, "\n") + for _, line := range lines { + if line == "" { + continue + } + + m := make(map[string]string) + + temp := line + for i, key := range keys { + if i == 0 { + continue + } + + idx := strings.Index(temp, " "+key+": ") + pair := temp[:idx] + value := strings.TrimPrefix(pair, key+": ") + temp = strings.TrimLeft(temp[idx:], " "+key+": ") + + m[keys[i-1]] = value + } + m[keys[len(keys)-1]] = temp + + l = append(l, m) + } + return l +} + +func getContainerId() (string, error) { + data, err := ioutil.ReadFile("/proc/1/cpuset") + if err != nil { + return "", err + } + lines := strings.Split(string(data), "\n") + if len(lines) == 0 { + return "", errors.New("Couldn't get docker container id (empty)") + } + containerId := strings.Replace(lines[0], "/docker/", "", -1) + return containerId, nil +} + +func runSignalCli(wait bool, args []string, stdin string, signalCliMode SignalCliMode) (string, error) { + containerId, err := getContainerId() + + log.Debug("If you want to run this command manually, run the following steps on your host system:") + if err == nil { + log.Debug("*) docker exec -it ", containerId, " /bin/bash") + } else { + log.Debug("*) docker exec -it /bin/bash") + } + + signalCliBinary := "" + if signalCliMode == Normal { + signalCliBinary = "signal-cli" + } else if signalCliMode == Native { + signalCliBinary = "signal-cli-native" + } else { + return "", errors.New("Invalid signal-cli mode") + } + + fullCmd := "" + if stdin != "" { + fullCmd += "echo '" + stdin + "' | " + } + fullCmd += signalCliBinary + " " + strings.Join(args, " ") + + log.Debug("*) su signal-api") + log.Debug("*) ", fullCmd) + + cmdTimeout, err := utils.GetIntEnv("SIGNAL_CLI_CMD_TIMEOUT", 120) + if err != nil { + log.Error("Env variable 'SIGNAL_CLI_CMD_TIMEOUT' contains an invalid timeout...falling back to default timeout (120 seconds)") + cmdTimeout = 120 + } + + cmd := exec.Command(signalCliBinary, args...) + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } + if wait { + var errBuffer bytes.Buffer + var outBuffer bytes.Buffer + cmd.Stderr = &errBuffer + cmd.Stdout = &outBuffer + + err := cmd.Start() + if err != nil { + return "", err + } + + done := make(chan error, 1) + go func() { + done <- cmd.Wait() + }() + select { + case <-time.After(time.Duration(cmdTimeout) * time.Second): + err := cmd.Process.Kill() + if err != nil { + return "", err + } + return "", errors.New("process killed as timeout reached") + case err := <-done: + if err != nil { + return "", errors.New(errBuffer.String()) + } + } + + return outBuffer.String(), nil + } else { + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", err + } + cmd.Start() + buf := bufio.NewReader(stdout) // Notice that this is not in a loop + line, _, _ := buf.ReadLine() + return string(line), nil + } +} + +func ConvertGroupIdToInternalGroupId(id string) (string, error) { + + groupIdWithoutPrefix := strings.TrimPrefix(id, groupPrefix) + internalGroupId, err := base64.StdEncoding.DecodeString(groupIdWithoutPrefix) + if err != nil { + return "", errors.New("Invalid group id") + } + + return string(internalGroupId), err +} + +type SignalClient struct { + signalCliConfig string + attachmentTmpDir string + avatarTmpDir string + signalCliMode SignalCliMode + jsonRpc2ClientConfig *utils.JsonRpc2ClientConfig + jsonRpc2ClientConfigPath string + jsonRpc2Clients map[string]*JsonRpc2Client +} + +func NewSignalClient(signalCliConfig string, attachmentTmpDir string, avatarTmpDir string, signalCliMode SignalCliMode, + jsonRpc2ClientConfigPath string) *SignalClient { + return &SignalClient{ + signalCliConfig: signalCliConfig, + attachmentTmpDir: attachmentTmpDir, + avatarTmpDir: avatarTmpDir, + signalCliMode: signalCliMode, + jsonRpc2ClientConfigPath: jsonRpc2ClientConfigPath, + jsonRpc2Clients: make(map[string]*JsonRpc2Client), + } +} + +func (s *SignalClient) GetSignalCliMode() SignalCliMode { + return s.signalCliMode +} + +func (s *SignalClient) Init() error { + if s.signalCliMode == JsonRpc { + s.jsonRpc2ClientConfig = utils.NewJsonRpc2ClientConfig() + err := s.jsonRpc2ClientConfig.Load(s.jsonRpc2ClientConfigPath) + if err != nil { + return err + } + + tcpPortsNumberMapping := s.jsonRpc2ClientConfig.GetTcpPortsForNumbers() + for number, tcpPort := range tcpPortsNumberMapping { + s.jsonRpc2Clients[number] = NewJsonRpc2Client() + err := s.jsonRpc2Clients[number].Dial("127.0.0.1:" + strconv.FormatInt(tcpPort, 10)) + if err != nil { + return err + } + + go s.jsonRpc2Clients[number].ReceiveData(number) //receive messages in goroutine + } + } + return nil +} + +func (s *SignalClient) send(number string, message string, + recipients []string, base64Attachments []string, isGroup bool) (*SendResponse, error) { + + var resp SendResponse + + if len(recipients) == 0 { + return nil, errors.New("Please specify at least one recipient") + } + + var groupId string = "" + if isGroup { + if len(recipients) > 1 { + return nil, errors.New("More than one recipient is currently not allowed") + } + + grpId, err := base64.StdEncoding.DecodeString(recipients[0]) + if err != nil { + return nil, errors.New("Invalid group id") + } + groupId = string(grpId) + } + + attachmentTmpPaths := []string{} + for _, base64Attachment := range base64Attachments { + u, err := uuid.NewV4() + if err != nil { + return nil, err + } + + dec, err := base64.StdEncoding.DecodeString(base64Attachment) + if err != nil { + return nil, err + } + + mimeType := mimetype.Detect(dec) + + attachmentTmpPath := s.attachmentTmpDir + u.String() + mimeType.Extension() + attachmentTmpPaths = append(attachmentTmpPaths, attachmentTmpPath) + + f, err := os.Create(attachmentTmpPath) + if err != nil { + return nil, err + } + defer f.Close() + + if _, err := f.Write(dec); err != nil { + cleanupTmpFiles(attachmentTmpPaths) + return nil, err + } + if err := f.Sync(); err != nil { + cleanupTmpFiles(attachmentTmpPaths) + return nil, err + } + + f.Close() + } + + if s.signalCliMode == JsonRpc { + jsonRpc2Client, err := s.getJsonRpc2Client(number) + if err != nil { + return nil, err + } + + type Request struct { + Recipients []string `json:"recipient,omitempty"` + Message string `json:"message"` + GroupId string `json:"group-id,omitempty"` + Attachments []string `json:"attachment,omitempty"` + } + + request := Request{Message: message} + if isGroup { + request.GroupId = groupId + } else { + request.Recipients = recipients + } + if len(attachmentTmpPaths) > 0 { + request.Attachments = append(request.Attachments, attachmentTmpPaths...) + } + + rawData, err := jsonRpc2Client.getRaw("send", request) + if err != nil { + cleanupTmpFiles(attachmentTmpPaths) + return nil, err + } + + err = json.Unmarshal([]byte(rawData), &resp) + if err != nil { + if strings.Contains(err.Error(), signalCliV2GroupError) { + return nil, errors.New("Cannot send message to group - please first update your profile.") + } + return nil, err + } + } else { + cmd := []string{"--config", s.signalCliConfig, "-u", number, "send"} + if !isGroup { + cmd = append(cmd, recipients...) + } else { + cmd = append(cmd, []string{"-g", groupId}...) + } + + if len(attachmentTmpPaths) > 0 { + cmd = append(cmd, "-a") + cmd = append(cmd, attachmentTmpPaths...) + } + + rawData, err := runSignalCli(true, cmd, message, s.signalCliMode) + if err != nil { + cleanupTmpFiles(attachmentTmpPaths) + if strings.Contains(err.Error(), signalCliV2GroupError) { + return nil, errors.New("Cannot send message to group - please first update your profile.") + } + return nil, err + } + resp.Timestamp, err = strconv.ParseInt(strings.TrimSuffix(rawData, "\n"), 10, 64) + if err != nil { + cleanupTmpFiles(attachmentTmpPaths) + return nil, err + } + } + + cleanupTmpFiles(attachmentTmpPaths) + + return &resp, nil +} + +func (s *SignalClient) About() About { + about := About{SupportedApiVersions: []string{"v1", "v2"}, BuildNr: 2} + return about +} + +func (s *SignalClient) RegisterNumber(number string, useVoice bool, captcha string) error { + if s.signalCliMode == JsonRpc { + return errors.New(endpointNotSupportedInJsonRpcMode) + } + command := []string{"--config", s.signalCliConfig, "-u", number, "register"} + + if useVoice { + command = append(command, "--voice") + } + + if captcha != "" { + command = append(command, []string{"--captcha", captcha}...) + } + + _, err := runSignalCli(true, command, "", s.signalCliMode) + return err +} + +func (s *SignalClient) VerifyRegisteredNumber(number string, token string, pin string) error { + if s.signalCliMode == JsonRpc { + return errors.New(endpointNotSupportedInJsonRpcMode) + } + + cmd := []string{"--config", s.signalCliConfig, "-u", number, "verify", token} + if pin != "" { + cmd = append(cmd, "--pin") + cmd = append(cmd, pin) + } + + _, err := runSignalCli(true, cmd, "", s.signalCliMode) + return err +} + +func (s *SignalClient) SendV1(number string, message string, recipients []string, base64Attachments []string, isGroup bool) (*SendResponse, error) { + timestamp, err := s.send(number, message, recipients, base64Attachments, isGroup) + return timestamp, err +} + +func (s *SignalClient) getJsonRpc2Client(number string) (*JsonRpc2Client, error) { + if val, ok := s.jsonRpc2Clients[number]; ok { + return val, nil + } + return nil, errors.New("Number not registered with JSON-RPC") +} + +func (s *SignalClient) SendV2(number string, message string, recps []string, base64Attachments []string) (*[]SendResponse, error) { + if len(recps) == 0 { + return nil, errors.New("Please provide at least one recipient") + } + + if number == "" { + return nil, errors.New("Please provide a valid number") + } + + groups := []string{} + recipients := []string{} + + for _, recipient := range recps { + if strings.HasPrefix(recipient, groupPrefix) { + groups = append(groups, strings.TrimPrefix(recipient, groupPrefix)) + } else { + recipients = append(recipients, recipient) + } + } + + if len(recipients) > 0 && len(groups) > 0 { + return nil, errors.New("Signal Messenger Groups and phone numbers cannot be specified together in one request! Please split them up into multiple REST API calls.") + } + + if len(groups) > 1 { + return nil, errors.New("A signal message cannot be sent to more than one group at once! Please use multiple REST API calls for that.") + } + + timestamps := []SendResponse{} + for _, group := range groups { + timestamp, err := s.send(number, message, []string{group}, base64Attachments, true) + if err != nil { + return nil, err + } + timestamps = append(timestamps, *timestamp) + } + + if len(recipients) > 0 { + timestamp, err := s.send(number, message, recipients, base64Attachments, false) + if err != nil { + return nil, err + } + timestamps = append(timestamps, *timestamp) + } + + return ×tamps, nil +} + +func (s *SignalClient) Receive(number string, timeout int64) (string, error) { + if s.signalCliMode == JsonRpc { + jsonRpc2Client, err := s.getJsonRpc2Client(number) + if err != nil { + return "", err + } + msg := jsonRpc2Client.ReceiveMessage() + if msg.Err.Code != 0 { + return "", errors.New(msg.Err.Message) + } + return string(msg.Params), nil + } else { + command := []string{"--config", s.signalCliConfig, "--output", "json", "-u", number, "receive", "-t", strconv.FormatInt(timeout, 10)} + + out, err := runSignalCli(true, command, "", s.signalCliMode) + if err != nil { + return "", err + } + + out = strings.Trim(out, "\n") + lines := strings.Split(out, "\n") + + jsonStr := "[" + for i, line := range lines { + jsonStr += line + if i != (len(lines) - 1) { + jsonStr += "," + } + } + jsonStr += "]" + + return jsonStr, nil + } +} + +func (s *SignalClient) CreateGroup(number string, name string, members []string, description string, editGroupPermission GroupPermission, addMembersPermission GroupPermission, groupLinkState GroupLinkState) (string, error) { + var err error + var rawData string + if s.signalCliMode == JsonRpc { + type Request struct { + Name string `json:"name"` + Members []string `json:"members"` + } + request := Request{Name: name, Members: members} + + jsonRpc2Client, err := s.getJsonRpc2Client(number) + if err != nil { + return "", err + } + rawData, err = jsonRpc2Client.getRaw("updateGroup", request) + if err != nil { + return "", err + } + } else { + cmd := []string{"--config", s.signalCliConfig, "-u", number, "updateGroup", "-n", name, "-m"} + cmd = append(cmd, members...) + + if addMembersPermission != DefaultGroupPermission { + cmd = append(cmd, []string{"--set-permission-add-member", addMembersPermission.String()}...) + } + + if editGroupPermission != DefaultGroupPermission { + cmd = append(cmd, []string{"--set-permission-edit-details", editGroupPermission.String()}...) + } + + if groupLinkState != DefaultGroupLinkState { + cmd = append(cmd, []string{"--link", groupLinkState.String()}...) + } + + if description != "" { + cmd = append(cmd, []string{"--description", description}...) + } + + rawData, err = runSignalCli(true, cmd, "", s.signalCliMode) + if err != nil { + if strings.Contains(err.Error(), signalCliV2GroupError) { + return "", errors.New("Cannot create group - please first update your profile.") + } + return "", err + } + } + internalGroupId := getStringInBetween(rawData, `"`, `"`) + groupId := convertInternalGroupIdToGroupId(internalGroupId) + + return groupId, nil +} + +func (s *SignalClient) GetGroups(number string) ([]GroupEntry, error) { + groupEntries := []GroupEntry{} + + var signalCliGroupEntries []SignalCliGroupEntry + var err error + var rawData string + + if s.signalCliMode == JsonRpc { + jsonRpc2Client, err := s.getJsonRpc2Client(number) + if err != nil { + return groupEntries, err + } + rawData, err = jsonRpc2Client.getRaw("listGroups", nil) + if err != nil { + return groupEntries, err + } + } else { + rawData, err = runSignalCli(true, []string{"--config", s.signalCliConfig, "--output", "json", "-u", number, "listGroups", "-d"}, "", s.signalCliMode) + if err != nil { + return groupEntries, err + } + } + + err = json.Unmarshal([]byte(rawData), &signalCliGroupEntries) + if err != nil { + return groupEntries, err + } + + for _, signalCliGroupEntry := range signalCliGroupEntries { + var groupEntry GroupEntry + groupEntry.InternalId = signalCliGroupEntry.Id + groupEntry.Name = signalCliGroupEntry.Name + groupEntry.Id = convertInternalGroupIdToGroupId(signalCliGroupEntry.Id) + groupEntry.Blocked = signalCliGroupEntry.IsBlocked + + members := []string{} + for _, val := range signalCliGroupEntry.Members { + members = append(members, val.Number) + } + groupEntry.Members = members + + groupEntry.PendingRequests = signalCliGroupEntry.PendingMembers + groupEntry.PendingInvites = signalCliGroupEntry.RequestingMembers + groupEntry.InviteLink = signalCliGroupEntry.GroupInviteLink + + groupEntries = append(groupEntries, groupEntry) + } + + return groupEntries, nil +} + +func (s *SignalClient) GetGroup(number string, groupId string) (*GroupEntry, error) { + groupEntry := GroupEntry{} + groups, err := s.GetGroups(number) + if err != nil { + return nil, err + } + + for _, group := range groups { + if group.Id == groupId { + groupEntry = group + return &groupEntry, nil + } + } + + return nil, nil +} + +func (s *SignalClient) DeleteGroup(number string, groupId string) error { + _, err := runSignalCli(true, []string{"--config", s.signalCliConfig, "-u", number, "quitGroup", "-g", string(groupId)}, "", s.signalCliMode) + return err +} + +func (s *SignalClient) GetQrCodeLink(deviceName string) ([]byte, error) { + if s.signalCliMode == JsonRpc { + return []byte{}, errors.New(endpointNotSupportedInJsonRpcMode) + } + command := []string{"--config", s.signalCliConfig, "link", "-n", deviceName} + + tsdeviceLink, err := runSignalCli(false, command, "", s.signalCliMode) + if err != nil { + return []byte{}, errors.New("Couldn't create QR code: " + err.Error()) + } + + q, err := qrcode.New(string(tsdeviceLink), qrcode.Medium) + if err != nil { + return []byte{}, errors.New("Couldn't create QR code: " + err.Error()) + } + + q.DisableBorder = false + var png []byte + png, err = q.PNG(256) + if err != nil { + return []byte{}, errors.New("Couldn't create QR code: " + err.Error()) + } + return png, nil +} + +func (s *SignalClient) GetAttachments() ([]string, error) { + files := []string{} + + err := filepath.Walk(s.signalCliConfig+"/attachments/", func(path string, info os.FileInfo, err error) error { + if info.IsDir() { + return nil + } + files = append(files, filepath.Base(path)) + return nil + }) + + return files, err +} + +func (s *SignalClient) RemoveAttachment(attachment string) error { + path, err := securejoin.SecureJoin(s.signalCliConfig+"/attachments/", attachment) + if err != nil { + return &InvalidNameError{Description: "Please provide a valid attachment name"} + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + return &NotFoundError{Description: "No attachment with that name found"} + } + err = os.Remove(path) + if err != nil { + return &InternalError{Description: "Couldn't delete attachment - please try again later"} + } + + return nil +} + +func (s *SignalClient) GetAttachment(attachment string) ([]byte, error) { + path, err := securejoin.SecureJoin(s.signalCliConfig+"/attachments/", attachment) + if err != nil { + return []byte{}, &InvalidNameError{Description: "Please provide a valid attachment name"} + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + return []byte{}, &NotFoundError{Description: "No attachment with that name found"} + } + + attachmentBytes, err := ioutil.ReadFile(path) + if err != nil { + return []byte{}, &InternalError{Description: "Couldn't read attachment - please try again later"} + } + + return attachmentBytes, nil +} + +func (s *SignalClient) UpdateProfile(number string, profileName string, base64Avatar string) error { + var err error + var avatarTmpPath string + if base64Avatar != "" { + u, err := uuid.NewV4() + if err != nil { + return err + } + + avatarBytes, err := base64.StdEncoding.DecodeString(base64Avatar) + if err != nil { + return errors.New("Couldn't decode base64 encoded avatar: " + err.Error()) + } + + fType, err := filetype.Get(avatarBytes) + if err != nil { + return err + } + + avatarTmpPath := s.avatarTmpDir + u.String() + "." + fType.Extension + + f, err := os.Create(avatarTmpPath) + if err != nil { + return err + } + defer f.Close() + + if _, err := f.Write(avatarBytes); err != nil { + cleanupTmpFiles([]string{avatarTmpPath}) + return err + } + if err := f.Sync(); err != nil { + cleanupTmpFiles([]string{avatarTmpPath}) + return err + } + f.Close() + } + + if s.signalCliMode == JsonRpc { + type Request struct { + Name string `json:"name"` + Avatar string `json:"avatar,omitempty"` + RemoveAvatar bool `json:"remove-avatar"` + } + request := Request{Name: profileName} + if base64Avatar == "" { + request.RemoveAvatar = true + } else { + request.Avatar = avatarTmpPath + request.RemoveAvatar = false + } + jsonRpc2Client, err := s.getJsonRpc2Client(number) + if err != nil { + return err + } + _, err = jsonRpc2Client.getRaw("updateProfile", request) + } else { + cmd := []string{"--config", s.signalCliConfig, "-u", number, "updateProfile", "--name", profileName} + if base64Avatar == "" { + cmd = append(cmd, "--remove-avatar") + } else { + cmd = append(cmd, []string{"--avatar", avatarTmpPath}...) + } + + _, err = runSignalCli(true, cmd, "", s.signalCliMode) + } + + cleanupTmpFiles([]string{avatarTmpPath}) + return err +} + +func (s *SignalClient) ListIdentities(number string) (*[]IdentityEntry, error) { + identityEntries := []IdentityEntry{} + if s.signalCliMode == JsonRpc { + jsonRpc2Client, err := s.getJsonRpc2Client(number) + if err != nil { + return nil, err + } + rawData, err := jsonRpc2Client.getRaw("listIdentities", nil) + signalCliIdentityEntries := []SignalCliIdentityEntry{} + err = json.Unmarshal([]byte(rawData), &signalCliIdentityEntries) + if err != nil { + return nil, err + } + for _, signalCliIdentityEntry := range signalCliIdentityEntries { + identityEntry := IdentityEntry{ + Number: signalCliIdentityEntry.Number, + Status: signalCliIdentityEntry.TrustLevel, + Added: strconv.FormatInt(signalCliIdentityEntry.AddedTimestamp, 10), + Fingerprint: signalCliIdentityEntry.Fingerprint, + SafetyNumber: signalCliIdentityEntry.SafetyNumber, + } + identityEntries = append(identityEntries, identityEntry) + } + } else { + rawData, err := runSignalCli(true, []string{"--config", s.signalCliConfig, "-u", number, "listIdentities"}, "", s.signalCliMode) + if err != nil { + return nil, err + } + + keyValuePairs := parseWhitespaceDelimitedKeyValueStringList(rawData, []string{"NumberAndTrustStatus", "Added", "Fingerprint", "Safety Number"}) + for _, keyValuePair := range keyValuePairs { + numberAndTrustStatus := keyValuePair["NumberAndTrustStatus"] + numberAndTrustStatusSplitted := strings.Split(numberAndTrustStatus, ":") + + identityEntry := IdentityEntry{Number: strings.Trim(numberAndTrustStatusSplitted[0], " "), + Status: strings.Trim(numberAndTrustStatusSplitted[1], " "), + Added: keyValuePair["Added"], + Fingerprint: strings.Trim(keyValuePair["Fingerprint"], " "), + SafetyNumber: strings.Trim(keyValuePair["Safety Number"], " "), + } + identityEntries = append(identityEntries, identityEntry) + } + } + + return &identityEntries, nil +} + +func (s *SignalClient) TrustIdentity(number string, numberToTrust string, verifiedSafetyNumber string) error { + var err error + if s.signalCliMode == JsonRpc { + type Request struct { + VerifiedSafetyNumber string `json:"verified-safety-number"` + Recipient string `json:"recipient"` + } + request := Request{VerifiedSafetyNumber: verifiedSafetyNumber, Recipient: numberToTrust} + jsonRpc2Client, err := s.getJsonRpc2Client(number) + if err != nil { + return err + } + _, err = jsonRpc2Client.getRaw("trust", request) + } else { + cmd := []string{"--config", s.signalCliConfig, "-u", number, "trust", numberToTrust, "--verified-safety-number", verifiedSafetyNumber} + _, err = runSignalCli(true, cmd, "", s.signalCliMode) + } + return err +} + +func (s *SignalClient) BlockGroup(number string, groupId string) error { + var err error + if s.signalCliMode == JsonRpc { + type Request struct { + GroupId string `json:"groupId"` + } + request := Request{GroupId: groupId} + jsonRpc2Client, err := s.getJsonRpc2Client(number) + if err != nil { + return err + } + _, err = jsonRpc2Client.getRaw("updateGroup", request) + } else { + _, err = runSignalCli(true, []string{"--config", s.signalCliConfig, "-u", number, "block", "-g", groupId}, "", s.signalCliMode) + } + return err +} + +func (s *SignalClient) JoinGroup(number string, groupId string) error { + var err error + if s.signalCliMode == JsonRpc { + type Request struct { + GroupId string `json:"groupId"` + } + request := Request{GroupId: groupId} + jsonRpc2Client, err := s.getJsonRpc2Client(number) + if err != nil { + return err + } + _, err = jsonRpc2Client.getRaw("updateGroup", request) + } else { + _, err = runSignalCli(true, []string{"--config", s.signalCliConfig, "-u", number, "updateGroup", "-g", groupId}, "", s.signalCliMode) + } + return err +} + +func (s *SignalClient) QuitGroup(number string, groupId string) error { + var err error + if s.signalCliMode == JsonRpc { + type Request struct { + GroupId string `json:"groupId"` + } + request := Request{GroupId: groupId} + jsonRpc2Client, err := s.getJsonRpc2Client(number) + if err != nil { + return err + } + _, err = jsonRpc2Client.getRaw("quitGroup", request) + } else { + _, err = runSignalCli(true, []string{"--config", s.signalCliConfig, "-u", number, "quitGroup", "-g", groupId}, "", s.signalCliMode) + } + return err +} diff --git a/src/client/errors.go b/src/client/errors.go new file mode 100644 index 0000000..5344334 --- /dev/null +++ b/src/client/errors.go @@ -0,0 +1,25 @@ +package client + +type InvalidNameError struct { + Description string +} + +func (e *InvalidNameError) Error() string { + return e.Description +} + +type NotFoundError struct { + Description string +} + +func (e *NotFoundError) Error() string { + return e.Description +} + +type InternalError struct { + Description string +} + +func (e *InternalError) Error() string { + return e.Description +} diff --git a/src/client/jsonrpc2.go b/src/client/jsonrpc2.go new file mode 100644 index 0000000..c78d368 --- /dev/null +++ b/src/client/jsonrpc2.go @@ -0,0 +1,134 @@ +package client + +import ( + "bufio" + "encoding/json" + "errors" + uuid "github.com/gofrs/uuid" + log "github.com/sirupsen/logrus" + "net" +) + +type Error struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type JsonRpc2MessageResponse struct { + Id string `json:"id"` + Result json.RawMessage `json:"result"` + Err Error `json:"error"` +} + +type JsonRpc2ReceivedMessage struct { + Method string `json:"method"` + Params json.RawMessage `json:"params"` + Err Error `json:"error"` +} + +type JsonRpc2Client struct { + conn net.Conn + receivedMessageResponses chan JsonRpc2MessageResponse + receivedMessages chan JsonRpc2ReceivedMessage +} + +func NewJsonRpc2Client() *JsonRpc2Client { + return &JsonRpc2Client{} +} + +func (r *JsonRpc2Client) Dial(address string) error { + var err error + r.conn, err = net.Dial("tcp", address) + if err != nil { + return err + } + + r.receivedMessageResponses = make(chan JsonRpc2MessageResponse) + r.receivedMessages = make(chan JsonRpc2ReceivedMessage) + + return nil +} + +func (r *JsonRpc2Client) getRaw(command string, args interface{}) (string, error) { + type Request struct { + JsonRpc string `json:"jsonrpc"` + Method string `json:"method"` + Id string `json:"id"` + Params interface{} `json:"params,omitempty"` + } + + u, err := uuid.NewV4() + if err != nil { + return "", err + } + + fullCommand := Request{JsonRpc: "2.0", Method: command, Id: u.String()} + if args != nil { + fullCommand.Params = args + } + + fullCommandBytes, err := json.Marshal(fullCommand) + if err != nil { + return "", err + } + + log.Debug("full command: ", string(fullCommandBytes)) + + _, err = r.conn.Write([]byte(string(fullCommandBytes) + "\n")) + if err != nil { + return "", err + } + + var resp JsonRpc2MessageResponse + for { + resp = <-r.receivedMessageResponses + if resp.Id == u.String() { + break + } + } + + if resp.Err.Code != 0 { + return "", errors.New(resp.Err.Message) + } + return string(resp.Result), nil +} + +func (r *JsonRpc2Client) ReceiveData(number string) { + connbuf := bufio.NewReader(r.conn) + for { + str, err := connbuf.ReadString('\n') + if err != nil { + log.Error("Couldn't read data for number ", number, ": ", err.Error(), ". Is the number properly registered?") + continue + } + //log.Info("Received data = ", str) + + var resp1 JsonRpc2ReceivedMessage + json.Unmarshal([]byte(str), &resp1) + if resp1.Method == "receive" { + select { + case r.receivedMessages <- resp1: + log.Debug("Message sent to golang channel") + default: + log.Debug("Couldn't send message to golang channel, as there's no receiver") + } + continue + } + + var resp2 JsonRpc2MessageResponse + err = json.Unmarshal([]byte(str), &resp2) + if err == nil { + if resp2.Id != "" { + r.receivedMessageResponses <- resp2 + } + } else { + log.Error("Received unparsable message: ", str) + } + } +} + +//blocks until message a message is received +func (r *JsonRpc2Client) ReceiveMessage() JsonRpc2ReceivedMessage { + resp := <-r.receivedMessages + return resp +} diff --git a/src/docs/docs.go b/src/docs/docs.go index 4793a65..4279001 100644 --- a/src/docs/docs.go +++ b/src/docs/docs.go @@ -39,7 +39,7 @@ var doc = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.About" + "$ref": "#/definitions/client.About" } } } @@ -237,7 +237,7 @@ var doc = `{ "schema": { "type": "array", "items": { - "$ref": "#/definitions/api.GroupEntry" + "$ref": "#/definitions/client.GroupEntry" } } }, @@ -328,7 +328,7 @@ var doc = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.GroupEntry" + "$ref": "#/definitions/client.GroupEntry" } }, "400": { @@ -563,7 +563,7 @@ var doc = `{ "schema": { "type": "array", "items": { - "$ref": "#/definitions/api.IdentityEntry" + "$ref": "#/definitions/client.IdentityEntry" } } } @@ -696,7 +696,7 @@ var doc = `{ }, "/v1/receive/{number}": { "get": { - "description": "Receives Signal Messages from the Signal Network.", + "description": "Receives Signal Messages from the Signal Network. If you are running the docker container in normal/native mode, this is a GET endpoint. In json-rpc mode this is a websocket endpoint.", "consumes": [ "application/json" ], @@ -918,20 +918,6 @@ var doc = `{ } }, "definitions": { - "api.About": { - "type": "object", - "properties": { - "build": { - "type": "integer" - }, - "versions": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, "api.Configuration": { "type": "object", "properties": { @@ -986,44 +972,6 @@ var doc = `{ } } }, - "api.GroupEntry": { - "type": "object", - "properties": { - "blocked": { - "type": "boolean" - }, - "id": { - "type": "string" - }, - "internal_id": { - "type": "string" - }, - "invite_link": { - "type": "string" - }, - "members": { - "type": "array", - "items": { - "type": "string" - } - }, - "name": { - "type": "string" - }, - "pending_invites": { - "type": "array", - "items": { - "type": "string" - } - }, - "pending_requests": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, "api.GroupPermissions": { "type": "object", "properties": { @@ -1043,26 +991,6 @@ var doc = `{ } } }, - "api.IdentityEntry": { - "type": "object", - "properties": { - "added": { - "type": "string" - }, - "fingerprint": { - "type": "string" - }, - "number": { - "type": "string" - }, - "safety_number": { - "type": "string" - }, - "status": { - "type": "string" - } - } - }, "api.LoggingConfiguration": { "type": "object", "properties": { @@ -1162,6 +1090,78 @@ var doc = `{ "type": "string" } } + }, + "client.About": { + "type": "object", + "properties": { + "build": { + "type": "integer" + }, + "versions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "client.GroupEntry": { + "type": "object", + "properties": { + "blocked": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "internal_id": { + "type": "string" + }, + "invite_link": { + "type": "string" + }, + "members": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "pending_invites": { + "type": "array", + "items": { + "type": "string" + } + }, + "pending_requests": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "client.IdentityEntry": { + "type": "object", + "properties": { + "added": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "number": { + "type": "string" + }, + "safety_number": { + "type": "string" + }, + "status": { + "type": "string" + } + } } }, "tags": [ diff --git a/src/docs/swagger.json b/src/docs/swagger.json index 28b735c..b7fcad0 100644 --- a/src/docs/swagger.json +++ b/src/docs/swagger.json @@ -24,7 +24,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.About" + "$ref": "#/definitions/client.About" } } } @@ -222,7 +222,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/api.GroupEntry" + "$ref": "#/definitions/client.GroupEntry" } } }, @@ -313,7 +313,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.GroupEntry" + "$ref": "#/definitions/client.GroupEntry" } }, "400": { @@ -548,7 +548,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/api.IdentityEntry" + "$ref": "#/definitions/client.IdentityEntry" } } } @@ -681,7 +681,7 @@ }, "/v1/receive/{number}": { "get": { - "description": "Receives Signal Messages from the Signal Network.", + "description": "Receives Signal Messages from the Signal Network. If you are running the docker container in normal/native mode, this is a GET endpoint. In json-rpc mode this is a websocket endpoint.", "consumes": [ "application/json" ], @@ -903,20 +903,6 @@ } }, "definitions": { - "api.About": { - "type": "object", - "properties": { - "build": { - "type": "integer" - }, - "versions": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, "api.Configuration": { "type": "object", "properties": { @@ -971,44 +957,6 @@ } } }, - "api.GroupEntry": { - "type": "object", - "properties": { - "blocked": { - "type": "boolean" - }, - "id": { - "type": "string" - }, - "internal_id": { - "type": "string" - }, - "invite_link": { - "type": "string" - }, - "members": { - "type": "array", - "items": { - "type": "string" - } - }, - "name": { - "type": "string" - }, - "pending_invites": { - "type": "array", - "items": { - "type": "string" - } - }, - "pending_requests": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, "api.GroupPermissions": { "type": "object", "properties": { @@ -1028,26 +976,6 @@ } } }, - "api.IdentityEntry": { - "type": "object", - "properties": { - "added": { - "type": "string" - }, - "fingerprint": { - "type": "string" - }, - "number": { - "type": "string" - }, - "safety_number": { - "type": "string" - }, - "status": { - "type": "string" - } - } - }, "api.LoggingConfiguration": { "type": "object", "properties": { @@ -1147,6 +1075,78 @@ "type": "string" } } + }, + "client.About": { + "type": "object", + "properties": { + "build": { + "type": "integer" + }, + "versions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "client.GroupEntry": { + "type": "object", + "properties": { + "blocked": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "internal_id": { + "type": "string" + }, + "invite_link": { + "type": "string" + }, + "members": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "pending_invites": { + "type": "array", + "items": { + "type": "string" + } + }, + "pending_requests": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "client.IdentityEntry": { + "type": "object", + "properties": { + "added": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "number": { + "type": "string" + }, + "safety_number": { + "type": "string" + }, + "status": { + "type": "string" + } + } } }, "tags": [ diff --git a/src/docs/swagger.yaml b/src/docs/swagger.yaml index f5fdc6a..139e792 100644 --- a/src/docs/swagger.yaml +++ b/src/docs/swagger.yaml @@ -1,14 +1,5 @@ basePath: / definitions: - api.About: - properties: - build: - type: integer - versions: - items: - type: string - type: array - type: object api.Configuration: properties: logging: @@ -45,31 +36,6 @@ definitions: error: type: string type: object - api.GroupEntry: - properties: - blocked: - type: boolean - id: - type: string - internal_id: - type: string - invite_link: - type: string - members: - items: - type: string - type: array - name: - type: string - pending_invites: - items: - type: string - type: array - pending_requests: - items: - type: string - type: array - type: object api.GroupPermissions: properties: add_members: @@ -83,19 +49,6 @@ definitions: - every-member type: string type: object - api.IdentityEntry: - properties: - added: - type: string - fingerprint: - type: string - number: - type: string - safety_number: - type: string - status: - type: string - type: object api.LoggingConfiguration: properties: Level: @@ -160,6 +113,53 @@ definitions: pin: type: string type: object + client.About: + properties: + build: + type: integer + versions: + items: + type: string + type: array + type: object + client.GroupEntry: + properties: + blocked: + type: boolean + id: + type: string + internal_id: + type: string + invite_link: + type: string + members: + items: + type: string + type: array + name: + type: string + pending_invites: + items: + type: string + type: array + pending_requests: + items: + type: string + type: array + type: object + client.IdentityEntry: + properties: + added: + type: string + fingerprint: + type: string + number: + type: string + safety_number: + type: string + status: + type: string + type: object host: 127.0.0.1:8080 info: contact: {} @@ -177,7 +177,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/api.About' + $ref: '#/definitions/client.About' summary: Lists general information about the API tags: - General @@ -307,7 +307,7 @@ paths: description: OK schema: items: - $ref: '#/definitions/api.GroupEntry' + $ref: '#/definitions/client.GroupEntry' type: array "400": description: Bad Request @@ -397,7 +397,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/api.GroupEntry' + $ref: '#/definitions/client.GroupEntry' "400": description: Bad Request schema: @@ -524,7 +524,7 @@ paths: description: OK schema: items: - $ref: '#/definitions/api.IdentityEntry' + $ref: '#/definitions/client.IdentityEntry' type: array summary: List Identities tags: @@ -615,7 +615,7 @@ paths: get: consumes: - application/json - description: Receives Signal Messages from the Signal Network. + description: Receives Signal Messages from the Signal Network. If you are running the docker container in normal/native mode, this is a GET endpoint. In json-rpc mode this is a websocket endpoint. parameters: - description: Registered Phone Number in: path diff --git a/src/go.mod b/src/go.mod index 3bc5b8d..56b5303 100644 --- a/src/go.mod +++ b/src/go.mod @@ -11,6 +11,7 @@ require ( github.com/go-openapi/spec v0.19.8 // indirect github.com/go-openapi/swag v0.19.9 // indirect github.com/gofrs/uuid v3.3.0+incompatible + github.com/gorilla/websocket v1.4.2 github.com/h2non/filetype v1.1.0 github.com/mailru/easyjson v0.7.1 // indirect github.com/robfig/cron/v3 v3.0.1 @@ -23,5 +24,5 @@ require ( golang.org/x/net v0.0.0-20200625001655-4c5254603344 // indirect golang.org/x/text v0.3.3 // indirect golang.org/x/tools v0.0.0-20200626171337-aa94e735be7f // indirect - gopkg.in/yaml.v2 v2.3.0 // indirect + gopkg.in/yaml.v2 v2.3.0 ) diff --git a/src/go.sum b/src/go.sum index 0199f7c..236835e 100644 --- a/src/go.sum +++ b/src/go.sum @@ -16,6 +16,7 @@ github.com/cyphar/filepath-securejoin v0.2.2 h1:jCwT2GTP+PY5nBz3c/YL5PAIbusElVrP github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gabriel-vasile/mimetype v1.1.2 h1:gaPnPcNor5aZSVCJVSGipcpbgMWiAAj9z182ocSGbHU= github.com/gabriel-vasile/mimetype v1.1.2/go.mod h1:6CDPel/o/3/s4+bp6kIbsWATq8pmgOisOPG40CJa6To= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= @@ -68,8 +69,11 @@ github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/h2non/filetype v1.1.0 h1:Or/gjocJrJRNK/Cri/TDEKFjAR+cfG6eK65NGYB6gBA= github.com/h2non/filetype v1.1.0/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -95,6 +99,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -143,6 +149,7 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -156,9 +163,11 @@ golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200625001655-4c5254603344 h1:vGXIOMxbNfDTk/aXCmfdLgkrSV+Z2tcbze+pEc3v5W4= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -188,8 +197,10 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/src/main.go b/src/main.go index f712e47..3c1480c 100644 --- a/src/main.go +++ b/src/main.go @@ -3,13 +3,15 @@ package main import ( "encoding/json" "flag" - "strings" - "strconv" + "io/ioutil" "net/http" "os" "path/filepath" - "io/ioutil" + "strconv" + "strings" + "github.com/bbernhard/signal-cli-rest-api/api" + "github.com/bbernhard/signal-cli-rest-api/client" _ "github.com/bbernhard/signal-cli-rest-api/docs" "github.com/bbernhard/signal-cli-rest-api/utils" "github.com/gin-gonic/gin" @@ -71,7 +73,56 @@ func main() { log.Fatal("Couldn't set env variable: ", err.Error()) } - api := api.NewApi(*signalCliConfig, *attachmentTmpDir, *avatarTmpDir) + useNative := utils.GetEnv("USE_NATIVE", "") + if useNative != "" { + log.Warning("The env variable USE_NATIVE is deprecated. Please use the env variable MODE instead") + } + + signalCliMode := client.Normal + mode := utils.GetEnv("MODE", "normal") + if mode == "normal" { + signalCliMode = client.Normal + } else if mode == "json-rpc" { + signalCliMode = client.JsonRpc + } else if mode == "native" { + signalCliMode = client.Native + } + + if useNative != "" { + _, modeEnvVariableSet := os.LookupEnv("MODE") + if modeEnvVariableSet { + log.Fatal("You have both the USE_NATIVE and the MODE env variable set. Please remove the deprecated env variable USE_NATIVE!") + } + } + + if useNative == "1" || signalCliMode == client.Native { + if supportsSignalCliNative == "0" { + log.Error("signal-cli-native is not support on this system...falling back to signal-cli") + signalCliMode = client.Normal + } + } + + if signalCliMode == client.JsonRpc { + _, autoReceiveScheduleEnvVariableSet := os.LookupEnv("AUTO_RECEIVE_SCHEDULE") + if autoReceiveScheduleEnvVariableSet { + log.Fatal("Env variable AUTO_RECEIVE_SCHEDULE can't be used with mode json-rpc") + } + + _, signalCliCommandTimeoutEnvVariableSet := os.LookupEnv("SIGNAL_CLI_CMD_TIMEOUT") + if signalCliCommandTimeoutEnvVariableSet { + log.Fatal("Env variable SIGNAL_CLI_CMD_TIMEOUT can't be used with mode json-rpc") + } + } + + + jsonRpc2ClientConfigPathPath := *signalCliConfig + "/jsonrpc2.yml" + signalClient := client.NewSignalClient(*signalCliConfig, *attachmentTmpDir, *avatarTmpDir, signalCliMode, jsonRpc2ClientConfigPathPath) + err = signalClient.Init() + if err != nil { + log.Fatal("Couldn't init Signal Client: ", err.Error()) + } + + api := api.NewApi(signalClient) v1 := router.Group("/v1") { about := v1.Group("/about") diff --git a/src/scripts/jsonrpc2-helper.go b/src/scripts/jsonrpc2-helper.go new file mode 100644 index 0000000..759b371 --- /dev/null +++ b/src/scripts/jsonrpc2-helper.go @@ -0,0 +1,101 @@ +package main + +import ( + "flag" + "fmt" + "github.com/bbernhard/signal-cli-rest-api/utils" + log "github.com/sirupsen/logrus" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +const supervisorctlConfigTemplate = ` +[program:%s] +environment=JAVA_HOME=/opt/java/openjdk +process_name=%s +command=bash -c "nc -l -p %d <%s | signal-cli --output=json -u %s --config /home/.local/share/signal-cli/ jsonRpc >%s" +autostart=true +autorestart=true +startretries=10 +user=signal-api +directory=/usr/bin/ +redirect_stderr=true +stdout_logfile=/var/log/%s/out.log +stderr_logfile=/var/log/%s/err.log +stdout_logfile_maxbytes=50MB +stdout_logfile_backups=10 +numprocs=1 +` + +func main() { + signalCliConfigDir := flag.String("signal-cli-config-dir", "/home/.local/share/signal-cli/", "Path to signal-cli config directory") + signalCliConfigDataDir := *signalCliConfigDir + "data" + + jsonRpc2ClientConfig := utils.NewJsonRpc2ClientConfig() + + var tcpBasePort int64 = 6000 + fifoBasePathName := "/tmp/sigsocket" + var ctr int64 = 0 + + items, err := ioutil.ReadDir(signalCliConfigDataDir) + if err != nil { + log.Fatal("Couldn't read contents of ", signalCliConfigDataDir) + } + for _, item := range items { + if item.IsDir() { + continue + } + filename := filepath.Base(item.Name()) + if strings.HasPrefix(filename, "+") { + if utils.IsPhoneNumber(filename) { + number := filename + fifoPathname := fifoBasePathName + strconv.FormatInt(ctr, 10) + tcpPort := tcpBasePort + ctr + jsonRpc2ClientConfig.AddEntry(number, utils.ConfigEntry{TcpPort: tcpPort, FifoPathname: fifoPathname}) + ctr += 1 + + os.Remove(fifoPathname) //remove any existing named pipe + + _, err = exec.Command("mkfifo", fifoPathname).Output() + if err != nil { + log.Fatal("Couldn't create fifo with name ", fifoPathname, ": ", err.Error()) + } + + _, err = exec.Command("chown", "1000:1000", fifoPathname).Output() + if err != nil { + log.Fatal("Couldn't change permissions of fifo with name ", fifoPathname, ": ", err.Error()) + } + + supervisorctlProgramName := "signal-cli-json-rpc-" + strconv.FormatInt(ctr, 10) + supervisorctlLogFolder := "/var/log/" + supervisorctlProgramName + _, err = exec.Command("mkdir", "-p", supervisorctlLogFolder).Output() + if err != nil { + log.Fatal("Couldn't create log folder ", supervisorctlLogFolder, ": ", err.Error()) + } + + log.Info("Found number ", number, " and added it to jsonrpc2.yml") + + //write supervisorctl config + supervisorctlConfigFilename := "/etc/supervisor/conf.d/" + "signal-cli-json-rpc-" + strconv.FormatInt(ctr, 10) + ".conf" + supervisorctlConfig := fmt.Sprintf(supervisorctlConfigTemplate, supervisorctlProgramName, supervisorctlProgramName, + tcpPort, fifoPathname, number, fifoPathname, supervisorctlProgramName, supervisorctlProgramName) + err = ioutil.WriteFile(supervisorctlConfigFilename, []byte(supervisorctlConfig), 0644) + if err != nil { + log.Fatal("Couldn't write ", supervisorctlConfigFilename, ": ", err.Error()) + } + } else { + log.Error("Skipping ", filename, " as it is not a valid phone number!") + } + } + } + + // write jsonrpc.yml config file + err = jsonRpc2ClientConfig.Persist(*signalCliConfigDir + "jsonrpc2.yml") + if err != nil { + log.Fatal("Couldn't persist jsonrpc2.yaml: ", err.Error()) + } +} diff --git a/src/utils/config.go b/src/utils/config.go new file mode 100644 index 0000000..dff209e --- /dev/null +++ b/src/utils/config.go @@ -0,0 +1,79 @@ +package utils + +import ( + "errors" + "gopkg.in/yaml.v2" + "io/ioutil" +) + +type ConfigEntry struct { + TcpPort int64 `yaml:"tcp_port"` + FifoPathname string `yaml:"fifo_pathname"` +} + +type Config struct { + Entries map[string]ConfigEntry `yaml:"config,omitempty"` +} + +type JsonRpc2ClientConfig struct { + config Config +} + +func NewJsonRpc2ClientConfig() *JsonRpc2ClientConfig { + return &JsonRpc2ClientConfig{} +} + +func (c *JsonRpc2ClientConfig) Load(path string) error { + data, err := ioutil.ReadFile(path) + if err != nil { + return err + } + + err = yaml.Unmarshal(data, &c.config) + if err != nil { + return err + } + + return nil +} + +func (c *JsonRpc2ClientConfig) GetTcpPortForNumber(number string) (int64, error) { + if val, ok := c.config.Entries[number]; ok { + return val.TcpPort, nil + } + + return 0, errors.New("Number " + number + " not found in local map") +} + +func (c *JsonRpc2ClientConfig) GetFifoPathnameForNumber(number string) (string, error) { + if val, ok := c.config.Entries[number]; ok { + return val.FifoPathname, nil + } + + return "", errors.New("Number " + number + " not found in local map") +} + +func (c *JsonRpc2ClientConfig) GetTcpPortsForNumbers() map[string]int64 { + mapping := make(map[string]int64) + for number, val := range c.config.Entries { + mapping[number] = val.TcpPort + } + + return mapping +} + +func (c *JsonRpc2ClientConfig) AddEntry(number string, configEntry ConfigEntry) { + if c.config.Entries == nil { + c.config.Entries = make(map[string]ConfigEntry) + } + c.config.Entries[number] = configEntry +} + +func (c *JsonRpc2ClientConfig) Persist(path string) error { + out, err := yaml.Marshal(&c.config) + if err != nil { + return err + } + + return ioutil.WriteFile(path, out, 0644) +} diff --git a/src/utils/utils.go b/src/utils/utils.go index c534ee3..70612bd 100644 --- a/src/utils/utils.go +++ b/src/utils/utils.go @@ -31,3 +31,18 @@ func StringInSlice(a string, list []string) bool { } return false } + +func IsPhoneNumber(s string) bool { + for index, c := range s { + if index == 0 { + if c != '+' { + return false + } + } else { + if c < '0' || c > '9' { + return false + } + } + } + return true +}