├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── README.md ├── apns ├── client.go ├── error.go ├── errorresponsecode_string.go ├── jwt.go ├── mock_server.go ├── notification.go ├── notification_test.go ├── response.go └── response_test.go ├── bench └── scripts │ ├── err_and_success.lua │ └── post.lua ├── client.go ├── cmd ├── apnsmock │ └── apnsmock.go └── gunfish │ └── gunfish.go ├── config ├── config.go ├── config_test.go └── gunfish.toml.example ├── const.go ├── docker ├── Dockerfile └── hooks │ └── build ├── environment_string.go ├── fcmv1 ├── client.go ├── error.go ├── request.go ├── request_test.go ├── response.go └── response_test.go ├── global.go ├── go.mod ├── go.sum ├── gunfish_test.go ├── logger.go ├── ltsv_formatter.go ├── ltsv_formatter_test.go ├── mock ├── apns_server.go └── fcmv1_server.go ├── request.go ├── response.go ├── server.go ├── server_test.go ├── stat.go ├── supervisor.go ├── supervisor_test.go └── test ├── gunfish_test.toml ├── invalid.crt ├── invalid.csr ├── invalid.key ├── scripts ├── curl_test.sh └── gen_test_cert.sh └── tools ├── apnsmock └── apnsmock.go ├── fcmv1mock └── fcmv1mock.go └── gunfish-cli └── gunfish-cli.go /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - "!**/*" 6 | tags: 7 | - "v*" 8 | 9 | permissions: 10 | contents: write 11 | packages: write 12 | 13 | jobs: 14 | release: 15 | name: Release 16 | runs-on: ubuntu-22.04 17 | steps: 18 | - name: Set up Go 19 | uses: actions/setup-go@v4 20 | with: 21 | go-version: "1.21" 22 | 23 | - name: Check out code into the Go module directory 24 | uses: actions/checkout@v4 25 | 26 | - name: setup QEMU 27 | uses: docker/setup-qemu-action@v3 28 | 29 | - name: setup Docker Buildx 30 | uses: docker/setup-buildx-action@v3 31 | 32 | - name: Run GoReleaser 33 | uses: goreleaser/goreleaser-action@v4 34 | with: 35 | version: latest 36 | args: release 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | - name: Docker Login 41 | uses: docker/login-action@v3 42 | with: 43 | username: fujiwara 44 | password: ${{ secrets.DOCKER_TOKEN }} 45 | 46 | - name: Docker Login 47 | uses: docker/login-action@v3 48 | with: 49 | registry: ghcr.io 50 | username: $GITHUB_ACTOR 51 | password: ${{ secrets.GITHUB_TOKEN }} 52 | 53 | - name: docker 54 | run: | 55 | PATH=~/bin:$PATH make docker-push 56 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [push] 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go: 8 | - "1.21" 9 | - "1.20" 10 | name: Build 11 | runs-on: ubuntu-22.04 12 | steps: 13 | - name: Set up Go 14 | uses: actions/setup-go@v4 15 | with: 16 | go-version: ${{ matrix.go }} 17 | id: go 18 | 19 | - name: Check out code into the Go module directory 20 | uses: actions/checkout@v4 21 | 22 | - name: Build & Test 23 | run: | 24 | make test 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | # User specific 27 | /config/gunfish.toml 28 | /gunfish 29 | /gunfish-cli 30 | /apnsmock 31 | /test/server* 32 | /pkg 33 | /dist 34 | 35 | *~ 36 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # This is an example goreleaser.yaml file with some sane defaults. 2 | # Make sure to check the documentation at http://goreleaser.com 3 | before: 4 | hooks: 5 | - go mod download 6 | builds: 7 | - env: 8 | - CGO_ENABLED=0 9 | main: ./cmd/gunfish/ 10 | binary: gunfish 11 | ldflags: 12 | - -s -w 13 | - -X main.version=v{{.Version}} 14 | goos: 15 | - darwin 16 | - linux 17 | goarch: 18 | - amd64 19 | - arm64 20 | archives: 21 | - name_template: "{{.ProjectName}}_v{{.Version}}_{{.Os}}_{{.Arch}}" 22 | 23 | release: 24 | prerelease: "true" 25 | checksum: 26 | name_template: "checksums.txt" 27 | changelog: 28 | sort: asc 29 | filters: 30 | exclude: 31 | - "^docs:" 32 | - "^test:" 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 KAYAC Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GIT_VER:=$(shell git describe --tags) 2 | DATE:=$(shell date +%Y-%m-%dT%H:%M:%SZ) 3 | export GO111MODULE:=on 4 | export PROJECT_ROOT:=$(shell git rev-parse --show-toplevel) 5 | 6 | .PHONY: test install clean 7 | 8 | all: test 9 | 10 | install: 11 | cd cmd/gunfish && go build -ldflags "-X main.version=${GIT_VER} -X main.buildDate=${DATE}" 12 | install cmd/gunfish/gunfish ${GOPATH}/bin 13 | 14 | gen-cert: 15 | test/scripts/gen_test_cert.sh 16 | 17 | test: gen-cert 18 | go test -v ./... 19 | 20 | clean: 21 | rm -f cmd/gunfish/gunfish 22 | rm -f test/server.* 23 | rm -f dist/* 24 | 25 | packages: 26 | goreleaser build --skip-validate --rm-dist 27 | 28 | build: 29 | go build -gcflags="-trimpath=${HOME}" -ldflags="-w" cmd/gunfish/gunfish.go 30 | 31 | tools/%: 32 | go build -gcflags="-trimpath=${HOME}" -ldflags="-w" test/tools/$*/$*.go 33 | 34 | docker-build: # clean packages 35 | mv dist/Gunfish_linux_amd64_v1 dist/Gunfish_linux_amd64 36 | docker buildx build \ 37 | --build-arg VERSION=${GIT_VER} \ 38 | --platform linux/amd64,linux/arm64 \ 39 | -f docker/Dockerfile \ 40 | -t kayac/gunfish:${GIT_VER} \ 41 | -t ghcr.io/kayac/gunfish:${GIT_VER} \ 42 | . 43 | 44 | docker-push: 45 | mv dist/Gunfish_linux_amd64_v1 dist/Gunfish_linux_amd64 46 | docker buildx build \ 47 | --build-arg VERSION=${GIT_VER} \ 48 | --platform linux/amd64,linux/arm64 \ 49 | -f docker/Dockerfile \ 50 | -t kayac/gunfish:${GIT_VER} \ 51 | -t ghcr.io/kayac/gunfish:${GIT_VER} \ 52 | --push \ 53 | . 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/kayac/Gunfish.svg?branch=master)](https://travis-ci.org/kayac/Gunfish) 2 | 3 | # Gunfish 4 | 5 | APNs and FCM provider server on HTTP/2. 6 | 7 | * Gunfish provides the interface as the APNs / FCM provider server. 8 | 9 | ## Overview 10 | 11 | ![overviews 1](https://cloud.githubusercontent.com/assets/13774847/14844813/17035232-0c95-11e6-8307-1d8340978bb7.png) 12 | 13 | [Gunfish slides](http://slides.com/takuyayoshimura-tkyshm/deck-1/fullscreen) 14 | 15 | [Gunfish slides (jp)](http://slides.com/takuyayoshimura-tkyshm/deck/fullscreen) 16 | 17 | ## Install 18 | 19 | ### Binary 20 | 21 | Download the latest binary from [releases](https://github.com/kayac/Gunfish/releases) 22 | 23 | ### Docker images 24 | 25 | [DockerHub](https://hub.docker.com/r/kayac/gunfish/) 26 | 27 | [GitHub Packages](https://github.com/kayac/Gunfish/pkgs/container/gunfish) 28 | 29 | ### Homebrew 30 | 31 | ```console 32 | $ brew tap kayac/tap 33 | $ brew install gunfish 34 | ``` 35 | 36 | ## Quick Started 37 | 38 | ```bash 39 | $ gunfish -c ./config/gunfish.toml -E production 40 | ``` 41 | 42 | ### Commandline Options 43 | 44 | option | required | description 45 | ------------------- |----------|------------------------------------------------------------------------------------------------------------------ 46 | -port | Optional | Port number of Gunfish provider server. Default is `8003`. 47 | -environment, -E | Optional | Default value is `production`. 48 | -conf, -c | Optional | Please specify this option if you want to change `toml` config file path. (default: `/etc/gunfish/config.toml`.) 49 | -log-level | Optional | Set the log level as 'warn', 'info', or 'debug'. 50 | -log-format | Optional | Supports `json` or `ltsv` log formats. 51 | -enable-pprof | Optional | You can set the flag of pprof debug port open. 52 | -output-hook-stdout | Optional | Merge stdout of hook command to gunfish's stdout. 53 | -output-hook-stderr | Optional | Merge stderr of hook command to gunfish's stderr. 54 | 55 | ## API 56 | 57 | ### POST /push/apns 58 | 59 | To delivery remote notifications via APNS to user's devices. 60 | 61 | param | description 62 | --- | --- 63 | Array | Array of JSON dictionary includes 'token' and 'payload' properties 64 | 65 | payload param | description 66 | --- | --- 67 | token | Published token from APNS to user's remote device 68 | payload | APNS notification payload 69 | 70 | Post JSON example: 71 | ```json 72 | [ 73 | { 74 | "payload": { 75 | "aps": { 76 | "alert": "test notification", 77 | "sound": "default" 78 | }, 79 | "option1": "foo", 80 | "option2": "bar" 81 | }, 82 | "token": "apns device token", 83 | "header": { 84 | "apns-id": "your apns id", 85 | "apns-topic": "your app bundle id", 86 | "apns-push-type": "alert" 87 | } 88 | } 89 | ] 90 | ``` 91 | 92 | Response example: 93 | ```json 94 | {"result": "ok"} 95 | ``` 96 | 97 | ### POST /push/fcm **Deprecated** 98 | 99 | This API has been deleted at v0.6.0. Use `/push/fcm/v1` instead. 100 | 101 | See also https://firebase.google.com/docs/cloud-messaging/migrate-v1 . 102 | 103 | ### POST /push/fcm/v1 104 | 105 | To delivery remote notifications via FCM v1 API to user's devices. 106 | 107 | Post body format is equal to it for FCM v1 origin server. 108 | 109 | example: 110 | ```json 111 | { 112 | "message": { 113 | "notification": { 114 | "title": "message_title", 115 | "body": "message_body", 116 | "image": "https://example.com/notification.png" 117 | }, 118 | "data": { 119 | "sample_key": "sample key", 120 | "message": "sample message" 121 | }, 122 | "token": "InstanceIDTokenForDevice" 123 | } 124 | } 125 | ``` 126 | 127 | Response example: 128 | ```json 129 | {"result": "ok"} 130 | ``` 131 | 132 | FCM v1 endpoint allows multiple payloads in a single request body. You can build request body simply concat multiple JSON payloads. Gunfish sends for each that payloads to FCM server. Limitation: Max count of payloads in a request body is 500. 133 | 134 | ### GET /stats/app 135 | 136 | ```json 137 | { 138 | "pid": 57843, 139 | "debug_port": 0, 140 | "uptime": 384, 141 | "start_at": 1492476864, 142 | "su_at": 0, 143 | "period": 309, 144 | "retry_after": 10, 145 | "workers": 8, 146 | "queue_size": 0, 147 | "retry_queue_size": 0, 148 | "workers_queue_size": 0, 149 | "cmdq_queue_size": 0, 150 | "retry_count": 0, 151 | "req_count": 0, 152 | "sent_count": 0, 153 | "err_count": 0, 154 | "certificate_not_after": "2027-04-16T00:53:53Z", 155 | "certificate_expire_until": 315359584 156 | } 157 | ``` 158 | 159 | To get the status of APNS proveder server. 160 | 161 | stats type | description 162 | --- | --- 163 | pid | PID 164 | debug\_port | pprof port number 165 | uptime | uptime 166 | workers | number of workers 167 | start\_at | The time of started 168 | queue\_size | queue size of requests 169 | retry\_queue\_size | queue size for resending notification 170 | workers\_queue\_size | summary of worker's queue size 171 | command\_queue\_size | error hook command queue size 172 | retry\_count | summary of retry count 173 | request\_count | request count to gunfish 174 | err\_count | count of recieving error response 175 | sent\_count | count of sending notification 176 | certificate\_not\_after | certificates minimum expiration date for APNs 177 | certificate\_expire\_until | certificates minimum expiration untile (sec) 178 | 179 | ### GET /stats/profile 180 | 181 | To get the status of go application. 182 | 183 | See detail properties that url: (https://github.com/fukata/golang-stats-api-handler). 184 | 185 | ## Configuration 186 | The Gunfish configuration file is a TOML file that Gunfish server uses to configure itself. 187 | That configuration file should be located at `/etc/gunfish.toml`, and is required to start. 188 | Here is an example configuration: 189 | 190 | ```toml 191 | [provider] 192 | port = 8003 193 | worker_num = 8 194 | queue_size = 2000 195 | max_request_size = 1000 196 | max_connections = 2000 197 | error_hook = "echo -e 'Hello Gunfish at error hook!'" 198 | 199 | [apns] 200 | key_file = "/path/to/server.key" 201 | cert_file = "/path/to/server.crt" 202 | kid = "kid" 203 | team_id = "team_id" 204 | 205 | [fcm_v1] 206 | google_application_credentials = "/path/to/credentials.json" 207 | ``` 208 | 209 | ### [provider] section 210 | 211 | This section is for Gunfish server configuration. 212 | 213 | Parameter | Requirement | Description 214 | ---------------- | ------ | -------------------------------------------------------------------------------------- 215 | port |optional| Listen port number. 216 | worker_num |optional| Number of Gunfish owns http clients. 217 | queue_size |optional| Limit number of posted JSON from the developer application. 218 | max_request_size |optional| Limit size of Posted JSON array. 219 | max_connections |optional| Max connections 220 | error_hook |optional| Error hook command. This command runs when Gunfish catches an error response. 221 | 222 | ### [apns] section 223 | 224 | This section is for APNs provider configuration. 225 | If you don't need to APNs provider, you can skip this section. 226 | 227 | Parameter | Requirement | Description 228 | ---------------- | ------ | -------------------------------------------------------------------------------------- 229 | key_file |required| The key file path. 230 | cert_file |optional| The cert file path. 231 | kid |optional| kid for APNs provider authentication token. 232 | team_id |optional| team id for APNs provider authentication token. 233 | 234 | ### [fcm_v1] section 235 | 236 | This section is for FCM v1 provider configuration. 237 | If you don't need to FCM v1 provider, you can skip this section. 238 | 239 | Parameter | Requirement | Description 240 | ---------------- | ------ | -------------------------------------------------------------------------------------- 241 | google_application_credentials |required| The path to the Google Cloud Platform service account key file. 242 | 243 | ## Error Hook 244 | 245 | Error hook command can get an each error response with JSON format by STDIN. 246 | 247 | for example JSON structure: (>= v0.2.x) 248 | ```json5 249 | // APNs 250 | { 251 | "provider": "apns", 252 | "apns-id": "123e4567-e89b-12d3-a456-42665544000", 253 | "status": 400, 254 | "token": "9fe817acbcef8173fb134d8a80123cba243c8376af83db8caf310daab1f23003", 255 | "reason": "MissingTopic" 256 | } 257 | ``` 258 | 259 | ```json5 260 | // FCM v1 261 | { 262 | "provider": "fcmv1", 263 | "status": 400, 264 | "token": "testToken", 265 | "error": { 266 | "status": "INVALID_ARGUMENT", 267 | "message": "The registration token is not a valid FCM registration token" 268 | } 269 | } 270 | ``` 271 | 272 | ## Graceful Restart 273 | Gunfish supports graceful restarting based on `Start Server`. So, you should start on `start_server` command if you want graceful to restart. 274 | 275 | ```bash 276 | ### install start_server 277 | $ go get github.com/lestrrat/go-server-starter/cmd/start_server 278 | 279 | ### Starts Gunfish with start_server 280 | $ start_server --port 38003 --pid-file gunfish.pid -- ./gunfish -c conf/gunfish.toml 281 | ``` 282 | 283 | ### Test 284 | 285 | ``` 286 | $ make test 287 | ``` 288 | 289 | The following tools are useful to send requests to gunfish for test the following. 290 | - gunfish-cli (send push notification to Gunfish for test) 291 | - apnsmock (APNs mock server) 292 | 293 | ``` 294 | $ make tools/gunfish-cli 295 | $ make tools/apnsmock 296 | ``` 297 | 298 | - send a request example with gunfish-cli 299 | ``` 300 | $ ./gunfish-cli -type apns -count 1 -json-file some.json -verbose 301 | $ ./gunfish-cli -type apns -count 1 -token -apns-topic -options key1=val1,key2=val2 -verbose 302 | ``` 303 | 304 | - start apnsmock server 305 | ``` 306 | $ ./apnsmock -cert-file ./test/server.crt -key-file ./test/server.key -verbose 307 | ``` 308 | 309 | ### Benchmark 310 | 311 | Gunfish repository includes Lua script for the benchmark. You can use wrk command with `err_and_success.lua` script. 312 | 313 | ``` 314 | $ make tools/apnsmock 315 | $ ./apnsmock -cert-file ./test/server.crt -key-file ./test/server.key -verbosea & 316 | $ ./gunfish -c test/gunfish_test.toml -E test 317 | $ wrk2 -t2 -c20 -s bench/scripts/err_and_success.lua -L -R100 http://localhost:38103 318 | ``` 319 | -------------------------------------------------------------------------------- /apns/client.go: -------------------------------------------------------------------------------- 1 | package apns 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "time" 12 | 13 | "github.com/kayac/Gunfish/config" 14 | "golang.org/x/net/http2" 15 | ) 16 | 17 | const ( 18 | // HTTP2 client timeout 19 | HTTP2ClientTimeout = time.Second * 10 20 | ) 21 | 22 | var ClientTransport = func(cert tls.Certificate) *http.Transport { 23 | return &http.Transport{ 24 | TLSClientConfig: &tls.Config{ 25 | Certificates: []tls.Certificate{cert}, 26 | }, 27 | } 28 | } 29 | 30 | type authToken struct { 31 | jwt string 32 | issuedAt time.Time 33 | } 34 | 35 | // Client is apns client 36 | type Client struct { 37 | Host string 38 | client *http.Client 39 | authToken authToken 40 | kid string 41 | teamID string 42 | key []byte 43 | useAuthToken bool 44 | } 45 | 46 | // Send sends notifications to apns 47 | func (ac *Client) Send(n Notification) ([]Result, error) { 48 | req, err := ac.NewRequest(n.Token, &n.Header, n.Payload) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | res, err := ac.client.Do(req) 54 | if err != nil { 55 | return nil, err 56 | } 57 | defer res.Body.Close() 58 | 59 | ret := []Result{ 60 | Result{ 61 | APNsID: res.Header.Get("apns-id"), 62 | StatusCode: res.StatusCode, 63 | Token: n.Token, 64 | }, 65 | } 66 | 67 | if res.StatusCode != http.StatusOK { 68 | var er ErrorResponse 69 | err := json.NewDecoder(res.Body).Decode(&er) 70 | if err != nil { 71 | ret[0].Reason = err.Error() 72 | } else { 73 | ret[0].Reason = er.Reason 74 | } 75 | } 76 | 77 | return ret, nil 78 | } 79 | 80 | // NewRequest creates request for apns 81 | func (ac *Client) NewRequest(token string, h *Header, payload Payload) (*http.Request, error) { 82 | u, err := url.Parse(fmt.Sprintf("%s/3/device/%s", ac.Host, token)) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | data, err := payload.MarshalJSON() 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | nreq, err := http.NewRequest("POST", u.String(), bytes.NewReader(data)) 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | if h != nil { 98 | if h.ApnsID != "" { 99 | nreq.Header.Set("apns-id", h.ApnsID) 100 | } 101 | if h.ApnsExpiration != "" { 102 | nreq.Header.Set("apns-expiration", h.ApnsExpiration) 103 | } 104 | if h.ApnsPriority != "" { 105 | nreq.Header.Set("apns-priority", h.ApnsPriority) 106 | } 107 | if h.ApnsTopic != "" { 108 | nreq.Header.Set("apns-topic", h.ApnsTopic) 109 | } 110 | if h.ApnsPushType != "" { 111 | nreq.Header.Set("apns-push-type", h.ApnsPushType) 112 | } 113 | } 114 | 115 | // APNs provider token authenticaton 116 | if ac.useAuthToken { 117 | // If iat of jwt is more than 1 hour ago, returns 403 InvalidProviderToken. 118 | // So, recreate jwt earlier than 1 hour. 119 | if ac.authToken.issuedAt.Add(time.Hour - time.Minute).Before(time.Now()) { 120 | if err := ac.issueToken(); err != nil { 121 | return nil, err 122 | } 123 | } 124 | nreq.Header.Set("Authorization", "bearer "+ac.authToken.jwt) 125 | } 126 | 127 | return nreq, err 128 | } 129 | 130 | func (ac *Client) issueToken() error { 131 | /* 132 | tokenTime is a unixtime of nearest HH:00:00 or HH:30:00 from now-10 min. 133 | When many Gunfish processes are running in one service, these JWT tokens must be a same value at the same time. 134 | 135 | https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/sending_notification_requests_to_apns 136 | > Update the authentication token no more than once every 20 minutes. 137 | 138 | */ 139 | tokenTime := ((time.Now().Unix() - 600) / 1800) * 1800 140 | 141 | var err error 142 | ac.authToken.jwt, err = CreateJWT(ac.key, ac.kid, ac.teamID, tokenTime) 143 | if err != nil { 144 | return err 145 | } 146 | ac.authToken.issuedAt = time.Unix(tokenTime, 0) 147 | return nil 148 | } 149 | 150 | func NewClient(conf config.SectionApns) (*Client, error) { 151 | useAuthToken := conf.Kid != "" && conf.TeamID != "" 152 | tr := &http.Transport{} 153 | if !useAuthToken { 154 | certPEMBlock, err := os.ReadFile(conf.CertFile) 155 | if err != nil { 156 | return nil, err 157 | } 158 | 159 | keyPEMBlock, err := os.ReadFile(conf.KeyFile) 160 | if err != nil { 161 | return nil, err 162 | } 163 | 164 | cert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock) 165 | if err != nil { 166 | return nil, err 167 | } 168 | tr = ClientTransport(cert) 169 | } 170 | 171 | if err := http2.ConfigureTransport(tr); err != nil { 172 | return nil, err 173 | } 174 | 175 | key, err := os.ReadFile(conf.KeyFile) 176 | if err != nil { 177 | return nil, err 178 | } 179 | 180 | client := &Client{ 181 | Host: conf.Host, 182 | client: &http.Client{ 183 | Timeout: HTTP2ClientTimeout, 184 | Transport: tr, 185 | }, 186 | kid: conf.Kid, 187 | teamID: conf.TeamID, 188 | key: key, 189 | useAuthToken: useAuthToken, 190 | } 191 | if client.useAuthToken { 192 | if err := client.issueToken(); err != nil { 193 | return nil, err 194 | } 195 | } 196 | 197 | return client, nil 198 | } 199 | -------------------------------------------------------------------------------- /apns/error.go: -------------------------------------------------------------------------------- 1 | package apns 2 | 3 | // ErrorResponseCode shows error message of responses from apns 4 | type ErrorResponseCode int 5 | 6 | // https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html 7 | // HTTP/2 Response from APNs Table 8-6 8 | // ErrorMessage const 9 | const ( 10 | PayloadEmpty ErrorResponseCode = iota 11 | PayloadTooLarge 12 | BadTopic 13 | TopicDisallowed 14 | BadExpirationDate 15 | BadPriority 16 | MissingDeviceToken 17 | BadDeviceToken 18 | DeviceTokenNotForTopic 19 | Unregistered 20 | DuplicateHeaders 21 | BadCertificateEnvironment 22 | BadCertificate 23 | Forbidden 24 | BadPath 25 | MethodNotAllowed 26 | TooManyRequests 27 | IdleTimeout 28 | Shutdown 29 | InternalServerError 30 | ServiceUnavailable 31 | MissingTopic 32 | BadCollapseId 33 | BadMessageId 34 | ExpiredProviderToken 35 | InvalidProviderToken 36 | MissingProviderToken 37 | TooManyProviderTokenUpdates 38 | ) 39 | -------------------------------------------------------------------------------- /apns/errorresponsecode_string.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type ErrorResponseCode error.go"; DO NOT EDIT. 2 | 3 | package apns 4 | 5 | import "fmt" 6 | 7 | const _ErrorResponseCode_name = "PayloadEmptyPayloadTooLargeBadTopicTopicDisallowedBadExpirationDateBadPriorityMissingDeviceTokenBadDeviceTokenDeviceTokenNotForTopicUnregisteredDuplicateHeadersBadCertificateEnvironmentBadCertificateForbiddenBadPathMethodNotAllowedTooManyRequestsIdleTimeoutShutdownInternalServerErrorServiceUnavailableMissingTopicBadCollapseIdBadMessageIdExpiredProviderTokenInvalidProviderTokenMissingProviderTokenTooManyProviderTokenUpdates" 8 | 9 | var _ErrorResponseCode_index = [...]uint16{0, 12, 27, 35, 50, 67, 78, 96, 110, 132, 144, 160, 185, 199, 208, 215, 231, 246, 257, 265, 284, 302, 314, 327, 339, 359, 379, 399, 426} 10 | 11 | func (i ErrorResponseCode) String() string { 12 | if i < 0 || i >= ErrorResponseCode(len(_ErrorResponseCode_index)-1) { 13 | return fmt.Sprintf("ErrorResponseCode(%d)", i) 14 | } 15 | return _ErrorResponseCode_name[_ErrorResponseCode_index[i]:_ErrorResponseCode_index[i+1]] 16 | } 17 | -------------------------------------------------------------------------------- /apns/jwt.go: -------------------------------------------------------------------------------- 1 | package apns 2 | 3 | import ( 4 | "bytes" 5 | "crypto" 6 | "crypto/ecdsa" 7 | "crypto/rand" 8 | "crypto/x509" 9 | "encoding/asn1" 10 | "encoding/base64" 11 | "encoding/json" 12 | "encoding/pem" 13 | "io" 14 | "math/big" 15 | ) 16 | 17 | // https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html#//apple_ref/doc/uid/TP40008194-CH11-SW1 18 | 19 | const jwtDefaultGrowSize = 256 20 | 21 | type jwtHeader struct { 22 | Alg string `json:"alg"` 23 | Kid string `json:"kid"` 24 | } 25 | 26 | type jwtClaim struct { 27 | Iss string `json:"iss"` 28 | Iat int64 `json:"iat"` 29 | } 30 | 31 | type ecdsaSignature struct { 32 | R, S *big.Int 33 | } 34 | 35 | func CreateJWT(key []byte, kid string, teamID string, unixtime int64) (string, error) { 36 | var b bytes.Buffer 37 | b.Grow(jwtDefaultGrowSize) 38 | 39 | header := jwtHeader{ 40 | Alg: "ES256", 41 | Kid: kid, 42 | } 43 | headerJSON, err := json.Marshal(&header) 44 | if err != nil { 45 | return "", err 46 | } 47 | if err := writeAsBase64(&b, headerJSON); err != nil { 48 | return "", err 49 | } 50 | b.WriteByte(byte('.')) 51 | 52 | claim := jwtClaim{ 53 | Iss: teamID, 54 | Iat: unixtime, 55 | } 56 | claimJSON, err := json.Marshal(&claim) 57 | if err != nil { 58 | return "", err 59 | } 60 | if err := writeAsBase64(&b, claimJSON); err != nil { 61 | return "", err 62 | } 63 | 64 | sig, err := createSignature(b.Bytes(), key) 65 | if err != nil { 66 | return "", err 67 | } 68 | b.WriteByte(byte('.')) 69 | 70 | if err := writeAsBase64(&b, sig); err != nil { 71 | return "", err 72 | } 73 | 74 | return b.String(), nil 75 | } 76 | 77 | func writeAsBase64(w io.Writer, byt []byte) error { 78 | enc := base64.NewEncoder(base64.RawURLEncoding, w) 79 | defer enc.Close() 80 | 81 | if _, err := enc.Write(byt); err != nil { 82 | return err 83 | } 84 | return nil 85 | } 86 | 87 | func createSignature(payload []byte, key []byte) ([]byte, error) { 88 | h := crypto.SHA256.New() 89 | if _, err := h.Write(payload); err != nil { 90 | return nil, err 91 | } 92 | msg := h.Sum(nil) 93 | 94 | block, _ := pem.Decode(key) 95 | p8key, err := x509.ParsePKCS8PrivateKey(block.Bytes) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | r, s, err := ecdsa.Sign(rand.Reader, p8key.(*ecdsa.PrivateKey), msg) 101 | if err != nil { 102 | return nil, err 103 | } 104 | 105 | sig, err := asn1.Marshal(ecdsaSignature{r, s}) 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | return sig, nil 111 | } 112 | -------------------------------------------------------------------------------- /apns/mock_server.go: -------------------------------------------------------------------------------- 1 | package apns 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "math/rand" 9 | "net" 10 | "net/http" 11 | "strings" 12 | "time" 13 | 14 | "golang.org/x/net/http2" 15 | ) 16 | 17 | const ( 18 | ApplicationJSON = "application/json" 19 | LimitApnsTokenByteSize = 100 // Payload byte size. 20 | ) 21 | 22 | // StartAPNSMockServer starts HTTP/2 server for mock 23 | func StartAPNSMockServer(cert, key string) { 24 | // Create TLSlistener 25 | s := http.Server{} 26 | s.Addr = ":2195" 27 | http2.VerboseLogs = false 28 | http2.ConfigureServer(&s, nil) 29 | tlsConf := &tls.Config{} 30 | if s.TLSConfig != nil { 31 | tlsConf = s.TLSConfig.Clone() 32 | } 33 | if tlsConf.NextProtos == nil { 34 | tlsConf.NextProtos = []string{"http/2.0"} 35 | } 36 | 37 | var err error 38 | tlsConf.Certificates = make([]tls.Certificate, 1) 39 | tlsConf.Certificates[0], err = tls.LoadX509KeyPair(cert, key) 40 | if err != nil { 41 | return 42 | } 43 | 44 | ln, err := net.Listen("tcp", s.Addr) 45 | if err != nil { 46 | return 47 | } 48 | 49 | tlsListener := tls.NewListener(ln, tlsConf) 50 | 51 | http.HandleFunc("/3/device/", func(w http.ResponseWriter, r *http.Request) { 52 | // sets the response time from apns server 53 | time.Sleep(time.Millisecond*200 + time.Millisecond*(time.Duration(rand.Int63n(90))-45)) 54 | 55 | // only allow path which pattern is '/3/device/:token' 56 | splitPath := strings.Split(r.URL.Path, "/") 57 | if len(splitPath) != 4 { 58 | w.WriteHeader(http.StatusNotFound) 59 | fmt.Fprintf(w, "404 Not found") 60 | return 61 | } 62 | 63 | w.Header().Set("Content-Type", ApplicationJSON) 64 | 65 | token := splitPath[len(splitPath)-1] 66 | if len(([]byte(token))) > LimitApnsTokenByteSize { 67 | w.Header().Set("apns-id", "apns-id") 68 | w.WriteHeader(http.StatusBadRequest) 69 | fmt.Fprintf(w, createErrorResponse(BadDeviceToken, http.StatusBadRequest)) 70 | } else if token == "missingtopic" { 71 | // MissingDeviceToken 72 | w.WriteHeader(http.StatusBadRequest) 73 | fmt.Fprintf(w, createErrorResponse(MissingTopic, http.StatusBadRequest)) 74 | } else if token == "status410" { 75 | // If the value in the :status header is 410, the value of this key is 76 | // the last time at which APNs confirmed that the device token was 77 | // no longer valid for the topic. 78 | // 79 | // Stop pushing notifications until the device registers a token with 80 | // a later timestamp with your provider. 81 | w.WriteHeader(http.StatusGone) 82 | fmt.Fprint(w, createErrorResponse(TopicDisallowed, http.StatusGone)) 83 | } else { 84 | w.Header().Set("apns-id", "apns-id") 85 | w.WriteHeader(http.StatusOK) 86 | } 87 | 88 | return 89 | }) 90 | 91 | http.HandleFunc("/stop/", func(w http.ResponseWriter, r *http.Request) { 92 | tlsListener.Close() 93 | return 94 | }) 95 | 96 | log.Fatal(s.Serve(tlsListener)) 97 | } 98 | 99 | func createErrorResponse(ermsg ErrorResponseCode, status int) string { 100 | var er ErrorResponse 101 | if status == http.StatusGone { 102 | er = ErrorResponse{ 103 | Reason: ermsg.String(), 104 | Timestamp: time.Now().Unix(), 105 | } 106 | } else { 107 | er = ErrorResponse{ 108 | Reason: ermsg.String(), 109 | } 110 | } 111 | der, _ := json.Marshal(er) 112 | return string(der) 113 | } 114 | -------------------------------------------------------------------------------- /apns/notification.go: -------------------------------------------------------------------------------- 1 | package apns 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // Request for a http2 client 8 | type Notification struct { 9 | Header Header `json:"header,omitempty"` 10 | Token string `json:"token"` 11 | Payload Payload `json:"payload"` 12 | } 13 | 14 | // Header for apns request 15 | type Header struct { 16 | ApnsID string `json:"apns-id,omitempty"` 17 | ApnsExpiration string `json:"apns-expiration,omitempty"` 18 | ApnsPriority string `json:"apns-priority,omitempty"` 19 | ApnsTopic string `json:"apns-topic,omitempty"` 20 | ApnsPushType string `json:"apns-push-type,omitempty"` 21 | } 22 | 23 | // Payload is Notification Payload 24 | type Payload struct { 25 | *APS `json:"aps"` 26 | Optional map[string]interface{} 27 | } 28 | 29 | // APS is a part of Payload 30 | type APS struct { 31 | Alert interface{} `json:"alert,omitempty"` 32 | Badge int `json:"badge,omitempty"` 33 | Sound string `json:"sound,omitempty"` 34 | ContentAvailable int `json:"content-available,omitempty"` 35 | Category string `json:"category,omitempty"` 36 | ThreadID string `json:"thread-id,omitempty"` 37 | MutableContent int `json:"mutable-content,omitempty"` 38 | TargetContentID string `json:"target-content-id,omitempty"` 39 | } 40 | 41 | // Alert is a part of APS 42 | type Alert struct { 43 | Title string `json:"title,omitempty"` 44 | Body string `json:"body,omitempty"` 45 | TitleLocKey string `json:"title-loc-key,omitempty"` 46 | TitleLocArgs []string `json:"title-loc-args,omitempty"` 47 | ActionLocKey string `json:"action-loc-key,omitempty"` 48 | LocKey string `json:"loc-key,omitempty"` 49 | LocArgs []string `json:"loc-args,omitempty"` 50 | LaunchImage string `json:"launch-image,omitempty"` 51 | } 52 | 53 | // MarshalJSON for Payload struct. 54 | func (p Payload) MarshalJSON() ([]byte, error) { 55 | payloadMap := make(map[string]interface{}) 56 | 57 | payloadMap["aps"] = p.APS 58 | for k, v := range p.Optional { 59 | payloadMap[k] = v 60 | } 61 | 62 | return json.Marshal(payloadMap) 63 | } 64 | 65 | // UnmarshalJSON for Payload struct. 66 | func (p *Payload) UnmarshalJSON(data []byte) error { 67 | var payloadMap map[string]interface{} 68 | p.APS = &APS{} 69 | p.Optional = make(map[string]interface{}) 70 | 71 | if err := json.Unmarshal(data, &payloadMap); err != nil { 72 | return err 73 | } 74 | 75 | apsMap := payloadMap["aps"].(map[string]interface{}) 76 | 77 | for k, v := range apsMap { 78 | switch k { 79 | case "alert": 80 | p.APS.Alert = v 81 | case "badge": 82 | p.APS.Badge = int(v.(float64)) 83 | case "sound": 84 | p.APS.Sound = v.(string) 85 | case "category": 86 | p.APS.Category = v.(string) 87 | case "content-available": 88 | p.APS.ContentAvailable = int(v.(float64)) 89 | } 90 | } 91 | 92 | for k, v := range payloadMap { 93 | if k != "aps" { 94 | p.Optional[k] = v 95 | } 96 | } 97 | 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /apns/notification_test.go: -------------------------------------------------------------------------------- 1 | package apns 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | ) 7 | 8 | var ( 9 | jstr = `{"aps":{"alert":"hoge","badge":1,"sound":"default"},"mio":"hoge","uid":"hoge"}` 10 | ) 11 | 12 | func TestUnmarshal(t *testing.T) { 13 | var payload Payload 14 | 15 | err := json.Unmarshal([]byte(jstr), &payload) 16 | if err != nil { 17 | t.Error(err) 18 | } 19 | 20 | pjson, err := payload.MarshalJSON() 21 | if err != nil { 22 | t.Error(err) 23 | } 24 | 25 | if string(pjson) != jstr { 26 | t.Errorf("Expected %s, but got %s", jstr, pjson) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apns/response.go: -------------------------------------------------------------------------------- 1 | package apns 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | ) 7 | 8 | const Provider = "apns" 9 | 10 | // Response from apns 11 | type Result struct { 12 | APNsID string `json:"apns-id"` 13 | StatusCode int `json:"status"` 14 | Token string `json:"token"` 15 | Reason string `json:"reason"` 16 | } 17 | 18 | func (r Result) Err() error { 19 | if r.StatusCode == 200 && r.Reason == "" { 20 | return nil 21 | } 22 | return errors.New(r.Reason) 23 | } 24 | 25 | func (r Result) RecipientIdentifier() string { 26 | return r.Token 27 | } 28 | 29 | func (r Result) ExtraKeys() []string { 30 | return []string{"apns-id", "reason"} 31 | } 32 | 33 | func (r Result) ExtraValue(key string) string { 34 | switch key { 35 | case "apns-id": 36 | return r.APNsID 37 | case "reason": 38 | return r.Reason 39 | } 40 | return "" 41 | } 42 | 43 | func (r Result) Status() int { 44 | return r.StatusCode 45 | } 46 | 47 | func (r Result) Provider() string { 48 | return Provider 49 | } 50 | 51 | func (r Result) MarshalJSON() ([]byte, error) { 52 | type Alias Result 53 | return json.Marshal(struct { 54 | Provider string `json:"provider"` 55 | Alias 56 | }{ 57 | Provider: Provider, 58 | Alias: (Alias)(r), 59 | }) 60 | } 61 | 62 | type ErrorResponse struct { 63 | Reason string `json:"reason"` 64 | Timestamp int64 `json:"timestamp"` 65 | } 66 | -------------------------------------------------------------------------------- /apns/response_test.go: -------------------------------------------------------------------------------- 1 | package apns 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestResult(t *testing.T) { 8 | result := Result{ 9 | APNsID: "xxxx", 10 | StatusCode: 400, 11 | Token: "foo", 12 | Reason: "BadDeviceToken", 13 | } 14 | b, err := result.MarshalJSON() 15 | if err != nil { 16 | t.Error(err) 17 | } 18 | t.Logf("%s", string(b)) 19 | if string(b) != `{"provider":"apns","apns-id":"xxxx","status":400,"token":"foo","reason":"BadDeviceToken"}` { 20 | t.Errorf("unexpected encoded json: %s", string(b)) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /bench/scripts/err_and_success.lua: -------------------------------------------------------------------------------- 1 | -- generates payload 2 | local random = math.random 3 | local function gen_apns_token() 4 | local template ='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' 5 | return string.gsub(template, '[x]', function (c) 6 | math.randomseed(os.clock()*100000000000) 7 | local v = (c == 'x') and random(0, 0xf) or random(8, 0xb) 8 | return string.format('%x', v) 9 | end) 10 | end 11 | 12 | local function gen_error_token() 13 | math.randomseed(os.clock()*100000000000) 14 | local err_type =random(1,3) 15 | if err_type == 1 then 16 | return "missingtopic" 17 | elseif err_type == 2 then 18 | return "baddevicetoken" 19 | else 20 | return "unregistered" 21 | end 22 | end 23 | 24 | -- paylaod 25 | payload = function(is_error) 26 | if is_error == 0 then 27 | token = gen_apns_token() 28 | else 29 | token = gen_error_token() 30 | end 31 | return '{"token":"'.. token ..'", "payload": {"aps":{"alert":"hoge","badge":1,"sound":"default"},"mio":"hoge","uid":"hoge"}}' 32 | end 33 | 34 | -- create bulk. 50:1 = success:error 35 | payloads = "[" .. payload(0) 36 | for i = 2, 200 do 37 | if i % 50 == 0 then 38 | payloads = payloads .. "," .. payload(1) 39 | else 40 | payloads = payloads .. "," .. payload(0) 41 | end 42 | end 43 | payloads = payloads .. "]" 44 | 45 | -- POST 46 | wrk.method = "POST" 47 | wrk.body = payloads 48 | wrk.port = 38103 49 | wrk.path = "/push/apns" 50 | wrk.headers["Content-Type"] = "application/json" 51 | -------------------------------------------------------------------------------- /bench/scripts/post.lua: -------------------------------------------------------------------------------- 1 | -- generate apn token function 2 | local random = math.random 3 | local function gen_apns_token() 4 | local template ='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' 5 | return string.gsub(template, '[x]', function (c) 6 | math.randomseed(os.clock()*100000000000) 7 | local v = (c == 'x') and random(0, 0xf) or random(8, 0xb) 8 | return string.format('%x', v) 9 | end) 10 | end 11 | 12 | -- paylaod 13 | payload = function() 14 | return '{"token":"'.. gen_apns_token() ..'", "payload": {"aps":{"alert":"hoge","badge":1,"sound":"default"},"mio":"hoge","uid":"hoge"}}' 15 | end 16 | payloads = "[" .. payload() 17 | 18 | -- create bulk 19 | for i = 2, 30 do 20 | payloads = payloads .. "," .. payload() 21 | end 22 | payloads = payloads .. "]" 23 | 24 | wrk.method = "POST" 25 | wrk.body = payloads 26 | wrk.port = 38103 27 | wrk.path = "/push/apns" 28 | wrk.headers["Content-Type"] = "application/json" 29 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package gunfish 2 | 3 | // Client interface for fcm and apns client 4 | type Client interface { 5 | Send(Notification) ([]Result, error) 6 | } 7 | -------------------------------------------------------------------------------- /cmd/apnsmock/apnsmock.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | 6 | "github.com/kayac/Gunfish/config" 7 | "github.com/kayac/Gunfish/apns" 8 | ) 9 | 10 | func main() { 11 | var ( 12 | confFile string 13 | ) 14 | flag.StringVar(&confFile, "c", "./test/gunfish_test.toml", "config file") 15 | flag.Parse() 16 | 17 | config, err := config.LoadConfig(confFile) 18 | if err != nil { 19 | return 20 | } 21 | apns.StartAPNSMockServer(config.Apns.CertFile, config.Apns.KeyFile) 22 | } 23 | -------------------------------------------------------------------------------- /cmd/gunfish/gunfish.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "flag" 6 | "fmt" 7 | "net" 8 | "net/http" 9 | "net/http/pprof" 10 | "os" 11 | "runtime" 12 | "strconv" 13 | 14 | gunfish "github.com/kayac/Gunfish" 15 | "github.com/kayac/Gunfish/apns" 16 | "github.com/kayac/Gunfish/config" 17 | "github.com/sirupsen/logrus" 18 | ) 19 | 20 | var version string 21 | 22 | func main() { 23 | var ( 24 | confPath string 25 | environment string 26 | logFormat string 27 | port int 28 | enablePprof bool 29 | showVersion bool 30 | logLevel string 31 | ) 32 | 33 | flag.StringVar(&confPath, "config", "/etc/gunfish/config.toml", "specify config file.") 34 | flag.StringVar(&confPath, "c", "/etc/gunfish/config.toml", "specify config file.") 35 | flag.StringVar(&environment, "environment", "production", "APNS environment. (production, development, or test)") 36 | flag.StringVar(&environment, "E", "production", "APNS environment. (production, development, or test)") 37 | flag.IntVar(&port, "port", 0, "Gunfish port number (range 1024-65535).") 38 | flag.StringVar(&logFormat, "log-format", "", "specifies the log format: ltsv or json.") 39 | flag.BoolVar(&enablePprof, "enable-pprof", false, ".") 40 | flag.BoolVar(&showVersion, "v", false, "show version number.") 41 | flag.BoolVar(&showVersion, "version", false, "show version number.") 42 | flag.BoolVar(&gunfish.OutputHookStdout, "output-hook-stdout", false, "merge stdout of hook command to gunfish's stdout") 43 | flag.BoolVar(&gunfish.OutputHookStderr, "output-hook-stderr", false, "merge stderr of hook command to gunfish's stderr") 44 | 45 | flag.StringVar(&logLevel, "log-level", "info", "set the log level (debug, warn, info)") 46 | flag.Parse() 47 | 48 | if showVersion { 49 | fmt.Printf("Compiler: %s %s\n", runtime.Compiler, runtime.Version()) 50 | fmt.Printf("Gunfish version: %s\n", version) 51 | return 52 | } 53 | 54 | initLogrus(logFormat, logLevel) 55 | 56 | c, err := config.LoadConfig(confPath) 57 | if err != nil { 58 | logrus.Error(err) 59 | os.Exit(1) 60 | } 61 | 62 | c.Provider.DebugPort = 0 63 | if port != 0 { 64 | c.Provider.Port = port // Default port number 65 | } 66 | 67 | var env gunfish.Environment 68 | switch environment { 69 | case "production": 70 | env = gunfish.Production 71 | case "development": 72 | env = gunfish.Development 73 | case "test": 74 | env = gunfish.Test 75 | apns.ClientTransport = func(cert tls.Certificate) *http.Transport { 76 | return &http.Transport{ 77 | TLSClientConfig: &tls.Config{ 78 | InsecureSkipVerify: true, 79 | Certificates: []tls.Certificate{cert}, 80 | }, 81 | } 82 | } 83 | default: 84 | logrus.Errorf("Unknown environment: %s. Please look at help.", environment) 85 | os.Exit(1) 86 | } 87 | 88 | // for profiling 89 | if enablePprof { 90 | mux := http.NewServeMux() 91 | l, err := net.Listen("tcp", "localhost:0") 92 | if err != nil { 93 | logrus.Fatal(err) 94 | } 95 | debugAddr := l.Addr().String() 96 | _, p, err := net.SplitHostPort(debugAddr) 97 | if err != nil { 98 | logrus.Fatal(err) 99 | } 100 | dp, err := strconv.Atoi(p) 101 | if err != nil { 102 | logrus.Fatal(err) 103 | } 104 | logrus.Infof("Debug port (pprof) is %d.", dp) 105 | c.Provider.DebugPort = dp 106 | 107 | if enablePprof { 108 | mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) 109 | mux.HandleFunc("/debug/pprof/profile", pprof.Profile) 110 | mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) 111 | mux.HandleFunc("/debug/pprof/", pprof.Index) 112 | } 113 | 114 | go func() { 115 | logrus.Fatal(http.Serve(l, mux)) 116 | }() 117 | } 118 | 119 | gunfish.StartServer(c, env) 120 | } 121 | 122 | func initLogrus(format string, logLevel string) { 123 | switch format { 124 | case "ltsv": 125 | logrus.SetFormatter(&gunfish.LtsvFormatter{}) 126 | case "json": 127 | logrus.SetFormatter(&logrus.JSONFormatter{}) 128 | } 129 | 130 | lvl, err := logrus.ParseLevel(logLevel) 131 | if err != nil { 132 | lvl = logrus.InfoLevel 133 | } 134 | 135 | logrus.SetLevel(lvl) 136 | } 137 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "encoding/json" 8 | "fmt" 9 | "os" 10 | "time" 11 | 12 | "github.com/kayac/Gunfish/fcmv1" 13 | goconf "github.com/kayac/go-config" 14 | "github.com/pkg/errors" 15 | "golang.org/x/oauth2" 16 | "golang.org/x/oauth2/google" 17 | ) 18 | 19 | // Limit values 20 | const ( 21 | MaxWorkerNum = 119 // Maximum of worker number 22 | MinWorkerNum = 1 // Minimum of worker number 23 | MaxQueueSize = 40960 // Maximum queue size. 24 | MinQueueSize = 128 // Minimum Queue size. 25 | MaxRequestSize = 5000 // Maximum of requset count. 26 | MinRequestSize = 1 // Minimum of request size. 27 | LimitApnsTokenByteSize = 100 // Payload byte size. 28 | ) 29 | 30 | const ( 31 | // Default array size of posted data. If not configures at file, this value is set. 32 | DefaultRequestQueueSize = 2000 33 | // Default port number of provider server 34 | DefaultPort = 8003 35 | // Default supervisor's queue size. If not configures at file, this value is set. 36 | DefaultQueueSize = 1000 37 | ) 38 | 39 | // Config is the configure of an APNS provider server 40 | type Config struct { 41 | Apns SectionApns `toml:"apns"` 42 | Provider SectionProvider `toml:"provider"` 43 | FCM SectionFCM `toml:"fcm"` 44 | FCMv1 SectionFCMv1 `toml:"fcm_v1"` 45 | } 46 | 47 | // SectionProvider is Gunfish provider configuration 48 | type SectionProvider struct { 49 | WorkerNum int `toml:"worker_num"` 50 | QueueSize int `toml:"queue_size"` 51 | RequestQueueSize int `toml:"max_request_size"` 52 | Port int `toml:"port"` 53 | DebugPort int 54 | MaxConnections int `toml:"max_connections"` 55 | ErrorHook string `toml:"error_hook"` 56 | } 57 | 58 | // SectionApns is the configure which is loaded from gunfish.toml 59 | type SectionApns struct { 60 | Host string 61 | CertFile string `toml:"cert_file"` 62 | KeyFile string `toml:"key_file"` 63 | Kid string `toml:"kid"` 64 | TeamID string `toml:"team_id"` 65 | CertificateNotAfter time.Time 66 | Enabled bool 67 | } 68 | 69 | // SectionFCM is the configuration of fcm 70 | type SectionFCM struct { 71 | APIKey string `toml:"api_key"` 72 | Enabled bool 73 | } 74 | 75 | // SectionFCMv1 is the configuration of fcm/v1 76 | type SectionFCMv1 struct { 77 | GoogleApplicationCredentials string `toml:"google_application_credentials"` 78 | Enabled bool 79 | ProjectID string 80 | TokenSource oauth2.TokenSource 81 | Endpoint string 82 | } 83 | 84 | // DefaultLoadConfig loads default /etc/gunfish.toml 85 | func DefaultLoadConfig() (Config, error) { 86 | return LoadConfig("/etc/gunfish/gunfish.toml") 87 | } 88 | 89 | // LoadConfig reads gunfish.toml and loads on ApnsConfig struct 90 | func LoadConfig(fn string) (Config, error) { 91 | var config Config 92 | 93 | if err := goconf.LoadWithEnvTOML(&config, fn); err != nil { 94 | return config, err 95 | } 96 | 97 | // if not set parameters, set default value. 98 | if config.Provider.RequestQueueSize == 0 { 99 | config.Provider.RequestQueueSize = DefaultRequestQueueSize 100 | } 101 | 102 | if config.Provider.QueueSize == 0 { 103 | config.Provider.QueueSize = DefaultQueueSize 104 | } 105 | 106 | if config.Provider.Port == 0 { 107 | config.Provider.Port = DefaultPort 108 | } 109 | 110 | // validates config parameters 111 | if err := (&config).validateConfig(); err != nil { 112 | return config, errors.Wrap(err, "validate config failed") 113 | } 114 | 115 | return config, nil 116 | } 117 | 118 | func (c *Config) validateConfig() error { 119 | if err := c.validateConfigProvider(); err != nil { 120 | return errors.Wrap(err, "[provider]") 121 | } 122 | if (c.Apns.CertFile != "" && c.Apns.KeyFile != "") || (c.Apns.TeamID != "" && c.Apns.Kid != "") { 123 | c.Apns.Enabled = true 124 | if err := c.validateConfigAPNs(); err != nil { 125 | return errors.Wrap(err, "[apns]") 126 | } 127 | } 128 | if c.FCM.APIKey != "" { 129 | return errors.New("[fcm] legacy is not supported anymore. Please use [fcm_v1]") 130 | } 131 | if c.FCMv1.GoogleApplicationCredentials != "" { 132 | c.FCMv1.Enabled = true 133 | if err := c.validateConfigFCMv1(); err != nil { 134 | return errors.Wrap(err, "[fcm_v1]") 135 | } 136 | } 137 | return nil 138 | } 139 | 140 | func (c *Config) validateConfigProvider() error { 141 | if c.Provider.RequestQueueSize < MinRequestSize || c.Provider.RequestQueueSize > MaxRequestSize { 142 | return fmt.Errorf("MaxRequestSize was out of available range: %d. (%d-%d)", c.Provider.RequestQueueSize, 143 | MinRequestSize, MaxRequestSize) 144 | } 145 | 146 | if c.Provider.QueueSize < MinQueueSize || c.Provider.QueueSize > MaxQueueSize { 147 | return fmt.Errorf("QueueSize was out of available range: %d. (%d-%d)", c.Provider.QueueSize, 148 | MinQueueSize, MaxQueueSize) 149 | } 150 | 151 | if c.Provider.WorkerNum < MinWorkerNum || c.Provider.WorkerNum > MaxWorkerNum { 152 | return fmt.Errorf("WorkerNum was out of available range: %d. (%d-%d)", c.Provider.WorkerNum, 153 | MinWorkerNum, MaxWorkerNum) 154 | } 155 | 156 | return nil 157 | } 158 | 159 | func (c *Config) validateConfigFCMv1() error { 160 | b, err := os.ReadFile(c.FCMv1.GoogleApplicationCredentials) 161 | if err != nil { 162 | return err 163 | } 164 | serviceAccount := make(map[string]string) 165 | if err := json.Unmarshal(b, &serviceAccount); err != nil { 166 | return err 167 | } 168 | if projectID := serviceAccount["project_id"]; projectID != "" { 169 | c.FCMv1.ProjectID = projectID 170 | } else { 171 | return fmt.Errorf("invalid service account json: %s project_id is not defined", c.FCMv1.GoogleApplicationCredentials) 172 | } 173 | 174 | conf, err := google.JWTConfigFromJSON(b, fcmv1.Scope) 175 | if err != nil { 176 | return err 177 | } 178 | c.FCMv1.TokenSource = conf.TokenSource(context.Background()) 179 | 180 | return err 181 | } 182 | 183 | func (c *Config) validateConfigAPNs() error { 184 | if c.Apns.CertFile != "" && c.Apns.KeyFile != "" { 185 | // check certificate files and expiration 186 | cert, err := tls.LoadX509KeyPair(c.Apns.CertFile, c.Apns.KeyFile) 187 | if err != nil { 188 | return fmt.Errorf("Invalid certificate pair for APNS: %s", err) 189 | } 190 | now := time.Now() 191 | for _, _ct := range cert.Certificate { 192 | ct, err := x509.ParseCertificate(_ct) 193 | if err != nil { 194 | return fmt.Errorf("Cannot parse X509 certificate") 195 | } 196 | if now.Before(ct.NotBefore) || now.After(ct.NotAfter) { 197 | return fmt.Errorf("Certificate is expired. Subject: %s, NotBefore: %s, NotAfter: %s", ct.Subject, ct.NotBefore, ct.NotAfter) 198 | } 199 | if c.Apns.CertificateNotAfter.IsZero() || c.Apns.CertificateNotAfter.Before(ct.NotAfter) { 200 | // hold minimum not after 201 | c.Apns.CertificateNotAfter = ct.NotAfter 202 | } 203 | } 204 | } 205 | return nil 206 | } 207 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestLoadTomlConfigFile(t *testing.T) { 9 | if err := os.Setenv("TEST_GUNFISH_HOOK_CMD", "cat | grep test"); err != nil { 10 | t.Error(err) 11 | } 12 | 13 | c, err := LoadConfig("../test/gunfish_test.toml") 14 | if err != nil { 15 | t.Error(err) 16 | } 17 | 18 | if g, w := c.Provider.ErrorHook, "cat | grep test"; g != w { 19 | t.Errorf("not match error hook: got %s want %s", g, w) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /config/gunfish.toml.example: -------------------------------------------------------------------------------- 1 | [provider] 2 | port = 8003 3 | worker_num = 8 4 | queue_size = 2000 5 | max_request_size = 1000 6 | max_connections = 2000 7 | error_hook = "jq . >> error.hook" 8 | 9 | [apns] 10 | skip_insecure = true 11 | key_file = "test/server.key" 12 | cert_file = "test/server.crt" 13 | 14 | [fcm] 15 | api_key = "FCM_API_KEY" 16 | -------------------------------------------------------------------------------- /const.go: -------------------------------------------------------------------------------- 1 | package gunfish 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Default values 8 | const ( 9 | // SendRetryCount is the threashold which is resend count. 10 | SendRetryCount = 10 11 | // RetryWaitTime is periodical time to retrieve notifications from retry queue to resend 12 | RetryWaitTime = time.Millisecond * 500 13 | // RetryOnceCount is the number of sending notification at once. 14 | RetryOnceCount = 1000 15 | // multiplicity of sending notifications. 16 | SenderNum = 20 17 | RequestPerSec = 2000 18 | 19 | // About the average time of response from apns. That value is not accurate 20 | // because that is defined heuristically in Japan. 21 | AverageResponseTime = time.Millisecond * 150 22 | // Minimum RetryAfter time (seconds). 23 | RetryAfterSecond = time.Second * 10 24 | // Gunfish returns RetryAfter header based on 'Exponential Backoff'. Therefore, 25 | // that defines the wait time threshold so as not to wait too long. 26 | ResetRetryAfterSecond = time.Second * 60 27 | // FlowRateInterval is the designed value to enable to delivery notifications 28 | // for that value seconds. Gunfish is designed as to ensure to delivery 29 | // notifications for 10 seconds. 30 | FlowRateInterval = time.Second * 10 31 | // Default flow rate as notification requests per sec (req/sec). 32 | DefaultFlowRatePerSec = 2000 33 | // Wait millisecond interval when to shutdown. 34 | ShutdownWaitTime = time.Millisecond * 10 35 | // That is the count while request counter is 0 in the 'ShutdownWaitTime' period. 36 | RestartWaitCount = 50 37 | ) 38 | 39 | // Apns endpoints 40 | const ( 41 | DevServer = "https://api.development.push.apple.com" 42 | ProdServer = "https://api.push.apple.com" 43 | MockServer = "https://localhost:2195" 44 | ) 45 | 46 | // Supports Content-Type 47 | const ( 48 | ApplicationJSON = "application/json" 49 | ApplicationXW3FormURLEncoded = "application/x-www-form-urlencoded" 50 | ) 51 | 52 | // Environment struct 53 | type Environment int 54 | 55 | // Executed environment 56 | const ( 57 | Production Environment = iota 58 | Development 59 | Test 60 | Disable 61 | ) 62 | 63 | // Alert fields mapping 64 | var ( 65 | AlertKeyToField = map[string]string{ 66 | "title": "Title", 67 | "body": "Body", 68 | "title-loc-key": "TitleLocKey", 69 | "title-loc-args": "TitleLocArgs", 70 | "action-loc-key": "ActionLocKey", 71 | "loc-key": "LocKey", 72 | "loc-args": "LocArgs", 73 | "launch-image": "LaunchImage", 74 | } 75 | ) 76 | 77 | var ( 78 | OutputHookStdout bool 79 | OutputHookStderr bool 80 | ) 81 | 82 | var RetryBackoff = true 83 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.18 2 | LABEL maintainer "FUJIWARA Shunichiro " 3 | 4 | ARG VERSION 5 | ARG TARGETARCH 6 | ADD dist/Gunfish_linux_${TARGETARCH}/gunfish /usr/local/bin/gunfish 7 | EXPOSE 8003 8 | WORKDIR /opt/gunfish 9 | 10 | ENTRYPOINT ["/usr/local/bin/gunfish"] 11 | -------------------------------------------------------------------------------- /docker/hooks/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | docker build --build-arg version=${DOCKER_TAG} -t ${IMAGE_NAME} . 3 | -------------------------------------------------------------------------------- /environment_string.go: -------------------------------------------------------------------------------- 1 | // generated by stringer -type Environment const.go; DO NOT EDIT 2 | 3 | package gunfish 4 | 5 | import "fmt" 6 | 7 | const _Environment_name = "ProductionDevelopmentTestDisable" 8 | 9 | var _Environment_index = [...]uint8{0, 10, 21, 25, 32} 10 | 11 | func (i Environment) String() string { 12 | if i < 0 || i >= Environment(len(_Environment_index)-1) { 13 | return fmt.Sprintf("Environment(%d)", i) 14 | } 15 | return _Environment_name[_Environment_index[i]:_Environment_index[i+1]] 16 | } 17 | -------------------------------------------------------------------------------- /fcmv1/client.go: -------------------------------------------------------------------------------- 1 | package fcmv1 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/url" 8 | "path" 9 | "time" 10 | 11 | "golang.org/x/oauth2" 12 | ) 13 | 14 | // fcm v1 Client const variables 15 | const ( 16 | DefaultFCMEndpoint = "https://fcm.googleapis.com/v1/projects" 17 | Scope = "https://www.googleapis.com/auth/firebase.messaging" 18 | ClientTimeout = time.Second * 10 19 | ) 20 | 21 | // Client is FCM v1 client 22 | type Client struct { 23 | endpoint *url.URL 24 | Client *http.Client 25 | tokenSource oauth2.TokenSource 26 | } 27 | 28 | // Send sends notifications to fcm 29 | func (c *Client) Send(p Payload) ([]Result, error) { 30 | req, err := c.NewRequest(p) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | res, err := c.Client.Do(req) 36 | if err != nil { 37 | return nil, err 38 | } 39 | defer res.Body.Close() 40 | 41 | var body ResponseBody 42 | dec := json.NewDecoder(res.Body) 43 | if err = dec.Decode(&body); err != nil { 44 | return nil, NewError(res.StatusCode, err.Error()) 45 | } 46 | 47 | if body.Error == nil && body.Name != "" { 48 | return []Result{ 49 | { 50 | StatusCode: res.StatusCode, 51 | Token: p.Message.Token, 52 | }, 53 | }, nil 54 | } else if body.Error != nil { 55 | return []Result{ 56 | { 57 | StatusCode: res.StatusCode, 58 | Token: p.Message.Token, 59 | Error: body.Error, 60 | }, 61 | }, nil 62 | } 63 | 64 | return nil, NewError(res.StatusCode, "unexpected response") 65 | } 66 | 67 | // NewRequest creates request for fcm 68 | func (c *Client) NewRequest(p Payload) (*http.Request, error) { 69 | data, err := json.Marshal(p) 70 | if err != nil { 71 | return nil, err 72 | } 73 | var bearer string 74 | if ts := c.tokenSource; ts != nil { 75 | token, err := c.tokenSource.Token() 76 | if err != nil { 77 | return nil, err 78 | } 79 | bearer = token.AccessToken 80 | } else { 81 | bearer = p.Message.Token 82 | } 83 | req, err := http.NewRequest("POST", c.endpoint.String(), bytes.NewReader(data)) 84 | if err != nil { 85 | return nil, err 86 | } 87 | req.Header.Set("Authorization", "Bearer "+bearer) 88 | req.Header.Set("Content-Type", "application/json") 89 | 90 | return req, nil 91 | } 92 | 93 | // NewClient establishes a http connection with fcm v1 94 | func NewClient(tokenSource oauth2.TokenSource, projectID string, endpoint string, timeout time.Duration) (*Client, error) { 95 | client := &http.Client{ 96 | Timeout: timeout, 97 | } 98 | c := &Client{ 99 | Client: client, 100 | tokenSource: tokenSource, 101 | } 102 | 103 | if endpoint == "" { 104 | endpoint = DefaultFCMEndpoint 105 | } 106 | ep, err := url.Parse(endpoint) 107 | if err != nil { 108 | return nil, err 109 | } 110 | ep.Path = path.Join(ep.Path, projectID, "messages:send") 111 | c.endpoint = ep 112 | 113 | return c, nil 114 | } 115 | -------------------------------------------------------------------------------- /fcmv1/error.go: -------------------------------------------------------------------------------- 1 | package fcmv1 2 | 3 | import "fmt" 4 | 5 | type FCMErrorResponseCode int 6 | 7 | // Error const variables 8 | const ( 9 | InvalidArgument = "INVALID_ARGUMENT" 10 | Unregistered = "UNREGISTERED" 11 | NotFound = "NOT_FOUND" 12 | Internal = "INTERNAL" 13 | Unavailable = "UNAVAILABLE" 14 | QuotaExceeded = "QUOTA_EXCEEDED" 15 | ) 16 | 17 | type Error struct { 18 | StatusCode int 19 | Reason string 20 | } 21 | 22 | func (e Error) Error() string { 23 | return fmt.Sprintf("status:%d reason:%s", e.StatusCode, e.Reason) 24 | } 25 | 26 | func NewError(s int, r string) Error { 27 | return Error{ 28 | StatusCode: s, 29 | Reason: r, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /fcmv1/request.go: -------------------------------------------------------------------------------- 1 | package fcmv1 2 | 3 | import ( 4 | "firebase.google.com/go/messaging" 5 | ) 6 | 7 | // Payload for fcm v1 8 | type Payload struct { 9 | Message messaging.Message `json:"message"` 10 | } 11 | 12 | // MaxBulkRequests represens max count of request payloads in a request body. 13 | const MaxBulkRequests = 500 14 | -------------------------------------------------------------------------------- /fcmv1/request_test.go: -------------------------------------------------------------------------------- 1 | package fcmv1 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | "firebase.google.com/go/messaging" 9 | ) 10 | 11 | func TestUnmarshalPayload(t *testing.T) { 12 | var p Payload 13 | if err := json.Unmarshal([]byte(buildPayloadJSON()), &p); err != nil { 14 | t.Error(err) 15 | } 16 | 17 | if diff := cmp.Diff(p, buildPayload()); diff != "" { 18 | t.Errorf("mismatch decoded payload: diff: %s", diff) 19 | } 20 | } 21 | 22 | func TestMarshalPayload(t *testing.T) { 23 | p := buildPayload() 24 | output, err := json.Marshal(p) 25 | if err != nil { 26 | t.Error(err) 27 | } 28 | 29 | expected := `{"message":{"data":{"message":"sample message","sample_key":"sample key"},"notification":{"title":"message_title","body":"message_body","image":"https://example.com/notification.png"},"token":"testToken"}}` 30 | 31 | if string(output) != expected { 32 | t.Errorf("should be expected json: got=%s, expected=%s", output, expected) 33 | } 34 | } 35 | 36 | func buildPayload() Payload { 37 | dataMap := map[string]string{ 38 | "sample_key": "sample key", 39 | "message": "sample message", 40 | } 41 | 42 | return Payload{ 43 | Message: messaging.Message{ 44 | Notification: &messaging.Notification{ 45 | Title: "message_title", 46 | Body: "message_body", 47 | ImageURL: "https://example.com/notification.png", 48 | }, 49 | Data: dataMap, 50 | Token: "testToken", 51 | }, 52 | } 53 | } 54 | 55 | func buildPayloadJSON() string { 56 | return `{ 57 | "message": { 58 | "notification": { 59 | "title": "message_title", 60 | "body": "message_body", 61 | "image": "https://example.com/notification.png" 62 | }, 63 | "data": { 64 | "sample_key": "sample key", 65 | "message": "sample message" 66 | }, 67 | "token": "testToken" 68 | } 69 | }` 70 | } 71 | -------------------------------------------------------------------------------- /fcmv1/response.go: -------------------------------------------------------------------------------- 1 | package fcmv1 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | ) 7 | 8 | const Provider = "fcmv1" 9 | 10 | // ResponseBody fcm response body 11 | type ResponseBody struct { 12 | Name string `json:"name,omitempty"` 13 | Error *FCMError `json:"error,omitempty"` 14 | } 15 | 16 | type FCMError struct { 17 | Status string `json:"status"` 18 | Message string `json:"message,omitempty"` 19 | Details []Detail `json:"details,omitempty"` 20 | } 21 | 22 | type Detail struct { 23 | Type string `json:"@type"` 24 | ErrorCode string `json:"errorCode,omitempty"` 25 | } 26 | 27 | // Result is the status of a processed FCMResponse 28 | type Result struct { 29 | StatusCode int `json:"status,omitempty"` 30 | Token string `json:"token,omitempty"` 31 | Error *FCMError `json:"error,omitempty"` 32 | } 33 | 34 | func (r Result) Err() error { 35 | if r.Error != nil { 36 | return errors.New(r.Error.Status) 37 | } 38 | return nil 39 | } 40 | 41 | func (r Result) Status() int { 42 | return r.StatusCode 43 | } 44 | 45 | func (r Result) RecipientIdentifier() string { 46 | return r.Token 47 | } 48 | 49 | func (r Result) ExtraKeys() []string { 50 | return []string{"message"} 51 | } 52 | 53 | func (r Result) Provider() string { 54 | return Provider 55 | } 56 | 57 | func (r Result) ExtraValue(key string) string { 58 | switch key { 59 | case "message": 60 | if r.Error != nil { 61 | return r.Error.Message 62 | } 63 | } 64 | return "" 65 | } 66 | 67 | func (r Result) MarshalJSON() ([]byte, error) { 68 | type Alias Result 69 | return json.Marshal(struct { 70 | Provider string `json:"provider"` 71 | Alias 72 | }{ 73 | Provider: Provider, 74 | Alias: (Alias)(r), 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /fcmv1/response_test.go: -------------------------------------------------------------------------------- 1 | package fcmv1 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | ) 9 | 10 | func TestUnmarshalResponse(t *testing.T) { 11 | var r ResponseBody 12 | if err := json.Unmarshal([]byte(buildResponseBodyJSON()), &r); err != nil { 13 | t.Error(err) 14 | } 15 | 16 | if diff := cmp.Diff(r, buildResponseBody()); diff != "" { 17 | t.Errorf("mismatch decoded payload diff: %s", diff) 18 | } 19 | } 20 | 21 | func TestMarshalResponse(t *testing.T) { 22 | p := buildResponseBody() 23 | output, err := json.Marshal(p) 24 | if err != nil { 25 | t.Error(err) 26 | } 27 | 28 | expected := `{"error":{"status":"INVALID_ARGUMENT","message":"The registration token is not a valid FCM registration token","details":[{"@type":"type.googleapis.com/google.firebase.fcm.v1.FcmError","errorCode":"INVALID_ARGUMENT"},{"@type":"type.googleapis.com/google.rpc.BadRequest"}]}}` 29 | 30 | if string(output) != expected { 31 | t.Errorf("mismatch decoded response:\ngot=%s\nexpected=%s", output, expected) 32 | } 33 | } 34 | 35 | func buildResponseBody() ResponseBody { 36 | return ResponseBody{ 37 | Error: &FCMError{ 38 | Message: "The registration token is not a valid FCM registration token", 39 | Status: InvalidArgument, 40 | Details: []Detail{ 41 | Detail{ 42 | Type: "type.googleapis.com/google.firebase.fcm.v1.FcmError", 43 | ErrorCode: InvalidArgument, 44 | }, 45 | Detail{ 46 | Type: "type.googleapis.com/google.rpc.BadRequest", 47 | }, 48 | }, 49 | }, 50 | } 51 | } 52 | 53 | func buildResponseBodyJSON() string { 54 | return `{ 55 | "error": { 56 | "code": 400, 57 | "message": "The registration token is not a valid FCM registration token", 58 | "status": "INVALID_ARGUMENT", 59 | "details": [ 60 | { 61 | "@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", 62 | "errorCode": "INVALID_ARGUMENT" 63 | }, 64 | { 65 | "@type": "type.googleapis.com/google.rpc.BadRequest", 66 | "fieldViolations": [ 67 | { 68 | "field": "message.token", 69 | "description": "The registration token is not a valid FCM registration token" 70 | } 71 | ] 72 | } 73 | ] 74 | } 75 | }` 76 | } 77 | 78 | func TestResult(t *testing.T) { 79 | result := Result{ 80 | StatusCode: 400, 81 | Token: "testToken", 82 | Error: &FCMError{ 83 | Status: InvalidArgument, 84 | Message: "The registration token is not a valid FCM registration token", 85 | }, 86 | } 87 | b, err := result.MarshalJSON() 88 | if err != nil { 89 | t.Error(err) 90 | } 91 | t.Logf("%s", string(b)) 92 | if string(b) != `{"provider":"fcmv1","status":400,"token":"testToken","error":{"status":"INVALID_ARGUMENT","message":"The registration token is not a valid FCM registration token"}}` { 93 | t.Errorf("unexpected encoded json: %s", string(b)) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /global.go: -------------------------------------------------------------------------------- 1 | package gunfish 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // Application global variables 8 | var ( 9 | srvStats Stats 10 | errorResponseHandler ResponseHandler 11 | successResponseHandler ResponseHandler 12 | ) 13 | 14 | // InitErrorResponseHandler initialize error response handler. 15 | func InitErrorResponseHandler(erh ResponseHandler) error { 16 | if erh != nil { 17 | errorResponseHandler = erh 18 | return nil 19 | } 20 | return fmt.Errorf("Invalid response handler: %v", erh) 21 | } 22 | 23 | // InitSuccessResponseHandler initialize success response handler. 24 | func InitSuccessResponseHandler(sh ResponseHandler) error { 25 | if sh != nil { 26 | successResponseHandler = sh 27 | return nil 28 | } 29 | return fmt.Errorf("Invalid response handler: %v", sh) 30 | } 31 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kayac/Gunfish 2 | 3 | go 1.12 4 | 5 | require ( 6 | firebase.google.com/go v3.10.0+incompatible 7 | github.com/fukata/golang-stats-api-handler v1.0.0 8 | github.com/google/go-cmp v0.3.1 9 | github.com/kayac/go-config v0.0.2 10 | github.com/lestrrat-go/server-starter v0.0.0-20181210024821-8564cc80d990 11 | github.com/onsi/ginkgo v1.10.1 // indirect 12 | github.com/onsi/gomega v1.7.0 // indirect 13 | github.com/pkg/errors v0.8.0 14 | github.com/satori/go.uuid v1.1.0 15 | github.com/sirupsen/logrus v1.0.3 16 | github.com/stretchr/testify v1.4.0 // indirect 17 | golang.org/x/net v0.17.0 18 | golang.org/x/oauth2 v0.0.0-20191122200657-5d9234df094c 19 | google.golang.org/api v0.14.0 // indirect 20 | gopkg.in/airbrake/gobrake.v2 v2.0.9 // indirect 21 | gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | cloud.google.com/go v0.38.0 h1:ROfEUZz+Gh5pa62DJWXSaonyu3StP6EA6lPEXPI6mCo= 4 | cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= 5 | firebase.google.com/go v3.10.0+incompatible h1:GVdqx1+ZmPg9qd2S+8K9NHgkUUmZsGJxDq56IW5ciqs= 6 | firebase.google.com/go v3.10.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs= 7 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 8 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 9 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 10 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 13 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 14 | github.com/fukata/golang-stats-api-handler v1.0.0 h1:N6M25vhs1yAvwGBpFY6oBmMOZeJdcWnvA+wej8pKeko= 15 | github.com/fukata/golang-stats-api-handler v1.0.0/go.mod h1:1sIi4/rHq6s/ednWMZqTmRq3765qTUSs/c3xF6lj8J8= 16 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= 17 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 18 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 19 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 20 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 21 | github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= 22 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 23 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 24 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 25 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 26 | github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= 27 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 28 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 29 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 30 | github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= 31 | github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= 32 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 33 | github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= 34 | github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 35 | github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= 36 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 37 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= 38 | github.com/kayac/go-config v0.0.2 h1:vdj8bXPtlGeyP/f0JBm2XoZHxZeUe+pC3Sa9JAWO1mg= 39 | github.com/kayac/go-config v0.0.2/go.mod h1:lP72SnCnnLhGPg4gRDdLLf+gx/fxpcdob4oRINAZ4cI= 40 | github.com/lestrrat-go/server-starter v0.0.0-20181210024821-8564cc80d990 h1:I/xDHYf06Z00f4smMbtiRP9P++9sTV3MGpN/txx/oGw= 41 | github.com/lestrrat-go/server-starter v0.0.0-20181210024821-8564cc80d990/go.mod h1:xG1VLzrCB2+uP91eXVCs5FteV/JJOjw0tr+hAM/aL/8= 42 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 43 | github.com/onsi/ginkgo v1.10.1 h1:q/mM8GF/n0shIN8SaAZ0V+jnLPzen6WIVZdiwrRlMlo= 44 | github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 45 | github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME= 46 | github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 47 | github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= 48 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 49 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 50 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 51 | github.com/satori/go.uuid v1.1.0 h1:B9KXyj+GzIpJbV7gmr873NsY6zpbxNy24CBtGrk7jHo= 52 | github.com/satori/go.uuid v1.1.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 53 | github.com/sirupsen/logrus v1.0.3 h1:B5C/igNWoiULof20pKfY4VntcIPqKuwEmoLZrabbUrc= 54 | github.com/sirupsen/logrus v1.0.3/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= 55 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 56 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 57 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 58 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 59 | go.opencensus.io v0.21.0 h1:mU6zScU4U1YAFPHEHYk+3JC4SY7JxgkqS10ZOSyksNg= 60 | go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= 61 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 62 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 63 | golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= 64 | golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= 65 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 66 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 67 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 68 | golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 69 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 70 | golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 71 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 72 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 73 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 74 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 75 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 76 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 77 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 78 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 79 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 80 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 81 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 82 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 83 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 84 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 85 | golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= 86 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= 87 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 88 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 89 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 90 | golang.org/x/oauth2 v0.0.0-20191122200657-5d9234df094c h1:HjRaKPaiWks0f5tA6ELVF7ZfqSppfPwOEEAvsrKUTO4= 91 | golang.org/x/oauth2 v0.0.0-20191122200657-5d9234df094c/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 92 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 93 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 94 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 95 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 96 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 97 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 98 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 99 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 100 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 101 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 102 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 103 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 104 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 105 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 106 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 107 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 108 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 109 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= 110 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 111 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 112 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 113 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 114 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 115 | golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= 116 | golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= 117 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 118 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 119 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 120 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 121 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 122 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 123 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 124 | golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= 125 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 126 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 127 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 128 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 129 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 130 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 131 | golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 132 | golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 133 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 134 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 135 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 136 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 137 | google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= 138 | google.golang.org/api v0.14.0 h1:uMf5uLi4eQMRrMKhCplNik4U4H8Z6C1br3zOtAa/aDE= 139 | google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 140 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 141 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 142 | google.golang.org/appengine v1.5.0 h1:KxkO13IPW4Lslp2bz+KHP2E3gtFlrIGNThxkZQ3g+4c= 143 | google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 144 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 145 | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 146 | google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 147 | google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873 h1:nfPFGzJkUDX6uBmpN/pSw7MbOAWegH5QDQuoXFHedLg= 148 | google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 149 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 150 | google.golang.org/grpc v1.20.1 h1:Hz2g2wirWK7H0qIIhGIqRGTuMwTE8HEKFnDZZ7lm9NU= 151 | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= 152 | gopkg.in/airbrake/gobrake.v2 v2.0.9 h1:7z2uVWwn7oVeeugY1DtlPAy5H+KYgB1KeKTnqjNatLo= 153 | gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= 154 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 155 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 156 | gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= 157 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 158 | gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2 h1:OAj3g0cR6Dx/R07QgQe8wkA9RNjB2u4i700xBkIT4e0= 159 | gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= 160 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 161 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 162 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 163 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 164 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 165 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 166 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 167 | honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 168 | -------------------------------------------------------------------------------- /gunfish_test.go: -------------------------------------------------------------------------------- 1 | package gunfish_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "os" 9 | "runtime/pprof" 10 | "testing" 11 | "time" 12 | 13 | gunfish "github.com/kayac/Gunfish" 14 | ) 15 | 16 | func BenchmarkGunfish(b *testing.B) { 17 | myprof := "mybench.prof" 18 | f, err := os.Create(myprof) 19 | if err != nil { 20 | b.Fatal(err) 21 | } 22 | 23 | b.StopTimer() 24 | go func() { 25 | gunfish.StartServer(conf, gunfish.Test) 26 | }() 27 | time.Sleep(time.Second * 1) 28 | 29 | oj := `{"token":"token-x","payload":{"aps":{"alert":{"body":"message","title":"bench test"},"sound":"default"},"suboption":"test"}}` 30 | jsons := bytes.NewBufferString("[") 31 | jsons.WriteString(oj) 32 | for i := 0; i < 2500; i++ { 33 | jsons.WriteString("," + oj) 34 | } 35 | jsons.WriteString("]") 36 | 37 | b.StartTimer() 38 | for i := 0; i < b.N; i++ { 39 | err := do(jsons) 40 | if err != nil { 41 | b.Fatal(err) 42 | } 43 | } 44 | pprof.WriteHeapProfile(f) 45 | defer f.Close() 46 | } 47 | 48 | func do(jsons *bytes.Buffer) error { 49 | u, err := url.Parse(fmt.Sprintf("http://localhost:%d/push/apns", conf.Provider.Port)) 50 | if err != nil { 51 | return err 52 | } 53 | client := &http.Client{} 54 | nreq, err := http.NewRequest("POST", u.String(), jsons) 55 | nreq.Header.Set("Content-Type", gunfish.ApplicationJSON) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | resp, err := client.Do(nreq) 61 | if err != nil { 62 | return err 63 | } 64 | defer resp.Body.Close() 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package gunfish 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | 7 | "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // LogWithFields wraps logrus's WithFields 11 | func LogWithFields(fields map[string]interface{}) *logrus.Entry { 12 | _, file, line, _ := runtime.Caller(1) 13 | 14 | fields["file"] = file 15 | fields["line"] = fmt.Sprintf("%d", line) 16 | 17 | return logrus.WithFields(fields) 18 | } 19 | -------------------------------------------------------------------------------- /ltsv_formatter.go: -------------------------------------------------------------------------------- 1 | package gunfish 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "sort" 7 | "time" 8 | 9 | "github.com/sirupsen/logrus" 10 | ) 11 | 12 | var ( 13 | baseTimestamp time.Time 14 | ) 15 | 16 | func init() { 17 | baseTimestamp = time.Now() 18 | } 19 | 20 | func miniTS() int { 21 | return int(time.Since(baseTimestamp) / time.Second) 22 | } 23 | 24 | // LtsvFormatter is ltsv format for logrus 25 | type LtsvFormatter struct { 26 | DisableTimestamp bool 27 | TimestampFormat string 28 | DisableSorting bool 29 | } 30 | 31 | // Format entry 32 | func (f *LtsvFormatter) Format(entry *logrus.Entry) ([]byte, error) { 33 | keys := make([]string, 0, len(entry.Data)) 34 | for k := range entry.Data { 35 | keys = append(keys, k) 36 | } 37 | 38 | if !f.DisableSorting { 39 | sort.Strings(keys) 40 | } 41 | 42 | b := &bytes.Buffer{} 43 | 44 | prefixFieldClashes(entry.Data) 45 | 46 | timestampFormat := f.TimestampFormat 47 | if timestampFormat == "" { 48 | timestampFormat = time.RFC3339 49 | } 50 | 51 | f.appendKeyValue(b, "level", entry.Level.String()) 52 | 53 | if entry.Message != "" { 54 | f.appendKeyValue(b, "msg", entry.Message) 55 | } 56 | 57 | for _, key := range keys { 58 | f.appendKeyValue(b, key, entry.Data[key]) 59 | } 60 | 61 | if !f.DisableTimestamp { 62 | f.appendKeyValue(b, "time", entry.Time.Format(timestampFormat)) 63 | } 64 | 65 | b.WriteByte('\n') 66 | return b.Bytes(), nil 67 | } 68 | 69 | func needsQuoting(text string) bool { 70 | for _, ch := range text { 71 | if !((ch >= 'a' && ch <= 'z') || 72 | (ch >= 'A' && ch <= 'Z') || 73 | (ch >= '0' && ch <= '9') || 74 | ch == '-' || ch == '.') { 75 | return false 76 | } 77 | } 78 | return true 79 | } 80 | 81 | func (f *LtsvFormatter) appendKeyValue(b *bytes.Buffer, key string, value interface{}) { 82 | 83 | b.WriteString(key) 84 | b.WriteByte(':') 85 | 86 | switch value := value.(type) { 87 | case string: 88 | if needsQuoting(value) { 89 | b.WriteString(value) 90 | } else { 91 | fmt.Fprintf(b, "%q", value) 92 | } 93 | case int, int64, int32: 94 | fmt.Fprintf(b, "%d", value) 95 | case float64, float32: 96 | fmt.Fprintf(b, "%f", value) 97 | case error: 98 | errmsg := value.Error() 99 | if needsQuoting(errmsg) { 100 | b.WriteString(errmsg) 101 | } else { 102 | fmt.Fprintf(b, "%q", value) 103 | } 104 | default: 105 | fmt.Fprintf(b, "\"%v\"", value) 106 | } 107 | 108 | b.WriteByte('\t') 109 | } 110 | 111 | func prefixFieldClashes(data logrus.Fields) { 112 | if _, ok := data["time"]; ok { 113 | data["fields.time"] = data["time"] 114 | } 115 | 116 | if _, ok := data["msg"]; ok { 117 | data["fields.msg"] = data["msg"] 118 | } 119 | 120 | if _, ok := data["level"]; ok { 121 | data["fields.level"] = data["level"] 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /ltsv_formatter_test.go: -------------------------------------------------------------------------------- 1 | package gunfish_test 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "testing" 7 | 8 | gunfish "github.com/kayac/Gunfish" 9 | "github.com/sirupsen/logrus" 10 | ) 11 | 12 | func TestQuoting(t *testing.T) { 13 | tf := &gunfish.LtsvFormatter{} 14 | 15 | checkQuoting := func(q bool, value interface{}) { 16 | b, _ := tf.Format(logrus.WithField("test", value)) 17 | idx := bytes.Index(b, ([]byte)("test:")) 18 | cont := bytes.Equal(b[idx+5:idx+6], []byte{'"'}) 19 | if cont != q { 20 | if q { 21 | t.Errorf("quoting expected for: %#v", value) 22 | } else { 23 | t.Errorf("quoting not expected for: %#v", value) 24 | } 25 | } 26 | } 27 | 28 | checkQuoting(false, "abcd") 29 | checkQuoting(false, "v1.0") 30 | checkQuoting(false, "1234567890") 31 | checkQuoting(true, "/foobar") 32 | checkQuoting(true, "x y") 33 | checkQuoting(true, "x,y") 34 | checkQuoting(false, errors.New("invalid")) 35 | checkQuoting(true, errors.New("invalid argument")) 36 | } 37 | -------------------------------------------------------------------------------- /mock/apns_server.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "log" 8 | "math/rand" 9 | "net/http" 10 | "strings" 11 | "time" 12 | 13 | "github.com/kayac/Gunfish/apns" 14 | ) 15 | 16 | const ( 17 | ApplicationJSON = "application/json" 18 | LimitApnsTokenByteSize = 100 // Payload byte size. 19 | ) 20 | 21 | // StartAPNSMockServer starts HTTP/2 server for mock 22 | func APNsMockServer(verbose bool) *http.ServeMux { 23 | mux := http.NewServeMux() 24 | 25 | mux.HandleFunc("/3/device/", func(w http.ResponseWriter, r *http.Request) { 26 | start := time.Now() 27 | defer func() { 28 | if verbose { 29 | log.Printf("reqtime:%f proto:%s method:%s path:%s host:%s", reqtime(start), r.Proto, r.Method, r.URL.Path, r.RemoteAddr) 30 | } 31 | }() 32 | 33 | // sets the response time from apns server 34 | time.Sleep(time.Millisecond*200 + time.Millisecond*(time.Duration(rand.Int63n(200)-100))) 35 | 36 | // only allow path which pattern is '/3/device/:token' 37 | splitPath := strings.Split(r.URL.Path, "/") 38 | if len(splitPath) != 4 { 39 | w.WriteHeader(http.StatusNotFound) 40 | fmt.Fprintf(w, "404 Not found") 41 | return 42 | } 43 | 44 | w.Header().Set("Content-Type", ApplicationJSON) 45 | 46 | token := splitPath[len(splitPath)-1] 47 | if len(([]byte(token))) > LimitApnsTokenByteSize || token == "baddevicetoken" { 48 | w.Header().Set("apns-id", "apns-id") 49 | w.WriteHeader(http.StatusBadRequest) 50 | createErrorResponse(w, apns.BadDeviceToken, http.StatusBadRequest) 51 | } else if token == "missingtopic" { 52 | w.WriteHeader(http.StatusBadRequest) 53 | createErrorResponse(w, apns.MissingTopic, http.StatusBadRequest) 54 | } else if token == "unregistered" { 55 | // If the value in the :status header is 410, the value of this key is 56 | // the last time at which APNs confirmed that the device token was 57 | // no longer valid for the topic. 58 | // 59 | // Stop pushing notifications until the device registers a token with 60 | // a later timestamp with your provider. 61 | w.WriteHeader(http.StatusGone) 62 | createErrorResponse(w, apns.Unregistered, http.StatusGone) 63 | } else if token == "expiredprovidertoken" { 64 | w.WriteHeader(http.StatusForbidden) 65 | createErrorResponse(w, apns.ExpiredProviderToken, http.StatusForbidden) 66 | } else { 67 | w.Header().Set("apns-id", "apns-id") 68 | w.WriteHeader(http.StatusOK) 69 | } 70 | 71 | return 72 | }) 73 | 74 | return mux 75 | } 76 | 77 | func createErrorResponse(w io.Writer, ermsg apns.ErrorResponseCode, status int) error { 78 | enc := json.NewEncoder(w) 79 | var er apns.ErrorResponse 80 | if status == http.StatusGone { 81 | er = apns.ErrorResponse{ 82 | Reason: ermsg.String(), 83 | Timestamp: time.Now().Unix(), 84 | } 85 | } else { 86 | er = apns.ErrorResponse{ 87 | Reason: ermsg.String(), 88 | } 89 | } 90 | return enc.Encode(er) 91 | } 92 | 93 | func reqtime(start time.Time) float64 { 94 | diff := time.Now().Sub(start) 95 | return diff.Seconds() 96 | } 97 | -------------------------------------------------------------------------------- /mock/fcmv1_server.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "math/rand" 8 | "net/http" 9 | "strings" 10 | "time" 11 | 12 | "github.com/kayac/Gunfish/fcmv1" 13 | ) 14 | 15 | func FCMv1MockServer(projectID string, verbose bool) *http.ServeMux { 16 | mux := http.NewServeMux() 17 | p := fmt.Sprintf("/v1/projects/%s/messages:send", projectID) 18 | log.Println("fcmv1 mock server path:", p) 19 | mux.HandleFunc(p, func(w http.ResponseWriter, r *http.Request) { 20 | start := time.Now() 21 | defer func() { 22 | if verbose { 23 | log.Printf("reqtime:%f proto:%s method:%s path:%s host:%s", reqtime(start), r.Proto, r.Method, r.URL.Path, r.RemoteAddr) 24 | } 25 | }() 26 | 27 | // sets the response time from FCM server 28 | time.Sleep(time.Millisecond*200 + time.Millisecond*(time.Duration(rand.Int63n(200)-100))) 29 | token := r.Header.Get("Authorization") 30 | token = strings.TrimPrefix(token, "Bearer ") 31 | 32 | w.Header().Set("Content-Type", ApplicationJSON) 33 | switch token { 34 | case fcmv1.InvalidArgument: 35 | createFCMv1ErrorResponse(w, http.StatusBadRequest, fcmv1.InvalidArgument) 36 | case fcmv1.Unregistered: 37 | createFCMv1ErrorResponse(w, http.StatusNotFound, fcmv1.Unregistered) 38 | case fcmv1.Unavailable: 39 | createFCMv1ErrorResponse(w, http.StatusServiceUnavailable, fcmv1.Unavailable) 40 | case fcmv1.Internal: 41 | createFCMv1ErrorResponse(w, http.StatusInternalServerError, fcmv1.Internal) 42 | case fcmv1.QuotaExceeded: 43 | createFCMv1ErrorResponse(w, http.StatusTooManyRequests, fcmv1.QuotaExceeded) 44 | default: 45 | enc := json.NewEncoder(w) 46 | enc.Encode(fcmv1.ResponseBody{ 47 | Name: "ok", 48 | }) 49 | } 50 | }) 51 | 52 | return mux 53 | } 54 | 55 | func createFCMv1ErrorResponse(w http.ResponseWriter, code int, status string) error { 56 | w.WriteHeader(code) 57 | enc := json.NewEncoder(w) 58 | return enc.Encode(fcmv1.ResponseBody{ 59 | Error: &fcmv1.FCMError{ 60 | Status: status, 61 | Message: "mock error:" + status, 62 | }, 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /request.go: -------------------------------------------------------------------------------- 1 | package gunfish 2 | 3 | import ( 4 | "github.com/kayac/Gunfish/apns" 5 | ) 6 | 7 | type Request struct { 8 | Notification Notification 9 | Tries int 10 | } 11 | 12 | type Notification interface{} 13 | 14 | // PostedData is posted data to this provider server /push/apns. 15 | type PostedData struct { 16 | Header apns.Header `json:"header,omitempty"` 17 | Token string `json:"token"` 18 | Payload apns.Payload `json:"payload"` 19 | } 20 | -------------------------------------------------------------------------------- /response.go: -------------------------------------------------------------------------------- 1 | package gunfish 2 | 3 | import "encoding/json" 4 | 5 | type Result interface { 6 | Err() error 7 | Status() int 8 | Provider() string 9 | RecipientIdentifier() string 10 | ExtraKeys() []string 11 | ExtraValue(string) string 12 | json.Marshaler 13 | } 14 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package gunfish 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "math" 10 | "net" 11 | "net/http" 12 | "os" 13 | "os/signal" 14 | "reflect" 15 | "sync" 16 | "sync/atomic" 17 | "syscall" 18 | "time" 19 | 20 | stats_api "github.com/fukata/golang-stats-api-handler" 21 | "github.com/kayac/Gunfish/apns" 22 | "github.com/kayac/Gunfish/config" 23 | "github.com/kayac/Gunfish/fcmv1" 24 | "github.com/lestrrat-go/server-starter/listener" 25 | "github.com/sirupsen/logrus" 26 | "golang.org/x/net/netutil" 27 | ) 28 | 29 | // Provider defines Gunfish httpHandler and has a state 30 | // of queue which is shared by the supervisor. 31 | type Provider struct { 32 | Sup Supervisor 33 | } 34 | 35 | // ResponseHandler provides you to implement handling on success or on error response from apns. 36 | // Therefore, you can specifies hook command which is set at toml file. 37 | type ResponseHandler interface { 38 | OnResponse(Result) 39 | HookCmd() string 40 | } 41 | 42 | // DefaultResponseHandler is the default ResponseHandler if not specified. 43 | type DefaultResponseHandler struct { 44 | Hook string 45 | } 46 | 47 | // OnResponse is performed when to receive result from APNs or FCM. 48 | func (rh DefaultResponseHandler) OnResponse(result Result) { 49 | } 50 | 51 | // HookCmd returns hook command to execute after getting response from APNS 52 | // only when to get error response. 53 | func (rh DefaultResponseHandler) HookCmd() string { 54 | return rh.Hook 55 | } 56 | 57 | // StartServer starts an apns provider server on http. 58 | func StartServer(conf config.Config, env Environment) { 59 | // Initialize DefaultResponseHandler if response handlers are not defined. 60 | if successResponseHandler == nil { 61 | InitSuccessResponseHandler(DefaultResponseHandler{}) 62 | } 63 | 64 | if errorResponseHandler == nil { 65 | InitErrorResponseHandler(DefaultResponseHandler{Hook: conf.Provider.ErrorHook}) 66 | } 67 | 68 | // Init Provider 69 | srvStats = NewStats(conf) 70 | prov := &Provider{} 71 | 72 | srvStats.DebugPort = conf.Provider.DebugPort 73 | LogWithFields(logrus.Fields{ 74 | "type": "provider", 75 | }).Infof("Size of POST request queue is %d", conf.Provider.QueueSize) 76 | 77 | // Set APNS host addr according to environment 78 | if env == Production { 79 | conf.Apns.Host = ProdServer 80 | } else if env == Development { 81 | conf.Apns.Host = DevServer 82 | } else if env == Test { 83 | conf.Apns.Host = MockServer 84 | } 85 | 86 | // start supervisor 87 | sup, err := StartSupervisor(&conf) 88 | if err != nil { 89 | LogWithFields(logrus.Fields{ 90 | "type": "provider", 91 | }).Fatalf("Failed to start Gunfish: %s", err.Error()) 92 | } 93 | prov.Sup = sup 94 | 95 | LogWithFields(logrus.Fields{ 96 | "type": "supervisor", 97 | }).Infof("Starts supervisor at %s", Production.String()) 98 | 99 | // StartServer listener 100 | listeners, err := listener.ListenAll() 101 | if err != nil { 102 | LogWithFields(logrus.Fields{ 103 | "type": "provider", 104 | }).Infof("%s. If you want graceful to restart Gunfish, you should use 'starter_server' (github.com/lestrrat/go-server-starter).", err) 105 | } 106 | 107 | // Start gunfish provider server 108 | var lis net.Listener 109 | if err == listener.ErrNoListeningTarget { 110 | // Fallback if not running under ServerStarter 111 | service := fmt.Sprintf(":%d", conf.Provider.Port) 112 | lis, err = net.Listen("tcp", service) 113 | if err != nil { 114 | LogWithFields(logrus.Fields{ 115 | "type": "provider", 116 | }).Error(err) 117 | sup.Shutdown() 118 | return 119 | } 120 | } else { 121 | if l, ok := listeners[0].Addr().(*net.TCPAddr); ok && l.Port != conf.Provider.Port { 122 | LogWithFields(logrus.Fields{ 123 | "type": "provider", 124 | }).Infof("'start_server' starts on :%d", l.Port) 125 | } 126 | // Starts Gunfish under ServerStarter. 127 | conf.Provider.Port = listeners[0].Addr().(*net.TCPAddr).Port 128 | lis = listeners[0] 129 | } 130 | 131 | // If many connections are established between Gunfish provider and your application, 132 | // Gunfish provider would be overloaded, and decrease in performance. 133 | llis := netutil.LimitListener(lis, conf.Provider.MaxConnections) 134 | 135 | // Start Gunfish provider 136 | LogWithFields(logrus.Fields{ 137 | "type": "provider", 138 | }).Infof("Starts provider on :%d ...", conf.Provider.Port) 139 | 140 | mux := http.NewServeMux() 141 | if conf.Apns.Enabled { 142 | LogWithFields(logrus.Fields{ 143 | "type": "provider", 144 | }).Infof("Enable endpoint /push/apns") 145 | mux.HandleFunc("/push/apns", prov.PushAPNsHandler()) 146 | } 147 | if conf.FCM.Enabled { 148 | panic("FCM legacy is not supported") 149 | } 150 | if conf.FCMv1.Enabled { 151 | LogWithFields(logrus.Fields{ 152 | "type": "provider", 153 | }).Infof("Enable endpoint /push/fcm/v1") 154 | mux.HandleFunc("/push/fcm/v1", prov.PushFCMHandler()) 155 | } 156 | mux.HandleFunc("/stats/app", prov.StatsHandler()) 157 | mux.HandleFunc("/stats/profile", stats_api.Handler) 158 | 159 | srv := &http.Server{Handler: mux} 160 | var wg sync.WaitGroup 161 | wg.Add(1) 162 | go func() { 163 | if err := srv.Serve(llis); err != nil && err != http.ErrServerClosed { 164 | LogWithFields(logrus.Fields{}).Error(err) 165 | } 166 | wg.Done() 167 | }() 168 | 169 | // signal handling 170 | wg.Add(1) 171 | go startSignalReciever(&wg, srv) 172 | 173 | // wait for server shutdown complete 174 | wg.Wait() 175 | 176 | // if Gunfish server stop, Close queue 177 | LogWithFields(logrus.Fields{ 178 | "type": "provider", 179 | }).Info("Stopping server") 180 | 181 | // if Gunfish server stop, Close queue 182 | sup.Shutdown() 183 | } 184 | 185 | func (prov *Provider) PushAPNsHandler() http.HandlerFunc { 186 | return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 187 | atomic.AddInt64(&(srvStats.RequestCount), 1) 188 | 189 | // Method Not Alllowed 190 | if err := validateMethod(res, req); err != nil { 191 | logrus.Warn(err) 192 | return 193 | } 194 | 195 | // Parse request body 196 | c := req.Header.Get("Content-Type") 197 | var ps []PostedData 198 | switch c { 199 | case ApplicationXW3FormURLEncoded: 200 | body := req.FormValue("json") 201 | if err := json.Unmarshal([]byte(body), &ps); err != nil { 202 | LogWithFields(logrus.Fields{}).Warnf("%s: %s", err, body) 203 | res.WriteHeader(http.StatusBadRequest) 204 | fmt.Fprintf(res, `{"reason": "%s"}`, err.Error()) 205 | return 206 | } 207 | case ApplicationJSON: 208 | decoder := json.NewDecoder(req.Body) 209 | if err := decoder.Decode(&ps); err != nil { 210 | LogWithFields(logrus.Fields{}).Warnf("%s: %v", err, ps) 211 | res.WriteHeader(http.StatusBadRequest) 212 | fmt.Fprintf(res, `{"reason": "%s"}`, err.Error()) 213 | return 214 | } 215 | default: 216 | // Unsupported Media Type 217 | logrus.Warnf("Unsupported Media Type: %s", c) 218 | res.WriteHeader(http.StatusUnsupportedMediaType) 219 | fmt.Fprintf(res, `{"reason":"Unsupported Media Type"}`) 220 | return 221 | } 222 | 223 | // Validates posted data 224 | if err := validatePostedData(ps); err != nil { 225 | res.WriteHeader(http.StatusBadRequest) 226 | fmt.Fprintf(res, `{"reason":"%s"}`, err.Error()) 227 | return 228 | } 229 | 230 | // Create requests 231 | reqs := make([]Request, len(ps)) 232 | for i, p := range ps { 233 | switch t := p.Payload.Alert.(type) { 234 | case map[string]interface{}: 235 | var alert apns.Alert 236 | mapToAlert(t, &alert) 237 | p.Payload.Alert = alert 238 | } 239 | 240 | req := Request{ 241 | Notification: apns.Notification{ 242 | Header: p.Header, 243 | Token: p.Token, 244 | Payload: p.Payload, 245 | }, 246 | Tries: 0, 247 | } 248 | 249 | reqs[i] = req 250 | } 251 | 252 | // enqueues one request into supervisor's queue. 253 | if err := prov.Sup.EnqueueClientRequest(&reqs); err != nil { 254 | setRetryAfter(res, req, err.Error()) 255 | return 256 | } 257 | 258 | // success 259 | res.WriteHeader(http.StatusOK) 260 | fmt.Fprint(res, "{\"result\": \"ok\"}") 261 | }) 262 | } 263 | 264 | func (prov *Provider) PushFCMHandler() http.HandlerFunc { 265 | return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 266 | atomic.AddInt64(&(srvStats.RequestCount), 1) 267 | 268 | // Method Not Alllowed 269 | if err := validateMethod(res, req); err != nil { 270 | logrus.Warn(err) 271 | return 272 | } 273 | 274 | // only Content-Type application/json 275 | c := req.Header.Get("Content-Type") 276 | if c != ApplicationJSON { 277 | // Unsupported Media Type 278 | logrus.Warnf("Unsupported Media Type: %s", c) 279 | res.WriteHeader(http.StatusUnsupportedMediaType) 280 | fmt.Fprintf(res, `{"reason":"Unsupported Media Type"}`) 281 | return 282 | } 283 | 284 | // create request for fcm 285 | grs, err := newFCMRequests(req.Body) 286 | if err != nil { 287 | logrus.Warnf("bad request: %s", err) 288 | res.WriteHeader(http.StatusBadRequest) 289 | fmt.Fprintf(res, "{\"reason\":\"%s\"}", err.Error()) 290 | return 291 | } 292 | 293 | // enqueues one request into supervisor's queue. 294 | if err := prov.Sup.EnqueueClientRequest(&grs); err != nil { 295 | setRetryAfter(res, req, err.Error()) 296 | return 297 | } 298 | 299 | // success 300 | res.WriteHeader(http.StatusOK) 301 | fmt.Fprint(res, "{\"result\": \"ok\"}") 302 | }) 303 | } 304 | 305 | func newFCMRequests(src io.Reader) ([]Request, error) { 306 | dec := json.NewDecoder(src) 307 | reqs := []Request{} 308 | count := 0 309 | PAYLOADS: 310 | for { 311 | var payload fcmv1.Payload 312 | if err := dec.Decode(&payload); err != nil { 313 | if err == io.EOF { 314 | break PAYLOADS 315 | } else { 316 | return nil, err 317 | } 318 | } 319 | count++ 320 | if count >= fcmv1.MaxBulkRequests { 321 | return nil, errors.New("Too many requests") 322 | } 323 | reqs = append(reqs, Request{Notification: payload, Tries: 0}) 324 | } 325 | return reqs, nil 326 | } 327 | 328 | func validateMethod(res http.ResponseWriter, req *http.Request) error { 329 | if req.Method != "POST" { 330 | res.WriteHeader(http.StatusMethodNotAllowed) 331 | fmt.Fprintf(res, "{\"reason\":\"Method Not Allowed.\"}") 332 | return fmt.Errorf("Method Not Allowed: %s", req.Method) 333 | } 334 | return nil 335 | } 336 | 337 | func setRetryAfter(res http.ResponseWriter, req *http.Request, reason string) { 338 | now := time.Now().Unix() 339 | atomic.StoreInt64(&(srvStats.ServiceUnavailableAt), now) 340 | updateRetryAfterStat(now - srvStats.ServiceUnavailableAt) 341 | // Retry-After is set seconds 342 | res.Header().Set("Retry-After", fmt.Sprintf("%d", srvStats.RetryAfter)) 343 | res.WriteHeader(http.StatusServiceUnavailable) 344 | fmt.Fprintf(res, fmt.Sprintf(`{"reason":"%s"}`, reason)) 345 | } 346 | 347 | func (prov *Provider) StatsHandler() http.HandlerFunc { 348 | return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 349 | if ok := validateStatsHandler(res, req); ok != true { 350 | return 351 | } 352 | 353 | wqs := 0 354 | for _, w := range prov.Sup.workers { 355 | wqs += len(w.queue) 356 | } 357 | 358 | atomic.StoreInt64(&(srvStats.QueueSize), int64(len(prov.Sup.queue))) 359 | atomic.StoreInt64(&(srvStats.RetryQueueSize), int64(len(prov.Sup.retryq))) 360 | atomic.StoreInt64(&(srvStats.WorkersQueueSize), int64(wqs)) 361 | atomic.StoreInt64(&(srvStats.CommandQueueSize), int64(len(prov.Sup.cmdq))) 362 | res.WriteHeader(http.StatusOK) 363 | encoder := json.NewEncoder(res) 364 | err := encoder.Encode(srvStats.GetStats()) 365 | if err != nil { 366 | res.WriteHeader(http.StatusInternalServerError) 367 | fmt.Fprintf(res, `{"reason":"Internal Server Error"}`) 368 | return 369 | } 370 | }) 371 | } 372 | 373 | func validatePostedData(ps []PostedData) error { 374 | if len(ps) == 0 { 375 | return fmt.Errorf("PostedData must not be empty: %v", ps) 376 | } 377 | 378 | if len(ps) > config.MaxRequestSize { 379 | return fmt.Errorf("PostedData was too long. Be less than %d: %v", config.MaxRequestSize, len(ps)) 380 | } 381 | 382 | for _, p := range ps { 383 | if p.Payload.APS == nil || p.Token == "" { 384 | return fmt.Errorf("Payload format was malformed: %v", p.Payload) 385 | } 386 | } 387 | return nil 388 | } 389 | 390 | func validateStatsHandler(res http.ResponseWriter, req *http.Request) bool { 391 | // Method Not Alllowed 392 | if req.Method != "GET" { 393 | res.WriteHeader(http.StatusMethodNotAllowed) 394 | fmt.Fprintf(res, `{"reason":"Method Not Allowed."}`) 395 | logrus.Warnf("Method Not Allowed: %s", req.Method) 396 | return false 397 | } 398 | 399 | return true 400 | } 401 | 402 | func mapToAlert(mapVal map[string]interface{}, alert *apns.Alert) { 403 | a := reflect.ValueOf(alert).Elem() 404 | for k, v := range mapVal { 405 | newk, ok := AlertKeyToField[k] 406 | if ok == true { 407 | a.FieldByName(newk).Set(reflect.ValueOf(v)) 408 | } else { 409 | logrus.Warnf("\"%s\" is not supported key for Alert struct.", k) 410 | } 411 | } 412 | } 413 | 414 | func startSignalReciever(wg *sync.WaitGroup, srv *http.Server) { 415 | defer wg.Done() 416 | 417 | sigChan := make(chan os.Signal) 418 | signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGINT) 419 | s := <-sigChan 420 | switch s { 421 | case syscall.SIGHUP: 422 | LogWithFields(logrus.Fields{ 423 | "type": "provider", 424 | }).Info("Gunfish recieved SIGHUP signal.") 425 | srv.Shutdown(context.Background()) 426 | case syscall.SIGTERM: 427 | LogWithFields(logrus.Fields{ 428 | "type": "provider", 429 | }).Info("Gunfish recieved SIGTERM signal.") 430 | srv.Shutdown(context.Background()) 431 | case syscall.SIGINT: 432 | LogWithFields(logrus.Fields{ 433 | "type": "provider", 434 | }).Info("Gunfish recieved SIGINT signal. Stopping server now...") 435 | srv.Shutdown(context.Background()) 436 | } 437 | } 438 | 439 | func updateRetryAfterStat(x int64) { 440 | var nxtRA int64 441 | if x > int64(ResetRetryAfterSecond/time.Second) { 442 | nxtRA = int64(RetryAfterSecond / time.Second) 443 | } else { 444 | a := int64(math.Log(float64(10/(x+1) + 1))) 445 | if srvStats.RetryAfter+2*a < int64(ResetRetryAfterSecond/time.Second) { 446 | nxtRA = srvStats.RetryAfter + 2*a 447 | } else { 448 | nxtRA = int64(ResetRetryAfterSecond / time.Second) 449 | } 450 | } 451 | 452 | atomic.StoreInt64(&(srvStats.RetryAfter), nxtRA) 453 | } 454 | -------------------------------------------------------------------------------- /server_test.go: -------------------------------------------------------------------------------- 1 | package gunfish_test 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "net/http/httptest" 10 | "net/url" 11 | "os" 12 | "testing" 13 | 14 | gunfish "github.com/kayac/Gunfish" 15 | "github.com/kayac/Gunfish/apns" 16 | "github.com/kayac/Gunfish/config" 17 | "github.com/kayac/Gunfish/mock" 18 | "github.com/sirupsen/logrus" 19 | "golang.org/x/net/http2" 20 | ) 21 | 22 | func TestMain(m *testing.M) { 23 | runner := func() int { 24 | apns.ClientTransport = func(cert tls.Certificate) *http.Transport { 25 | return &http.Transport{ 26 | TLSClientConfig: &tls.Config{ 27 | InsecureSkipVerify: true, 28 | Certificates: []tls.Certificate{cert}, 29 | }, 30 | } 31 | } 32 | gunfish.InitErrorResponseHandler(gunfish.DefaultResponseHandler{Hook: `cat `}) 33 | gunfish.InitSuccessResponseHandler(gunfish.DefaultResponseHandler{}) 34 | logrus.SetLevel(logrus.WarnLevel) 35 | 36 | ts := httptest.NewUnstartedServer(mock.APNsMockServer(false)) 37 | if err := http2.ConfigureServer(ts.Config, nil); err != nil { 38 | return 1 39 | } 40 | ts.TLS = ts.Config.TLSConfig 41 | ts.StartTLS() 42 | conf.Apns.Host = ts.URL 43 | 44 | code := m.Run() 45 | 46 | return code 47 | } 48 | 49 | os.Exit(runner()) 50 | } 51 | 52 | func TestInvalidCertification(t *testing.T) { 53 | c, _ := config.LoadConfig("./test/gunfish_test.toml") 54 | c.Apns.CertFile = "./test/invalid.crt" 55 | c.Apns.KeyFile = "./test/invalid.key" 56 | _, err := gunfish.StartSupervisor(&c) 57 | if err != nil { 58 | t.Errorf("Expected supervisor cannot start %s", err) 59 | } 60 | } 61 | 62 | func TestSuccessToPostJson(t *testing.T) { 63 | sup, _ := gunfish.StartSupervisor(&conf) 64 | prov := &gunfish.Provider{Sup: sup} 65 | handler := prov.PushAPNsHandler() 66 | 67 | // application/json 68 | jsons := createJSONPostedData(3) 69 | w := httptest.NewRecorder() 70 | r, err := newRequest(jsons, "POST", gunfish.ApplicationJSON) 71 | if err != nil { 72 | t.Errorf("%s", err) 73 | } 74 | 75 | handler.ServeHTTP(w, r) 76 | if w.Code != http.StatusOK { 77 | t.Errorf("Expected status code is 200 but got %d", w.Code) 78 | } 79 | 80 | // application/x-www-form-urlencoded 81 | data := createFormPostedData(3) 82 | w = httptest.NewRecorder() // re-creates Recoder because cannot overwrite header after to write body. 83 | r, err = newRequest(data, "POST", gunfish.ApplicationXW3FormURLEncoded) 84 | if err != nil { 85 | t.Errorf("%s", err) 86 | } 87 | 88 | handler.ServeHTTP(w, r) 89 | if w.Code != http.StatusOK { 90 | t.Errorf("Expected status code is 200 but got %d", w.Code) 91 | } 92 | 93 | sup.Shutdown() 94 | } 95 | 96 | func TestFailedToPostInvalidJson(t *testing.T) { 97 | sup, _ := gunfish.StartSupervisor(&conf) 98 | prov := &gunfish.Provider{Sup: sup} 99 | handler := prov.PushFCMHandler() 100 | 101 | // missing `}` 102 | invalidJson := []byte(`{"registration_ids": ["xxxxxxxxx"], "data": {"message":"test"`) 103 | 104 | w := httptest.NewRecorder() 105 | r, err := newRequest(invalidJson, "POST", gunfish.ApplicationJSON) 106 | if err != nil { 107 | t.Errorf("%s", err) 108 | } 109 | 110 | handler.ServeHTTP(w, r) 111 | 112 | invalidResponse := bytes.NewBufferString("{\"reason\":\"unexpected EOF\"}{\"result\": \"ok\"}").String() 113 | if w.Body.String() == invalidResponse { 114 | t.Errorf("Invalid Json responce: '%s'", w.Body) 115 | } 116 | 117 | sup.Shutdown() 118 | } 119 | 120 | func TestFailedToPostMalformedJson(t *testing.T) { 121 | sup, _ := gunfish.StartSupervisor(&conf) 122 | prov := &gunfish.Provider{Sup: sup} 123 | handler := prov.PushAPNsHandler() 124 | 125 | jsons := []string{ 126 | `{"test":"test"}`, 127 | "[{\"payload\": {\"aps\": {\"alert\":\"msg\", \"sound\":\"default\" }}}]", 128 | "[{\"token\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]", 129 | } 130 | for _, s := range jsons { 131 | v := url.Values{} 132 | v.Add("json", s) 133 | 134 | // application/x-www-form-urlencoded 135 | r, err := newRequest([]byte(v.Encode()), "POST", gunfish.ApplicationXW3FormURLEncoded) 136 | if err != nil { 137 | t.Errorf("%s", err) 138 | } 139 | 140 | w := httptest.NewRecorder() 141 | handler.ServeHTTP(w, r) 142 | if w.Code == http.StatusOK { 143 | t.Errorf("Expected status code is NOT 200 but got %d", w.Code) 144 | } 145 | 146 | // application/json 147 | r, err = newRequest([]byte(s), "POST", gunfish.ApplicationJSON) 148 | if err != nil { 149 | t.Errorf("%s", err) 150 | } 151 | 152 | w = httptest.NewRecorder() // re-creates Recoder because cannot overwrite header after to write body. 153 | handler.ServeHTTP(w, r) 154 | if w.Code == http.StatusOK { 155 | t.Errorf("Expected status code is NOT 200 but got %d", w.Code) 156 | } 157 | } 158 | 159 | sup.Shutdown() 160 | } 161 | 162 | func _TestEnqueueTooManyRequest(t *testing.T) { 163 | sup, _ := gunfish.StartSupervisor(&conf) 164 | prov := &gunfish.Provider{Sup: sup} 165 | handler := prov.PushAPNsHandler() 166 | 167 | var jsons [][]byte 168 | for i := 0; i < 1000; i++ { 169 | jsons = append(jsons, createJSONPostedData(1)) // Too many requests 170 | } 171 | 172 | // Test 503 returns 173 | check503 := false 174 | w := httptest.NewRecorder() // creates new Recoder because cannot overwrite header. 175 | var ra string 176 | for _, json := range jsons { 177 | r, err := newRequest(json, "POST", gunfish.ApplicationJSON) 178 | if err != nil { 179 | t.Errorf("%s", err) 180 | } 181 | 182 | handler.ServeHTTP(w, r) 183 | if w.Code == http.StatusServiceUnavailable { 184 | check503 = true 185 | ra = w.Header().Get("Retry-After") 186 | break 187 | } else { 188 | w = httptest.NewRecorder() 189 | } 190 | } 191 | if check503 == false { 192 | t.Errorf("Expected status code is 503 but got %d", w.Code) 193 | } 194 | if w.Header().Get("Retry-After") == "" { 195 | t.Error("Not set Retry-After correctlly") 196 | } 197 | 198 | // Test Retry-After value increases 199 | w = httptest.NewRecorder() // re-creates Recoder because cannot overwrite header after to write body. 200 | r, err := newRequest(jsons[0], "POST", gunfish.ApplicationJSON) 201 | if err != nil { 202 | t.Errorf("%s", err) 203 | } 204 | 205 | handler.ServeHTTP(w, r) 206 | if w.Code != http.StatusServiceUnavailable { 207 | t.Errorf("Expected status code is 503 but got %d", w.Code) 208 | } 209 | if w.Header().Get("Retry-After") == ra { 210 | t.Errorf("Retry-After should become different: old[ %s ], new[ %s ]", ra, w.Header().Get("Retry-After")) 211 | } 212 | 213 | sup.Shutdown() 214 | } 215 | 216 | func TestTooLargeRequest(t *testing.T) { 217 | sup, _ := gunfish.StartSupervisor(&conf) 218 | prov := &gunfish.Provider{Sup: sup} 219 | handler := prov.PushAPNsHandler() 220 | 221 | jsons := createJSONPostedData(config.MaxRequestSize + 1) // Too many requests 222 | r, err := newRequest(jsons, "POST", gunfish.ApplicationJSON) 223 | if err != nil { 224 | t.Errorf("%s", err) 225 | } 226 | 227 | w := httptest.NewRecorder() 228 | handler.ServeHTTP(w, r) 229 | if w.Code != http.StatusBadRequest { 230 | t.Errorf("Expected status code is %d but got %d", http.StatusBadRequest, w.Code) 231 | } 232 | 233 | sup.Shutdown() 234 | } 235 | 236 | func TestMethodNotAllowed(t *testing.T) { 237 | sup, _ := gunfish.StartSupervisor(&conf) 238 | prov := &gunfish.Provider{Sup: sup} 239 | handler := prov.PushAPNsHandler() 240 | 241 | jsons := createJSONPostedData(1) 242 | w := httptest.NewRecorder() 243 | r, err := newRequest(jsons, "GET", gunfish.ApplicationJSON) 244 | if err != nil { 245 | t.Errorf("%s", err) 246 | } 247 | 248 | handler.ServeHTTP(w, r) 249 | if w.Code != http.StatusMethodNotAllowed { 250 | t.Errorf("Expected status code is 405 but got %d", w.Code) 251 | } 252 | 253 | sup.Shutdown() 254 | } 255 | 256 | func TestUnsupportedMediaType(t *testing.T) { 257 | sup, _ := gunfish.StartSupervisor(&conf) 258 | prov := &gunfish.Provider{Sup: sup} 259 | handler := prov.PushAPNsHandler() 260 | 261 | jsons := createPostedData(1) 262 | r, err := http.NewRequest( 263 | "POST", 264 | "", 265 | bytes.NewBuffer([]byte(jsons)), 266 | ) 267 | r.Header.Set("Content-Type", "plain/text") 268 | w := httptest.NewRecorder() 269 | if err != nil { 270 | t.Errorf("%s", err) 271 | } 272 | 273 | handler.ServeHTTP(w, r) 274 | if w.Code != http.StatusUnsupportedMediaType { 275 | t.Errorf("Expected status code is 415 but got %d", w.Code) 276 | } 277 | 278 | sup.Shutdown() 279 | } 280 | 281 | func TestStats(t *testing.T) { 282 | sup, _ := gunfish.StartSupervisor(&conf) 283 | prov := &gunfish.Provider{Sup: sup} 284 | pushh := prov.PushAPNsHandler() 285 | statsh := prov.StatsHandler() 286 | 287 | var statsBefore, statsAfter gunfish.Stats 288 | // GET stats 289 | { 290 | r, err := newRequest([]byte(""), "GET", gunfish.ApplicationJSON) 291 | if err != nil { 292 | t.Errorf("%s", err) 293 | } 294 | w := httptest.NewRecorder() 295 | statsh.ServeHTTP(w, r) 296 | de := json.NewDecoder(w.Body) 297 | de.Decode(&statsBefore) 298 | } 299 | 300 | // Updates stat 301 | { 302 | jsons := createJSONPostedData(1) 303 | r, err := newRequest(jsons, "POST", gunfish.ApplicationJSON) 304 | if err != nil { 305 | t.Errorf("%s", err) 306 | } 307 | w := httptest.NewRecorder() 308 | pushh.ServeHTTP(w, r) 309 | } 310 | 311 | // GET stats 312 | { 313 | r, err := newRequest([]byte(""), "GET", gunfish.ApplicationJSON) 314 | if err != nil { 315 | t.Errorf("%s", err) 316 | } 317 | w := httptest.NewRecorder() 318 | statsh.ServeHTTP(w, r) 319 | de := json.NewDecoder(w.Body) 320 | de.Decode(&statsAfter) 321 | } 322 | 323 | if statsAfter.RequestCount != statsBefore.RequestCount+1 { 324 | t.Errorf("Unexpected stats request count: %#v %#v", statsBefore, statsAfter) 325 | } 326 | 327 | if statsAfter.CertificateExpireUntil < 0 { 328 | t.Errorf("Certificate expired %s %d", statsAfter.CertificateNotAfter, statsAfter.CertificateExpireUntil) 329 | } 330 | 331 | sup.Shutdown() 332 | } 333 | 334 | func newRequest(data []byte, method string, c string) (*http.Request, error) { 335 | req, err := http.NewRequest( 336 | method, 337 | "", 338 | bytes.NewBuffer([]byte(data)), 339 | ) 340 | req.Header.Set("Content-Type", c) 341 | return req, err 342 | } 343 | 344 | func createJSONPostedData(num int) []byte { 345 | return createPostedData(num) 346 | } 347 | 348 | func createFormPostedData(num int) []byte { 349 | jsonStr := createPostedData(num) 350 | v := url.Values{} 351 | v.Add("json", string(jsonStr)) 352 | return []byte(v.Encode()) 353 | } 354 | 355 | func createPostedData(num int) []byte { 356 | pds := make([]gunfish.PostedData, num) 357 | tokens := make([]string, num) 358 | for i := 0; i < num; i++ { 359 | tokens[i] = fmt.Sprintf("%032d", i) 360 | } 361 | for i, v := range tokens { 362 | payload := apns.Payload{} 363 | 364 | payload.APS = &apns.APS{ 365 | Alert: apns.Alert{ 366 | Title: "test", 367 | Body: "message", 368 | }, 369 | Sound: "default", 370 | } 371 | 372 | pds[i] = gunfish.PostedData{ 373 | Payload: payload, 374 | Token: v, 375 | } 376 | } 377 | 378 | jsonStr, _ := json.Marshal(pds) 379 | 380 | return jsonStr 381 | } 382 | -------------------------------------------------------------------------------- /stat.go: -------------------------------------------------------------------------------- 1 | package gunfish 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "github.com/kayac/Gunfish/config" 8 | ) 9 | 10 | // Stats stores metrics 11 | type Stats struct { 12 | Pid int `json:"pid"` 13 | DebugPort int `json:"debug_port"` 14 | Uptime int64 `json:"uptime"` 15 | StartAt int64 `json:"start_at"` 16 | ServiceUnavailableAt int64 `json:"su_at"` 17 | Period int64 `json:"period"` 18 | RetryAfter int64 `json:"retry_after"` 19 | Workers int64 `json:"workers"` 20 | QueueSize int64 `json:"queue_size"` 21 | RetryQueueSize int64 `json:"retry_queue_size"` 22 | WorkersQueueSize int64 `json:"workers_queue_size"` 23 | CommandQueueSize int64 `json:"cmdq_queue_size"` 24 | RetryCount int64 `json:"retry_count"` 25 | RequestCount int64 `json:"req_count"` 26 | SentCount int64 `json:"sent_count"` 27 | ErrCount int64 `json:"err_count"` 28 | CertificateNotAfter time.Time `json:"certificate_not_after"` 29 | CertificateExpireUntil int64 `json:"certificate_expire_until"` 30 | } 31 | 32 | // NewStats initialize Stats 33 | func NewStats(conf config.Config) Stats { 34 | return Stats{ 35 | Pid: os.Getpid(), 36 | StartAt: time.Now().Unix(), 37 | RetryAfter: int64(RetryAfterSecond / time.Second), 38 | CertificateNotAfter: conf.Apns.CertificateNotAfter, 39 | } 40 | } 41 | 42 | // GetStats returns MemdStats of app 43 | func (st *Stats) GetStats() *Stats { 44 | preUptime := st.Uptime 45 | st.Uptime = time.Now().Unix() - st.StartAt 46 | st.Period = st.Uptime - preUptime 47 | if !st.CertificateNotAfter.IsZero() { 48 | st.CertificateExpireUntil = int64(st.CertificateNotAfter.Sub(time.Now()).Seconds()) 49 | } 50 | return st 51 | } 52 | -------------------------------------------------------------------------------- /supervisor.go: -------------------------------------------------------------------------------- 1 | package gunfish 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "math" 9 | "os" 10 | "os/exec" 11 | "sync" 12 | "sync/atomic" 13 | "syscall" 14 | "time" 15 | 16 | "github.com/kayac/Gunfish/apns" 17 | "github.com/kayac/Gunfish/config" 18 | "github.com/kayac/Gunfish/fcmv1" 19 | uuid "github.com/satori/go.uuid" 20 | "github.com/sirupsen/logrus" 21 | ) 22 | 23 | // Supervisor monitor mutiple http2 clients. 24 | type Supervisor struct { 25 | queue chan *[]Request // supervisor's queue that recieves POST requests. 26 | retryq chan Request // enqueues this retry queue when to failed to send notification on the http layer. 27 | cmdq chan Command // enqueues this command queue when to get error response from apns. 28 | exit chan struct{} // exit channel is used to stop the supervisor. 29 | ticker *time.Ticker // ticker checks retry queue that has notifications to resend periodically. 30 | wgrp *sync.WaitGroup 31 | workers []*Worker 32 | } 33 | 34 | // Worker sends notification to apns. 35 | type Worker struct { 36 | ac *apns.Client 37 | fcv1 *fcmv1.Client 38 | queue chan Request 39 | respq chan SenderResponse 40 | wgrp *sync.WaitGroup 41 | sn int 42 | id int 43 | } 44 | 45 | // SenderResponse is responses to worker from sender. 46 | type SenderResponse struct { 47 | Results []Result `json:"response"` 48 | RespTime float64 `json:"response_time"` 49 | Req Request `json:"request"` 50 | Err error `json:"error_msg"` 51 | UID string `json:"resp_uid"` 52 | } 53 | 54 | // Command has execute command and input stream. 55 | type Command struct { 56 | command string 57 | input []byte 58 | } 59 | 60 | // EnqueueClientRequest enqueues request to supervisor's queue from external application service 61 | func (s *Supervisor) EnqueueClientRequest(reqs *[]Request) error { 62 | logf := logrus.Fields{ 63 | "type": "supervisor", 64 | "request_size": len(*reqs), 65 | "queue_size": len(s.queue), 66 | "retry_queue_size": len(s.retryq), 67 | } 68 | 69 | select { 70 | case s.queue <- reqs: 71 | LogWithFields(logf).Debugf("Enqueued request from provider.") 72 | default: 73 | LogWithFields(logf).Warnf("Supervisor's queue is full.") 74 | return fmt.Errorf("Supervisor's queue is full") 75 | } 76 | 77 | return nil 78 | } 79 | 80 | // StartSupervisor starts supervisor 81 | func StartSupervisor(conf *config.Config) (Supervisor, error) { 82 | // Calculates each worker queue size to accept requests with a given parameter of requests per sec as flow rate. 83 | var wqSize int 84 | tp := ((conf.Provider.RequestQueueSize * int(AverageResponseTime/time.Millisecond)) / 1000) / SenderNum 85 | dif := (RequestPerSec - conf.Provider.RequestQueueSize/tp) 86 | if dif > 0 { 87 | wqSize = dif * int(FlowRateInterval/time.Second) / conf.Provider.WorkerNum 88 | } else { 89 | wqSize = -1 * dif * int(FlowRateInterval/time.Second) / conf.Provider.WorkerNum 90 | } 91 | 92 | // Initialize Supervisor 93 | swgrp := &sync.WaitGroup{} 94 | s := Supervisor{ 95 | queue: make(chan *[]Request, conf.Provider.QueueSize), 96 | retryq: make(chan Request, conf.Provider.RequestQueueSize*conf.Provider.WorkerNum), 97 | cmdq: make(chan Command, wqSize*conf.Provider.WorkerNum), 98 | exit: make(chan struct{}, 1), 99 | ticker: time.NewTicker(RetryWaitTime), 100 | wgrp: swgrp, 101 | } 102 | LogWithFields(logrus.Fields{}).Infof("Retry queue size: %d", cap(s.retryq)) 103 | LogWithFields(logrus.Fields{}).Infof("Queue size: %d", cap(s.queue)) 104 | 105 | // Time ticker to retry to send 106 | go func() { 107 | for { 108 | select { 109 | case <-s.ticker.C: 110 | // Number of request retry send at once. 111 | for cnt := 0; cnt < RetryOnceCount; cnt++ { 112 | select { 113 | case req := <-s.retryq: 114 | var delay time.Duration 115 | if RetryBackoff { 116 | delay = time.Duration(math.Pow(float64(req.Tries), 2)) * 100 * time.Millisecond 117 | } 118 | time.AfterFunc(delay, func() { 119 | reqs := &[]Request{req} 120 | select { 121 | case s.queue <- reqs: 122 | LogWithFields(logrus.Fields{"delay": delay, "type": "retry", "resend_cnt": req.Tries}). 123 | Debugf("Enqueue to retry to send notification.") 124 | default: 125 | LogWithFields(logrus.Fields{"delay": delay, "type": "retry"}). 126 | Infof("Could not retry to enqueue because the supervisor queue is full.") 127 | } 128 | }) 129 | default: 130 | } 131 | } 132 | case <-s.exit: 133 | s.ticker.Stop() 134 | return 135 | } 136 | } 137 | }() 138 | 139 | // spawn command 140 | for i := 0; i < conf.Provider.WorkerNum; i++ { 141 | s.wgrp.Add(1) 142 | go func() { 143 | logf := logrus.Fields{"type": "cmd_worker"} 144 | for c := range s.cmdq { 145 | LogWithFields(logf).Debugf("invoking command: %s %s", c.command, string(c.input)) 146 | src := bytes.NewBuffer(c.input) 147 | out, err := InvokePipe(c.command, src) 148 | if err != nil { 149 | LogWithFields(logf).Errorf("(%s) %s", err.Error(), string(out)) 150 | } else { 151 | LogWithFields(logf).Debugf("Success to execute command") 152 | } 153 | } 154 | s.wgrp.Done() 155 | }() 156 | } 157 | 158 | // Spawn workers 159 | var err error 160 | for i := 0; i < conf.Provider.WorkerNum; i++ { 161 | var ( 162 | ac *apns.Client 163 | fcv1 *fcmv1.Client 164 | ) 165 | if conf.Apns.Enabled { 166 | ac, err = apns.NewClient(conf.Apns) 167 | if err != nil { 168 | LogWithFields(logrus.Fields{ 169 | "type": "supervisor", 170 | }).Errorf("faile to new client for apns: %s", err.Error()) 171 | break 172 | } 173 | } 174 | if conf.FCM.Enabled { 175 | return Supervisor{}, errors.New("FCM legacy is not supported") 176 | } 177 | if conf.FCMv1.Enabled { 178 | fcv1, err = fcmv1.NewClient(conf.FCMv1.TokenSource, conf.FCMv1.ProjectID, conf.FCMv1.Endpoint, fcmv1.ClientTimeout) 179 | if err != nil { 180 | LogWithFields(logrus.Fields{ 181 | "type": "supervisor", 182 | }).Errorf("failed to new client for fcmv1: %s", err.Error()) 183 | break 184 | } 185 | } 186 | worker := Worker{ 187 | id: i, 188 | queue: make(chan Request, wqSize), 189 | respq: make(chan SenderResponse, wqSize*100), 190 | wgrp: &sync.WaitGroup{}, 191 | sn: SenderNum, 192 | ac: ac, 193 | fcv1: fcv1, 194 | } 195 | 196 | s.workers = append(s.workers, &worker) 197 | s.wgrp.Add(1) 198 | go s.spawnWorker(worker) 199 | LogWithFields(logrus.Fields{ 200 | "type": "worker", 201 | "worker_id": i, 202 | }).Debugf("Spawned worker-%d.", i) 203 | } 204 | 205 | if err != nil { 206 | return Supervisor{}, err 207 | } 208 | return s, nil 209 | } 210 | 211 | // Shutdown supervisor 212 | func (s *Supervisor) Shutdown() { 213 | LogWithFields(logrus.Fields{ 214 | "type": "supervisor", 215 | }).Infoln("Waiting for stopping supervisor...") 216 | 217 | // Waiting for processing notification requests 218 | zeroCnt := 0 219 | tryCnt := 0 220 | for zeroCnt < RestartWaitCount { 221 | // if 's.counter' is not 0 potentially, here loop should not cancel to wait. 222 | if len(s.queue)+len(s.cmdq)+len(s.retryq)+s.workersAllQueueLength() > 0 { 223 | zeroCnt = 0 224 | tryCnt++ 225 | } else { 226 | zeroCnt++ 227 | tryCnt = 0 228 | } 229 | 230 | // force terminate application waiting for over 2 min. 231 | // RestartWaitCount: 50 232 | // ShutdownWaitTime: 10 (msec) 233 | // 40 * 50 * 6 * 10 (msec) / 1,000 / 60 = 2 (min) 234 | if tryCnt > RestartWaitCount*40*6 { 235 | break 236 | } 237 | 238 | time.Sleep(ShutdownWaitTime) 239 | } 240 | close(s.exit) 241 | close(s.cmdq) 242 | s.wgrp.Wait() 243 | close(s.queue) 244 | close(s.retryq) 245 | 246 | LogWithFields(logrus.Fields{ 247 | "type": "supervisor", 248 | }).Infoln("Stoped supervisor.") 249 | } 250 | 251 | func (s *Supervisor) spawnWorker(w Worker) { 252 | atomic.AddInt64(&(srvStats.Workers), 1) 253 | defer func() { 254 | atomic.AddInt64(&(srvStats.Workers), -1) 255 | close(w.respq) 256 | s.wgrp.Done() 257 | }() 258 | 259 | // Queue of SenderResopnse 260 | for i := 0; i < w.sn; i++ { 261 | w.wgrp.Add(1) 262 | LogWithFields(logrus.Fields{ 263 | "type": "worker", 264 | "worker_id": w.id, 265 | }).Debugf("Spawned a sender-%d-%d.", w.id, i) 266 | 267 | // spawnSender 268 | go spawnSender(w.queue, w.respq, w.wgrp, w.ac, w.fcv1) 269 | } 270 | 271 | func() { 272 | for { 273 | select { 274 | case reqs := <-s.queue: 275 | w.receiveRequests(reqs) 276 | case resp := <-w.respq: 277 | w.receiveResponse(resp, s.retryq, s.cmdq) 278 | case <-s.exit: 279 | return 280 | } 281 | } 282 | }() 283 | 284 | close(w.queue) 285 | w.wgrp.Wait() 286 | } 287 | 288 | func (w *Worker) receiveResponse(resp SenderResponse, retryq chan<- Request, cmdq chan Command) { 289 | req := resp.Req 290 | 291 | switch t := req.Notification.(type) { 292 | case apns.Notification: 293 | no := req.Notification.(apns.Notification) 294 | logf := logrus.Fields{ 295 | "type": "worker", 296 | "status": "-", 297 | "apns_id": "-", 298 | "token": no.Token, 299 | "payload": no.Payload, 300 | "worker_id": w.id, 301 | "res_queue_size": len(w.respq), 302 | "resend_cnt": req.Tries, 303 | "response_time": resp.RespTime, 304 | "resp_uid": resp.UID, 305 | } 306 | handleAPNsResponse(resp, retryq, cmdq, logf) 307 | case fcmv1.Payload: 308 | p := req.Notification.(fcmv1.Payload) 309 | logf := logrus.Fields{ 310 | "type": "worker", 311 | "token": p.Message.Token, 312 | "worker_id": w.id, 313 | "res_queue_size": len(w.respq), 314 | "resend_cnt": req.Tries, 315 | "response_time": resp.RespTime, 316 | "resp_uid": resp.UID, 317 | } 318 | handleFCMResponse(resp, retryq, cmdq, logf) 319 | default: 320 | LogWithFields(logrus.Fields{"type": "worker"}).Infof("Unknown response type:%s", t) 321 | } 322 | 323 | } 324 | 325 | func handleAPNsResponse(resp SenderResponse, retryq chan<- Request, cmdq chan Command, logf logrus.Fields) { 326 | req := resp.Req 327 | 328 | // Response handling 329 | if resp.Err != nil { 330 | atomic.AddInt64(&(srvStats.ErrCount), 1) 331 | if len(resp.Results) > 0 { 332 | result := resp.Results[0] 333 | for _, key := range result.ExtraKeys() { 334 | logf[key] = result.ExtraValue(key) 335 | } 336 | logf["status"] = result.Status() 337 | LogWithFields(logf).Errorf("%s", resp.Err) 338 | // Error handling 339 | onResponse(result, errorResponseHandler.HookCmd(), cmdq) 340 | } else { 341 | // if 'result' is nil, HTTP connection error with APNS. 342 | retry(retryq, req, errors.New("http connection error between APNs"), logf) 343 | } 344 | } else { 345 | atomic.AddInt64(&(srvStats.SentCount), 1) 346 | if len(resp.Results) > 0 { 347 | result := resp.Results[0] 348 | for _, key := range result.ExtraKeys() { 349 | logf[key] = result.ExtraValue(key) 350 | } 351 | if err := result.Err(); err != nil { 352 | atomic.AddInt64(&(srvStats.ErrCount), 1) 353 | 354 | // retry when provider auhentication token is expired 355 | if err.Error() == apns.ExpiredProviderToken.String() { 356 | retry(retryq, req, err, logf) 357 | } 358 | 359 | onResponse(result, errorResponseHandler.HookCmd(), cmdq) 360 | LogWithFields(logf).Errorf("%s", err) 361 | } else { 362 | onResponse(result, "", cmdq) 363 | LogWithFields(logf).Info("Succeeded to send a notification") 364 | } 365 | } 366 | } 367 | } 368 | 369 | func handleFCMResponse(resp SenderResponse, retryq chan<- Request, cmdq chan Command, logf logrus.Fields) { 370 | if resp.Err != nil { 371 | req := resp.Req 372 | LogWithFields(logf).Warnf("response is nil. reason: %s", resp.Err.Error()) 373 | retry(retryq, req, resp.Err, logf) 374 | return 375 | } 376 | 377 | for _, result := range resp.Results { 378 | // success when Error is nothing 379 | err := result.Err() 380 | if err == nil { 381 | atomic.AddInt64(&(srvStats.SentCount), 1) 382 | LogWithFields(logf).Info("Succeeded to send a notification") 383 | continue 384 | } 385 | switch err.Error() { 386 | case fcmv1.Internal, fcmv1.Unavailable: 387 | LogWithFields(logf).Warn("retrying:", err) 388 | retry(retryq, resp.Req, err, logf) 389 | case fcmv1.QuotaExceeded: 390 | LogWithFields(logf).Warn("retrying after 1 min:", err) 391 | time.AfterFunc(time.Minute, func() { retry(retryq, resp.Req, err, logf) }) 392 | case fcmv1.Unregistered, fcmv1.InvalidArgument, fcmv1.NotFound: 393 | LogWithFields(logf).Errorf("calling error hook: %s", err) 394 | atomic.AddInt64(&(srvStats.ErrCount), 1) 395 | onResponse(result, errorResponseHandler.HookCmd(), cmdq) 396 | default: 397 | atomic.AddInt64(&(srvStats.ErrCount), 1) 398 | LogWithFields(logf).Errorf("Unknown error message: %s", err) 399 | } 400 | } 401 | } 402 | 403 | func (w *Worker) receiveRequests(reqs *[]Request) { 404 | logf := logrus.Fields{ 405 | "type": "worker", 406 | "worker_id": w.id, 407 | "worker_queue_size": len(w.queue), 408 | "request_size": len(*reqs), 409 | } 410 | 411 | for _, req := range *reqs { 412 | select { 413 | case w.queue <- req: 414 | LogWithFields(logf). 415 | Debugf("Enqueue request into worker's queue") 416 | } 417 | } 418 | } 419 | 420 | func spawnSender(wq <-chan Request, respq chan<- SenderResponse, wgrp *sync.WaitGroup, ac *apns.Client, fcv1 *fcmv1.Client) { 421 | defer wgrp.Done() 422 | for req := range wq { 423 | var sres SenderResponse 424 | switch t := req.Notification.(type) { 425 | case apns.Notification: 426 | if ac == nil { 427 | LogWithFields(logrus.Fields{"type": "sender"}). 428 | Errorf("apns client is not present") 429 | continue 430 | } 431 | no := req.Notification.(apns.Notification) 432 | start := time.Now() 433 | results, err := ac.Send(no) 434 | respTime := time.Since(start).Seconds() 435 | rs := make([]Result, 0, len(results)) 436 | for _, v := range results { 437 | rs = append(rs, v) 438 | } 439 | sres = SenderResponse{ 440 | Results: rs, 441 | RespTime: respTime, 442 | Req: req, // Must copy 443 | Err: err, 444 | UID: uuid.NewV4().String(), 445 | } 446 | case fcmv1.Payload: 447 | if fcv1 == nil { 448 | LogWithFields(logrus.Fields{"type": "sender"}). 449 | Errorf("fcmv1 client is not present") 450 | continue 451 | } 452 | p := req.Notification.(fcmv1.Payload) 453 | start := time.Now() 454 | results, err := fcv1.Send(p) 455 | respTime := time.Since(start).Seconds() 456 | rs := make([]Result, 0, len(results)) 457 | for _, v := range results { 458 | rs = append(rs, v) 459 | } 460 | sres = SenderResponse{ 461 | Results: rs, 462 | RespTime: respTime, 463 | Req: req, 464 | Err: err, 465 | UID: uuid.NewV4().String(), 466 | } 467 | default: 468 | LogWithFields(logrus.Fields{"type": "sender"}). 469 | Errorf("Unknown request data type: %s", t) 470 | continue 471 | } 472 | 473 | select { 474 | case respq <- sres: 475 | LogWithFields(logrus.Fields{"type": "sender", "resp_queue_size": len(respq)}). 476 | Debugf("Enqueue response into respq.") 477 | default: 478 | LogWithFields(logrus.Fields{"type": "sender", "resp_queue_size": len(respq)}). 479 | Warnf("Response queue is full.") 480 | } 481 | } 482 | } 483 | 484 | func (s Supervisor) workersAllQueueLength() int { 485 | sum := 0 486 | for _, w := range s.workers { 487 | sum += len(w.queue) + len(w.respq) 488 | } 489 | return sum 490 | } 491 | 492 | func onResponse(result Result, cmd string, cmdq chan<- Command) { 493 | logf := logrus.Fields{ 494 | "provider": result.Provider(), 495 | "type": "on_response", 496 | "token": result.RecipientIdentifier(), 497 | } 498 | for _, key := range result.ExtraKeys() { 499 | logf[key] = result.ExtraValue(key) 500 | } 501 | // on error handler 502 | if err := result.Err(); err != nil { 503 | errorResponseHandler.OnResponse(result) 504 | } else { 505 | successResponseHandler.OnResponse(result) 506 | } 507 | 508 | if cmd == "" { 509 | return 510 | } 511 | 512 | b, _ := result.MarshalJSON() 513 | command := Command{ 514 | command: cmd, 515 | input: b, 516 | } 517 | select { 518 | case cmdq <- command: 519 | LogWithFields(logf).Debugf("Enqueue command: %s < %s", command.command, string(b)) 520 | default: 521 | LogWithFields(logf).Warnf("Command queue is full, so could not execute commnad: %v", command) 522 | } 523 | } 524 | 525 | func InvokePipe(hook string, src io.Reader) ([]byte, error) { 526 | logf := logrus.Fields{"type": "invoke_pipe"} 527 | cmd := exec.Command("sh", "-c", hook) 528 | 529 | stdin, err := cmd.StdinPipe() 530 | if err != nil { 531 | return nil, fmt.Errorf("failed: %v %s", cmd, err.Error()) 532 | } 533 | 534 | var b bytes.Buffer 535 | // merge std(out|err) of command to gunfish 536 | if OutputHookStdout { 537 | cmd.Stdout = os.Stdout 538 | } else { 539 | cmd.Stdout = &b 540 | } 541 | if OutputHookStderr { 542 | cmd.Stderr = os.Stderr 543 | } else { 544 | cmd.Stderr = &b 545 | } 546 | 547 | // src copy to cmd.stdin 548 | _, err = io.Copy(stdin, src) 549 | if e, ok := err.(*os.PathError); ok && e.Err == syscall.EPIPE { 550 | LogWithFields(logf).Errorf(e.Error()) 551 | } else if err != nil { 552 | LogWithFields(logf).Errorf("failed to write STDIN: cmd( %s ), error( %s )", hook, err.Error()) 553 | } 554 | stdin.Close() 555 | 556 | err = cmd.Run() 557 | return b.Bytes(), err 558 | } 559 | 560 | func retry(retryq chan<- Request, req Request, err error, logf logrus.Fields) { 561 | if req.Tries < SendRetryCount { 562 | req.Tries++ 563 | atomic.AddInt64(&(srvStats.RetryCount), 1) 564 | logf["resend_cnt"] = req.Tries 565 | 566 | select { 567 | case retryq <- req: 568 | LogWithFields(logf). 569 | Debugf("%s: Retry to enqueue into retryq.", err.Error()) 570 | default: 571 | LogWithFields(logf). 572 | Warnf("Supervisor retry queue is full.") 573 | } 574 | } else { 575 | LogWithFields(logf). 576 | Warnf("Retry count is over than %d. Could not deliver notification.", SendRetryCount) 577 | } 578 | } 579 | -------------------------------------------------------------------------------- /supervisor_test.go: -------------------------------------------------------------------------------- 1 | package gunfish_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "sync" 8 | "testing" 9 | "time" 10 | 11 | gunfish "github.com/kayac/Gunfish" 12 | "github.com/kayac/Gunfish/apns" 13 | "github.com/kayac/Gunfish/config" 14 | "github.com/sirupsen/logrus" 15 | ) 16 | 17 | var ( 18 | conf, _ = config.LoadConfig("./test/gunfish_test.toml") 19 | mu sync.Mutex 20 | ) 21 | 22 | type TestResponseHandler struct { 23 | scoreboard map[string]*int 24 | wg *sync.WaitGroup 25 | hook string 26 | } 27 | 28 | func (tr *TestResponseHandler) Done(token string) { 29 | tr.wg.Done() 30 | } 31 | 32 | func (tr *TestResponseHandler) Countup(name string) { 33 | mu.Lock() 34 | defer mu.Unlock() 35 | *(tr.scoreboard[name])++ 36 | } 37 | 38 | func (tr *TestResponseHandler) Get(name string) int { 39 | mu.Lock() 40 | defer mu.Unlock() 41 | return *(tr.scoreboard[name]) 42 | } 43 | 44 | func (tr TestResponseHandler) OnResponse(result gunfish.Result) { 45 | tr.wg.Add(1) 46 | if err := result.Err(); err != nil { 47 | tr.Countup(err.Error()) 48 | } else { 49 | tr.Countup("success") 50 | } 51 | tr.Done(result.RecipientIdentifier()) 52 | } 53 | 54 | func (tr TestResponseHandler) HookCmd() string { 55 | return tr.hook 56 | } 57 | 58 | func init() { 59 | logrus.SetLevel(logrus.WarnLevel) 60 | conf.Apns.Host = gunfish.MockServer 61 | gunfish.RetryBackoff = false // for testing 62 | } 63 | 64 | func TestEnqueuRequestToSupervisor(t *testing.T) { 65 | // Prepare 66 | wg := sync.WaitGroup{} 67 | score := make(map[string]*int, 5) 68 | boardList := []string{ 69 | apns.MissingTopic.String(), 70 | apns.BadDeviceToken.String(), 71 | apns.Unregistered.String(), 72 | apns.ExpiredProviderToken.String(), 73 | "success", 74 | } 75 | for _, v := range boardList { 76 | x := 0 77 | score[v] = &x 78 | } 79 | 80 | etr := TestResponseHandler{ 81 | wg: &wg, 82 | scoreboard: score, 83 | hook: conf.Provider.ErrorHook, 84 | } 85 | str := TestResponseHandler{ 86 | wg: &wg, 87 | scoreboard: score, 88 | } 89 | gunfish.InitErrorResponseHandler(etr) 90 | gunfish.InitSuccessResponseHandler(str) 91 | 92 | sup, err := gunfish.StartSupervisor(&conf) 93 | if err != nil { 94 | t.Errorf("cannot start supervisor: %s", err.Error()) 95 | } 96 | defer sup.Shutdown() 97 | 98 | // test success requests 99 | reqs := repeatRequestData("1122334455667788112233445566778811223344556677881122334455667788", 10) 100 | for range []int{0, 1, 2, 3, 4, 5, 6} { 101 | sup.EnqueueClientRequest(&reqs) 102 | } 103 | time.Sleep(time.Millisecond * 1000) 104 | wg.Wait() 105 | if g, w := str.Get("success"), 70; g != w { 106 | t.Errorf("not match success count: got %d want %d", g, w) 107 | } 108 | 109 | // test error requests 110 | testTable := []struct { 111 | errToken string 112 | num int 113 | msleep time.Duration 114 | errCode apns.ErrorResponseCode 115 | expect int 116 | }{ 117 | { 118 | errToken: "missingtopic", 119 | num: 1, 120 | msleep: 300, 121 | errCode: apns.MissingTopic, 122 | expect: 1, 123 | }, 124 | { 125 | errToken: "unregistered", 126 | num: 1, 127 | msleep: 300, 128 | errCode: apns.Unregistered, 129 | expect: 1, 130 | }, 131 | { 132 | errToken: "baddevicetoken", 133 | num: 1, 134 | msleep: 300, 135 | errCode: apns.BadDeviceToken, 136 | expect: 1, 137 | }, 138 | { 139 | errToken: "expiredprovidertoken", 140 | num: 1, 141 | msleep: 5000, 142 | errCode: apns.ExpiredProviderToken, 143 | expect: 1 * gunfish.SendRetryCount, 144 | }, 145 | } 146 | 147 | for _, tt := range testTable { 148 | reqs := repeatRequestData(tt.errToken, tt.num) 149 | sup.EnqueueClientRequest(&reqs) 150 | time.Sleep(time.Millisecond * tt.msleep) 151 | wg.Wait() 152 | 153 | errReason := tt.errCode.String() 154 | if g, w := str.Get(errReason), tt.expect; g != w { 155 | t.Errorf("not match %s count: got %d want %d", errReason, g, w) 156 | } 157 | } 158 | } 159 | 160 | func repeatRequestData(token string, num int) []gunfish.Request { 161 | var reqs []gunfish.Request 162 | for i := 0; i < num; i++ { 163 | // Create request 164 | aps := &apns.APS{ 165 | Alert: &apns.Alert{ 166 | Title: "test", 167 | Body: "message", 168 | }, 169 | Sound: "default", 170 | } 171 | payload := apns.Payload{} 172 | payload.APS = aps 173 | 174 | req := gunfish.Request{ 175 | Notification: apns.Notification{ 176 | Token: token, 177 | Payload: payload, 178 | }, 179 | Tries: 0, 180 | } 181 | 182 | reqs = append(reqs, req) 183 | } 184 | return reqs 185 | } 186 | 187 | func TestSuccessOrFailureInvoke(t *testing.T) { 188 | // prepare SenderResponse 189 | token := "invalid token" 190 | sre := fmt.Errorf(apns.Unregistered.String()) 191 | aps := &apns.APS{ 192 | Alert: apns.Alert{ 193 | Title: "test", 194 | Body: "hoge message", 195 | }, 196 | Badge: 1, 197 | Sound: "default", 198 | } 199 | payload := apns.Payload{} 200 | payload.APS = aps 201 | sr := gunfish.SenderResponse{ 202 | Req: gunfish.Request{ 203 | Notification: apns.Notification{ 204 | Token: token, 205 | Payload: payload, 206 | }, 207 | Tries: 0, 208 | }, 209 | RespTime: 0.0, 210 | Err: sre, 211 | } 212 | j, err := json.Marshal(sr) 213 | if err != nil { 214 | t.Errorf(err.Error()) 215 | } 216 | 217 | // Succeed to invoke 218 | src := bytes.NewBuffer(j) 219 | out, err := gunfish.InvokePipe(`cat`, src) 220 | if err != nil { 221 | t.Errorf("result: %s, err: %s", string(out), err.Error()) 222 | } 223 | 224 | // checks Unmarshaled result 225 | if string(out) == `{}` { 226 | t.Errorf("output of result is empty: %s", string(out)) 227 | } 228 | if string(out) != string(j) { 229 | t.Errorf("Expected result %s but got %s", j, string(out)) 230 | } 231 | 232 | // Failure to invoke 233 | src = bytes.NewBuffer(j) 234 | out, err = gunfish.InvokePipe(`expr 1 1`, src) 235 | if err == nil { 236 | t.Errorf("Expected failure to invoke command: %s", string(out)) 237 | } 238 | 239 | // tests command including Pipe '|' 240 | src = bytes.NewBuffer(j) 241 | out, err = gunfish.InvokePipe(`cat | head -n 10 | tail -n 10`, src) 242 | if err != nil { 243 | t.Errorf("result: %s, err: %s", string(out), err.Error()) 244 | } 245 | if string(out) != string(j) { 246 | t.Errorf("Expected result '%s' but got %s", j, string(out)) 247 | } 248 | 249 | // Must fail 250 | src = bytes.NewBuffer(j) 251 | out, err = gunfish.InvokePipe(`echo 'Failure test'; false`, src) 252 | if err == nil { 253 | t.Errorf("result: %s, err: %s", string(out), err.Error()) 254 | } 255 | if fmt.Sprintf("%s", err.Error()) != `exit status 1` { 256 | t.Errorf("invalid err message: %s", err.Error()) 257 | } 258 | 259 | // stdout be not captured 260 | gunfish.OutputHookStdout = true 261 | src = bytes.NewBuffer(j) 262 | out, err = gunfish.InvokePipe(`cat; echo 'this is error.' 1>&2`, src) 263 | if len(out) != 15 { 264 | t.Errorf("hooks stdout must not be captured: %s", out) 265 | } 266 | 267 | // stderr 268 | gunfish.OutputHookStderr = true 269 | src = bytes.NewBuffer(j) 270 | out, err = gunfish.InvokePipe(`cat; echo 'this is error.' 1>&2`, src) 271 | if len(out) != 0 { 272 | t.Errorf("hooks stderr must not be captured: %s", out) 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /test/gunfish_test.toml: -------------------------------------------------------------------------------- 1 | [provider] 2 | error_hook = "{{ env `TEST_GUNFISH_HOOK_CMD` `cat ` }}" 3 | max_connections = 2000 4 | max_request_size = 1000 5 | port = 38103 6 | queue_size = 200 7 | worker_num = 8 8 | 9 | [apns] 10 | cert_file = "{{ env `PROJECT_ROOT` `.` }}/test/server.crt" 11 | key_file = "{{ env `PROJECT_ROOT` `.` }}/test/server.key" 12 | request_per_sec = 2000 13 | sender_num = 50 14 | 15 | [fcm_v1] 16 | # google_application_credentials = "{{ env `PROJECT_ROOT` `.` }}/credentials.json" 17 | enabled = true 18 | endpoint = "http://localhost:8888/v1/projects" 19 | projectid = "test" 20 | -------------------------------------------------------------------------------- /test/invalid.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDaDCCAlACCQC5OU/g5iJR8TANBgkqhkiG9w0BAQsFADB2MQswCQYDVQQGEwJK 3 | UDERMA8GA1UECAwIS2FuYWdhd2ExEjAQBgNVBAcMCVRlc3QgVG93bjEVMBMGA1UE 4 | CgwMVGVzdCBDb21wYW55MRUwEwYDVQQLDAxUZXN0IFNlY3Rpb24xEjAQBgNVBAMM 5 | CWxvY2FsaG9zdDAeFw0xNjAyMDgwODU3MTVaFw0yNjAyMDUwODU3MTVaMHYxCzAJ 6 | BgNVBAYTAkpQMREwDwYDVQQIDAhLYW5hZ2F3YTESMBAGA1UEBwwJVGVzdCBUb3du 7 | MRUwEwYDVQQKDAxUZXN0IENvbXBhbnkxFTATBgNVBAsMDFRlc3QgU2VjdGlvbjES 8 | MBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC 9 | AQEA7lbvzh/62z/cIX+e3+qcXGEctykqplvbAG0XwTLbr/IqR5UdXvMa54lyUYKY 10 | mrVwSrhPRHz3aqzim9uX2ui4Yrj+BfZUxplF82i0b2hgWGksiiCeeVCSKwANZnHy 11 | RP593cevtv4odoiI9mt06NlzgBdcqK63EPoT0TkZsZaLdATbVdTBsz3YZx3LcXcL 12 | t/CgVXhEP7O4w7oRLT4+biEYMdo9abNlmbSl5yxncalmiUxdZs0lGMc20WNQeyQx 13 | v91Pu6GDQKXHUyadiS5EH1INgaUiRaqcqGS8+wo1ZtJa08TDbIPyp5Nvl5J1WVf7 14 | z/cNu0PuAR6WK7IcjzmLxitGCQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCC23en 15 | jEcTuwE3pAyfz0Zlx7U5wkXvZfefF0QMzUCBZAoAPNBOnHc0iAsn/mMJ5UTDnQC8 16 | TQFtqwiLLgi+mDfge7ejBAGe1drMP27JqyHKRw/clyb1pJu9nJSiFl5zdGetztSa 17 | Spn/oGOyvZW33FhRrmPexnNWWoyte1gZz9MEwlYHNe7Mo46A3a7ekvt/acsfWgWM 18 | yLnbJYevkFeA9sU4aXwd64EkFBick4oOavEMsIYExzr8WZrlXkmNi+B1ToWqDRqX 19 | UGbCwhDbCwAy/Uek1XYq6fIPTFGHDic2l8jZB3xMQvrZxPbkJwoYnHx17teF6Vxa 20 | KOnWY5Td0RA4E03M 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /test/invalid.csr: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE REQUEST----- 2 | MIICuzCCAaMCAQAwdjELMAkGA1UEBhMCSlAxETAPBgNVBAgMCEthbmFnYXdhMRIw 3 | EAYDVQQHDAlUZXN0IFRvd24xFTATBgNVBAoMDFRlc3QgQ29tcGFueTEVMBMGA1UE 4 | CwwMVGVzdCBTZWN0aW9uMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3 5 | DQEBAQUAA4IBDwAwggEKAoIBAQDuVu/OH/rbP9whf57f6pxcYRy3KSqmW9sAbRfB 6 | Mtuv8ipHlR1e8xrniXJRgpiatXBKuE9EfPdqrOKb25fa6LhiuP4F9lTGmUXzaLRv 7 | aGBYaSyKIJ55UJIrAA1mcfJE/n3dx6+2/ih2iIj2a3To2XOAF1yorrcQ+hPRORmx 8 | lot0BNtV1MGzPdhnHctxdwu38KBVeEQ/s7jDuhEtPj5uIRgx2j1ps2WZtKXnLGdx 9 | qWaJTF1mzSUYxzbRY1B7JDG/3U+7oYNApcdTJp2JLkQfUg2BpSJFqpyoZLz7CjVm 10 | 0lrTxMNsg/Knk2+XknVZV/vP9w27Q+4BHpYrshyPOYvGK0YJAgMBAAGgADANBgkq 11 | hkiG9w0BAQsFAAOCAQEAieZMhlcQrQTiaJ8n2lYEZohZ2mGy2EsRh2FOf/sreR+H 12 | WjGcVDQdYKcziUqW8zWC+QTX1+2BxAKWRnbRc95ZPkYkHbYw6vjKQQUkNNGXcEKD 13 | 6pqSdtCfnFzbrRDFXmwR41n8gnoP5+yQUD2DH1QmJFDAIss4mRCH6LAsYjBDZRWU 14 | hLLPluCFBy3Z5PhzIQuFBFUaGluwUP6IP8vGTY00O/mv27ptioMgNaOINSkX68Fz 15 | ZyYXoEj0yGBHxic9HfHKCR8Mp6fr/mxq3/qI4WBYm8jzpLWzKbWC81NaczzLCKOQ 16 | qMm+uX4IAmpMZdRmdv64L6zG8HL3RfelUjDatvlHQA== 17 | -----END CERTIFICATE REQUEST----- 18 | -------------------------------------------------------------------------------- /test/invalid.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpQIBAAKCAQEA7lbvzh/62z/cIX+e3+qcXGEctykqplvbAG0XwTLbr/IqR5Ud 3 | XvMa54lyUYKYmrVwSrhPRHz3aqzim9uX2ui4Yrj+BfZUxplF82i0b2hgWGksiiCe 4 | eVCSKwANZnHyRP593cevtv4odoiI9mt06NlzgBdcqK63EPoT0TkZsZaLdATbVdTB 5 | sz3YZx3LcXcLt/CgVXhEP7O4w7oRLT4+biEYMdo9abNlmbSl5yxncalmiUxdZs0l 6 | GMc20WNQeyQxv91Pu6GDQKXHUyadiS5EH1INgaUiRaqcqGS8+wo1ZtJa08TDbIPy 7 | p5Nvl5J1WVf7z/cNu0PuAR6WK7IcjzmLxitGCQIDAQABAoIBABgIDAXsk46n0bpO 8 | 8+X/8eQeppaRQAumF17joRGJ3zzOXhT5pAx+1qeh5DTzxg9TXA8splFxiEDpTbAJ 9 | ZDZeYLkTjglr3QBpU/RHCmpxS8WeVS9YOqJgzVwolTFPK+5o+qfyCnWZCttoyOaP 10 | zynrQwoXUPBxLWQ40ua1qzGMzGLWKLdHd6UKn//XKKyznMp5aBlW5Ki/3AHrosVp 11 | 1xFwBtbftMrA2ey4Lvu7a0l5kpovwly/OvghcMG16OXZY9tvbXw1Usr7ckCc/zB1 12 | i7vV4w1rLFkCNmXB4jnxYTg/05G6te5dTWtPgxAHXkfpU6PjXx0VoV9s7RpQYWhW 13 | rsgvxEECgYEA+D7iqerpfs69P1iMaGUVqmw9pHUeZCMiOWXjiWPzhQMPPG7VjZQ0 14 | EWlrapxBkNkD5jKrTa23YoDf9fnkpqJ/3a9RGSuQtu29OMS2GREQRzc18I103FvX 15 | ki7ibdih7Ca3Zzbo4+Ieo7x2xCq6ZWUkwMlMR06xJ6Nl3WrkRuaCn/cCgYEA9cjW 16 | Pbqa0XEMiXfl7mZzWFyPny9x0IeAufwsWrlSchAqoR36y7fwUgPb/UONLY/1ie9E 17 | QSDRf+RmbeJ6DbeU4wfdx6snxE6Bk1rpIRWh5SfKjep5Vp1ufCj+b8hqPaWuOcQM 18 | CNvj0DZxTCx2FtXI4u6vMBveYTwnOJsIIZXzyf8CgYEAsv30xPuifFJo1gHsy2EH 19 | bCg7khb4YM+MX8J9e5TcA24fUD3CMSFJIbzXPLmJ9Pzk+NhT9+Bnt9igo2UZXqUQ 20 | eTFt0i49XAizRPlhK1XIXPEMLXRxbGm0V60Cip2GsxV/bCaFabqiyQCcyfjdCTsS 21 | cwcxvsCYr7H7QtlN22ldiiUCgYEAjOoWmtGPzaCo9W++bg5i9zgqR7Pl5w6pKPiB 22 | XYp+0FKgfjs3/PB6YitAR1YhbQvqVKjPUx/DvTVv3HRKUe7896Uc7Esew5fXBmrK 23 | 2mMSrNVBdlgGNTiRjbHbHq+i6bFB0HCsDbA0Tr3H+0pKchEj2afK3SQ9PTZFrliE 24 | Mu1MFnUCgYEA807afx4Te6wL5Zx5pFa1Cmr+jgK7YPIrqTjhSdovYn6jHJFsTmFq 25 | 4JCK5xf7UbjivOowNgrl5+eV3ZUFGcCmlJPuXaYOHaTgMSaCkwbjrcUD1XyaF5xj 26 | Ku0H5Wi6QU7iD4d/Z62fnjxboV3xyoXm2jMYyVPsVi7vcAwEYhRVc7I= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /test/scripts/curl_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | no=$1 5 | port=38103 # default is test server port number 6 | token=$3 7 | if [[ $2 != "" ]] ; then 8 | port=$2 9 | fi 10 | 11 | usage(){ 12 | cat < : 14 | send: Success curl. 15 | send_many: Send 250 notification. 16 | stats_app: Get report stats. 17 | stats_profile: Get go application resource stats. 18 | EOF 19 | } 20 | 21 | send_many(){ 22 | payload='[{"token": "sendtoomanytoken", "payload": {"aps": {"alert": "send too many", "sound": "test"}, "u":"a", "t":"a"}}' 23 | for cnt in $(seq 2 250) 24 | do 25 | payload=$payload',{"token": "sendtoomanytoken", "payload": {"aps": {"alert": "send too many", "sound": "test"}, "u":"a", "t":"a"}}' 26 | done 27 | payload=$payload"]" 28 | curl -s -X POST -d "$payload" -H "Content-Type: application/json" http://localhost:$port/push/apns 29 | } 30 | 31 | if [[ "$no" == "send" ]] ; then 32 | if [[ "$token" == "" ]] ; then 33 | echo "required device token." 34 | usage 35 | else 36 | payload='[{"token": "'$token'", "payload": {"aps": {"alert": "push notification test", "sound": "default", "badge": 1, "category": "sns", "content-available": 1}, "u":"a", "t":"a"}}]' 37 | curl -X POST -d "$payload" -H "Content-Type: application/json" http://localhost:$port/push/apns 38 | fi 39 | elif [[ "$no" == "send_many" ]] ; then 40 | send_many 41 | elif [[ "$no" == "stats_app" ]] ; then 42 | curl -s -X GET http://localhost:$port/stats/app 43 | elif [[ "$no" == "stats_profile" ]] ; then 44 | curl -s -X GET http://localhost:$port/stats/profile 45 | elif [[ "$no" == "loop_send_many" ]] ; then 46 | while : ; 47 | do 48 | sleep 1 49 | clear 50 | send_many | jq -r . 51 | done 52 | elif [[ "$no" == "loop_stats_app" ]] ; then 53 | while : ; 54 | do 55 | sleep 1 56 | clear 57 | curl -s -X GET http://localhost:$port/stats/app | jq -r . 58 | done 59 | elif [[ "$no" == "loop_stats_profile" ]] ; then 60 | while : ; 61 | do 62 | sleep 1 63 | clear 64 | curl -s -X GET http://localhost:$port/stats/profile | jq -r . 65 | done 66 | else 67 | echo "Do nothing." 68 | usage 69 | fi 70 | -------------------------------------------------------------------------------- /test/scripts/gen_test_cert.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | greadlink=$(which greadlink) 4 | readlink=${greadlink:-readlink} 5 | 6 | set -e 7 | 8 | script_path=$(dirname $($readlink -f $0)) 9 | gen_path=$script_path/.. 10 | #------------------------------------------------------------ 11 | # Creates secrete key 12 | #------------------------------------------------------------ 13 | rm -rf $gen_path/server* 14 | openssl genrsa 2048 > $gen_path/server.key 15 | 16 | #------------------------------------------------------------ 17 | # Country Name (2 letter code) [AU]: 18 | # State or Province Name (full name) [Some-State]: 19 | # Locality Name (eg, city) []: 20 | # Organization Name (eg, company) [Internet Widgits Pty Ltd]: 21 | # Organizational Unit Name (eg, section) []: 22 | # Common Name (e.g. server FQDN or YOUR name) []: 23 | # Email Address []: 24 | # 25 | # Please enter the following 'extra' attributes 26 | # to be sent with your certificate request 27 | # A challenge password []: 28 | # An optional company name []: 29 | #------------------------------------------------------------ 30 | openssl req -new -key $gen_path/server.key < $gen_path/server.csr 31 | JP 32 | Kanagawa 33 | Test Town 34 | Test Company 35 | Test Section 36 | localhost 37 | 38 | 39 | 40 | EOF 41 | 42 | #------------------------------------------------------------ 43 | # Creates server certification 44 | #------------------------------------------------------------ 45 | openssl x509 -days 3650 -req -signkey $gen_path/server.key < $gen_path/server.csr > $gen_path/server.crt 46 | -------------------------------------------------------------------------------- /test/tools/apnsmock/apnsmock.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/kayac/Gunfish/mock" 10 | ) 11 | 12 | func main() { 13 | var ( 14 | port int 15 | keyFile, certFile string 16 | verbose bool 17 | ) 18 | 19 | flag.IntVar(&port, "port", 2195, "apns mock server port") 20 | flag.StringVar(&keyFile, "cert-file", "", "apns mock server key file") 21 | flag.StringVar(&certFile, "key-file", "", "apns mock server cert file") 22 | flag.BoolVar(&verbose, "verbose", false, "verbose flag") 23 | flag.Parse() 24 | 25 | mux := mock.APNsMockServer(verbose) 26 | log.Println("start apnsmock server") 27 | if err := http.ListenAndServeTLS(fmt.Sprintf(":%d", port), keyFile, certFile, mux); err != nil { 28 | log.Fatal(err) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/tools/fcmv1mock/fcmv1mock.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/kayac/Gunfish/mock" 10 | ) 11 | 12 | func main() { 13 | var ( 14 | port int 15 | projectID string 16 | verbose bool 17 | ) 18 | 19 | flag.IntVar(&port, "port", 8888, "fcmv1 mock server port") 20 | flag.StringVar(&projectID, "project-id", "test", "fcmv1 mock project id") 21 | flag.BoolVar(&verbose, "verbose", false, "verbose flag") 22 | flag.Parse() 23 | 24 | mux := mock.FCMv1MockServer(projectID, verbose) 25 | log.Println("start fcmv1mock server port:", port, "project_id:", projectID) 26 | if err := http.ListenAndServe(fmt.Sprintf(":%d", port), mux); err != nil { 27 | log.Fatal(err) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/tools/gunfish-cli/gunfish-cli.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "flag" 8 | "fmt" 9 | "io" 10 | "log" 11 | "net/http" 12 | "os" 13 | "strings" 14 | ) 15 | 16 | func main() { 17 | if err := run(); err != nil { 18 | log.Fatal(err) 19 | } 20 | } 21 | 22 | func run() error { 23 | 24 | var ( 25 | typ string 26 | port int 27 | host string 28 | apnsTopic string 29 | count int 30 | message string 31 | sound string 32 | options string 33 | token string 34 | dryrun bool 35 | verbose bool 36 | jsonFile string 37 | ) 38 | 39 | flag.StringVar(&typ, "type", "apns", "push notification type. 'apns' or 'fcm' (fcm not implemented)") 40 | flag.IntVar(&count, "count", 1, "send count") 41 | flag.IntVar(&port, "port", 8003, "gunfish port") 42 | flag.StringVar(&host, "host", "localhost", "gunfish host") 43 | flag.StringVar(&apnsTopic, "apns-topic", "", "apns topic") 44 | flag.StringVar(&message, "message", "test notification", "push notification message") 45 | flag.StringVar(&sound, "sound", "default", "push notification sound (default: 'default')") 46 | flag.StringVar(&options, "options", "", "options (key1=value1,key2=value2...)") 47 | flag.StringVar(&token, "token", "", "apns device token (required)") 48 | flag.BoolVar(&dryrun, "dryrun", false, "dryrun") 49 | flag.BoolVar(&verbose, "verbose", false, "dryrun") 50 | flag.StringVar(&jsonFile, "json-file", "", "json input file") 51 | 52 | flag.Parse() 53 | 54 | switch typ { 55 | case "apns": 56 | // OK 57 | case "fcm": 58 | return errors.New("[ERROR] not implemented") 59 | default: 60 | return errors.New("[ERROR] wrong push notification type") 61 | } 62 | 63 | if verbose { 64 | log.Printf("host: %s, port: %d, send count: %d", host, port, count) 65 | } 66 | 67 | opts := map[string]string{} 68 | payloads := make([]map[string]interface{}, count) 69 | if jsonFile == "" { 70 | if options != "" { 71 | for _, opt := range strings.Split(options, ",") { 72 | kv := strings.Split(opt, "=") 73 | key, val := kv[0], kv[1] 74 | opts[key] = val 75 | } 76 | } 77 | 78 | for i := 0; i < count; i++ { 79 | payloads[i] = map[string]interface{}{} 80 | payloads[i] = buildPayload(token, message, sound, apnsTopic, opts) 81 | } 82 | } 83 | 84 | if dryrun { 85 | log.Println("[dryrun] checks request payload:") 86 | if jsonFile == "" { 87 | out, err := json.MarshalIndent(payloads, "", " ") 88 | if err != nil { 89 | return err 90 | } 91 | fmt.Println(string(out)) 92 | } 93 | 94 | out, err := os.ReadFile(jsonFile) 95 | if err != nil { 96 | return err 97 | } 98 | fmt.Println(string(out)) 99 | return nil 100 | } 101 | 102 | if verbose && len(payloads) > 0 { 103 | log.Printf("post data: %#v", payloads) 104 | } 105 | endpoint := fmt.Sprintf("http://%s:%d/push/apns", host, port) 106 | req, err := newRequest(endpoint, jsonFile, payloads) 107 | if err != nil { 108 | return err 109 | } 110 | req.Header.Set("content-type", "application/json") 111 | 112 | resp, err := http.DefaultClient.Do(req) 113 | if err != nil { 114 | return err 115 | } 116 | defer resp.Body.Close() 117 | 118 | out, err := io.ReadAll(resp.Body) 119 | if err != nil { 120 | return err 121 | } 122 | fmt.Println(string(out)) 123 | return nil 124 | } 125 | 126 | func buildPayload(token, message, sound, apnsTopic string, opts map[string]string) map[string]interface{} { 127 | payload := map[string]interface{}{ 128 | "aps": map[string]string{ 129 | "alert": message, 130 | "sound": sound, 131 | }, 132 | } 133 | for k, v := range opts { 134 | payload[k] = v 135 | } 136 | 137 | return map[string]interface{}{ 138 | "payload": payload, 139 | "token": token, 140 | "header": map[string]interface{}{ 141 | "apns-topic": apnsTopic, 142 | }, 143 | } 144 | } 145 | 146 | func newRequest(endpoint, jsonFile string, payloads []map[string]interface{}) (*http.Request, error) { 147 | if jsonFile == "" { 148 | b := &bytes.Buffer{} 149 | err := json.NewEncoder(b).Encode(payloads) 150 | if err != nil { 151 | return nil, err 152 | } 153 | return http.NewRequest(http.MethodPost, endpoint, b) 154 | } 155 | 156 | b, err := os.Open(jsonFile) 157 | if err != nil { 158 | return nil, err 159 | } 160 | return http.NewRequest(http.MethodPost, endpoint, b) 161 | } 162 | --------------------------------------------------------------------------------