├── .codeclimate.yml ├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ └── golangci-lint.yml ├── .gitignore ├── README.md ├── TODO.org ├── api.go ├── client.go ├── client_test.go ├── cmd └── ejabberd │ └── main.go ├── doc.go ├── go.mod ├── go.sum ├── helpers.go ├── helpers_test.go ├── jid.go ├── jid_test.go ├── oauth_token.go └── url.go /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" # required to adjust maintainability checks 2 | plugins: 3 | golint: 4 | enabled: true 5 | gofmt: 6 | enabled: true 7 | govet: 8 | enabled: true 9 | fixme: 10 | enabled: true 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: '10:00' 8 | open-pull-requests-limit: 10 9 | 10 | - package-ecosystem: "github-actions" 11 | directory: "/" 12 | schedule: 13 | interval: monthly 14 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Check out code into the Go module directory 11 | uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version: 1.21 19 | 20 | - name: Test 21 | run: go test -v ./... 22 | 23 | - name: Build 24 | run: go build -v . 25 | 26 | - name: Install 27 | run: go install -v github.com/processone/ejabberd-api/cmd/ejabberd@${{ github.sha }} 28 | 29 | - name: Run goreleaser in release mode 30 | if: success() && startsWith(github.ref, 'refs/tags/v') 31 | uses: goreleaser/goreleaser-action@v6 32 | with: 33 | version: latest 34 | args: release --rm-dist 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | golangci: 7 | name: lint 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - name: golangci-lint 13 | uses: golangci/golangci-lint-action@v6 14 | with: 15 | version: v1.54 16 | -------------------------------------------------------------------------------- /.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 | .DS_Store 27 | .idea/ 28 | .ejabberd-oauth.json 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ejabberd API library and command-line tool 2 | 3 | [![Build Status](https://semaphoreci.com/api/v1/processone/ejabberd-api/branches/master/shields_badge.svg)](https://semaphoreci.com/processone/ejabberd-api) 4 | [![Coverage Status](https://coveralls.io/repos/github/processone/ejabberd-api/badge.svg?branch=master)](https://coveralls.io/github/processone/ejabberd-api?branch=master) 5 | 6 | This tool is composed of two components: 7 | 8 | - A command-line tool to interact with ejabberd through ReST API calls 9 | from the command-line, from any server type or desktop (Linux, OSX, 10 | Windows). 11 | - An implementation of ejabberd API client library in Go. It can be 12 | used to interact with ejabberd from backend applications developed 13 | in Go programming language. 14 | 15 | ## Installation 16 | 17 | Both the library and the command-line tool can be installed from 18 | source with `go get` tool. 19 | 20 | If you have go installed in your environment you can install 21 | `ejabberd` command-line tool with: 22 | 23 | ```bash 24 | go install -v github.com/processone/ejabberd-api/cmd/ejabberd@master 25 | ``` 26 | Once the get completes, you should find your new `ejabberd` (or `ejabberd.exe`) executable sitting inside `$GOPATH/bin/`. 27 | 28 | ## Configuring ejabberd OAuth API 29 | 30 | Before being able to interact with ejabberd API, you need to configure 31 | ejabberd with OAuth support enabled. This is is documented in 32 | [ejabberd OAuth support](https://docs.ejabberd.im/developer/ejabberd-api/oauth/). 33 | 34 | Here are example entries to check / change in your ejabberd 35 | configuration file: 36 | 37 | 1. Add a [listener](https://docs.ejabberd.im/admin/configuration/listen/) for OAuth and ReST API: 38 | 39 | ```yaml 40 | listen: 41 | - 42 | # Using a separate port for oauth and API to make it easy to protect it 43 | # differently than BOSH and Websocket HTTP interface. 44 | port: 5281 45 | # oauth and API only listen on localhost interface for security reason 46 | # You can set ip to 0.0.0.0 to open it widely, but be careful! 47 | ip: 127.0.0.1 48 | module: ejabberd_http 49 | request_handlers: 50 | /oauth: ejabberd_oauth 51 | /api: mod_http_api 52 | ``` 53 | 54 | 2. You can then configure the OAuth commands you want to expose. Check 55 | [`api_permissions`](https://docs.ejabberd.im/developer/ejabberd-api/permissions/) 56 | to make sure ACL for passing commands as admins are set properly: 57 | 58 | ```yaml 59 | api_permissions: 60 | "admin access": 61 | who: 62 | oauth: 63 | scope: "ejabberd:admin" 64 | access: 65 | allow: 66 | user: admin@localhost 67 | what: 68 | - "register" 69 | - "change_password" 70 | 71 | # Tokens are valid for a year as default: 72 | oauth_expire: 31536000 73 | oauth_access: all 74 | ``` 75 | 76 | 3. Finally, make sure to enable the modules that provide the commands you 77 | want to use, for example: 78 | 79 | ```yaml 80 | modules: 81 | mod_admin_extra: {} 82 | ``` 83 | 84 | ## ejabberd command-line tool 85 | 86 | The `ejabberd` command-line tool allows you to interact with your ejabberd server 87 | by using ejabberd's ReST API. It relies on OAuth tokens and scope to define the command 88 | the user will be allowed to call. 89 | 90 | ### Usage 91 | 92 | 1. Generating an OAuth token: 93 | 94 | To use the `ejabberd` command-line tool, you first need to generate an OAuth 95 | token. 96 | 97 | It can be done, for example, with the following command: 98 | 99 | ```bash 100 | ejabberd token -j admin@localhost -p mypassword -s ejabberd:admin 101 | ``` 102 | 103 | This will generate a `.ejabberd-oauth.json` file containing your 104 | credentials. Keep the file secret, as it will grant access to command 105 | available in the requested scope on your behalf. 106 | 107 | 2. Calling ejabberd API from the command-line, using your token file. For example: 108 | 109 | ```bash 110 | ejabberd stats registeredusers 111 | ``` 112 | 113 | 3. You can also try to call any available command thanks to the generic `call` command. Do not forget `-a` parameter for commands that requires admin rights. For example: 114 | 115 | ```bash 116 | cat register.json 117 | {"user":"test1", "host":"localhost", "password":"somePassW0rd"} 118 | 119 | ejabberd call --name register -a --data-file=register.json 120 | ``` 121 | 122 | ### Generating Bash/ZSH completion 123 | 124 | You can generate Bash completion with following command: 125 | 126 | ```bash 127 | ./ejabberd --completion-script-bash 128 | ``` 129 | 130 | You can generate ZSH completion with following command: 131 | 132 | ```bash 133 | ./ejabberd --completion-script-zsh 134 | ``` 135 | 136 | To be able to use completion for Bash, you can type or add in your 137 | `bash_profile` (or equivalent): 138 | 139 | ```bash 140 | eval "$(ejabberd --completion-script-bash)" 141 | ``` 142 | 143 | For ZSH, you can use: 144 | 145 | ```bash 146 | eval "$(ejabberd --completion-script-zsh)" 147 | ``` 148 | 149 | ### Available commands 150 | 151 | * **token**: Get OAuth token. This is needed before calling others commands. 152 | * **stats**: Retrieve some stats from ejabberd. 153 | 154 | To get a full list of commands and their options: 155 | 156 | ```bash 157 | ejabberd --help-long 158 | ``` 159 | 160 | ### OAuth Token file format 161 | 162 | As a default, the token is stored in a file called 163 | `./.ejabberd-oauth.json` when using the command `token` and read from 164 | the same file when you use any other commands. 165 | 166 | Option `-f file` will let you point to another file. 167 | 168 | The file contains a JSON structure with the following fields: 169 | 170 | * **access_token**: Actual token value. 171 | * **endpoint**: Base URL. 172 | * **jid**: JID for which user the token was generated. 173 | * **scope**: OAuth scope for which the token was generated. 174 | * **expiration**: Expiration date for the token. 175 | 176 | For example: 177 | 178 | ```json 179 | {"access_token":"AaQTb0PUZqeZhFKYoaTQBb4KKkCTAolE", 180 | "endpoint":"http://localhost:5281/", 181 | "jid":"admin@localhost", 182 | "scope":"ejabberd:admin", 183 | "expiration":"2017-07-23T13:53:08.326421575+02:00"} 184 | ``` 185 | 186 | ## Development 187 | 188 | ### Local build 189 | 190 | ```bash 191 | go build -o ejabberd cmd/ejabberd/main.go 192 | ``` 193 | 194 | ### Running tests 195 | 196 | You can run tests from repository clone with command: 197 | 198 | ```bash 199 | go test -race -v ./. 200 | ``` 201 | -------------------------------------------------------------------------------- /TODO.org: -------------------------------------------------------------------------------- 1 | #+STARTUP: indent 2 | #+STARTUP: content 3 | #+STARTUP: lognotestate 4 | 5 | * ejabberd API library - TODO 6 | 7 | * ejabberd command-line 8 | 9 | ** Add a way to call any command by posting any JSON payload to any API endpoint. 10 | ** Add prompt for password on oauth token generation with -P 11 | ** Verbose mode to help debug request. 12 | ** Option to print value as text or JSON. 13 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | package ejabberd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | ) 9 | 10 | // Response is the common interface for all ejabberd API call results. 11 | type Response interface { 12 | JSON() string 13 | } 14 | 15 | // request is the common interface to all ejabberd requests. It is 16 | // passed to the ejabberd.Client Call methods to get parameters to 17 | // make the call and parse responses from the server. 18 | type request interface { 19 | params() (apiParams, error) 20 | parseResponse([]byte) (Response, error) 21 | } 22 | 23 | // apiParams gathers all values needed by the client to encode actual 24 | // ejabberd API call. An ejabberd API commands should return apiParams 25 | // struct when being issued params call. 26 | type apiParams struct { 27 | name string 28 | version int 29 | admin bool // = Flag to mark if API requires admin header 30 | 31 | method string 32 | query url.Values 33 | body []byte 34 | } 35 | 36 | //============================================================================== 37 | 38 | // TODO: Move into a api_stats file 39 | 40 | // Wraps various ejabberd call that all returns stats 41 | // From ejabberd mod_admin_extra 42 | 43 | // Stats is the data structure returned by ejabberd Stats API call. 44 | type Stats struct { 45 | Name string `json:"name"` 46 | Value int `json:"stat"` 47 | } 48 | 49 | // JSON converts Stats data structure to JSON string. 50 | func (s Stats) JSON() string { 51 | body, _ := json.Marshal(s) 52 | return string(body) 53 | } 54 | 55 | // String represents Stats data structure as a human readable value. 56 | func (s Stats) String() string { 57 | return fmt.Sprintf("%d", s.Value) 58 | } 59 | 60 | type statsRequest struct { 61 | Name string `json:"name"` 62 | } 63 | 64 | func (s statsRequest) params() (apiParams, error) { 65 | switch s.Name { 66 | case "": 67 | return apiParams{}, fmt.Errorf("required argument 'name' not provided") 68 | case "registeredusers", "onlineusers", "onlineusersnode", "uptimeseconds", "processes": 69 | return s.paramsStats() 70 | default: 71 | return apiParams{}, fmt.Errorf("unknow statistic: %s", s.Name) 72 | } 73 | } 74 | 75 | func (s statsRequest) paramsStats() (apiParams, error) { 76 | var query url.Values 77 | 78 | body, err := json.Marshal(s) 79 | 80 | if err != nil { 81 | return apiParams{}, err 82 | } 83 | 84 | return apiParams{ 85 | name: "stats", 86 | version: 1, 87 | 88 | admin: true, 89 | method: "POST", 90 | query: query, 91 | body: body, 92 | }, nil 93 | } 94 | 95 | func (s statsRequest) parseResponse(body []byte) (Response, error) { 96 | var resp Stats 97 | err := json.Unmarshal(body, &resp) 98 | if err != nil { 99 | return resp, APIError{Code: 99, Message: err.Error()} 100 | } 101 | resp.Name = s.Name 102 | return resp, err 103 | } 104 | 105 | //============================================================================== 106 | 107 | // From ejabberd_admin 108 | 109 | // Register contains the message return by server after successful 110 | // user registration. 111 | type Register string 112 | 113 | // JSON represents Register result as a JSON string. Can be useful for 114 | // further processing. 115 | func (r Register) JSON() string { 116 | body, _ := json.Marshal(r) 117 | return string(body) 118 | } 119 | 120 | type registerRequest struct { 121 | JID string `json:"jid"` 122 | Password string `json:"password"` 123 | } 124 | 125 | func (r registerRequest) params() (apiParams, error) { 126 | var query url.Values 127 | 128 | jid, err := parseJID(r.JID) 129 | if err != nil { 130 | return apiParams{}, err 131 | } 132 | 133 | // Actual parameters for ejabberd. We expose JID string as it is 134 | // easier to manipulate from a client. 135 | type register struct { 136 | User string `json:"user"` 137 | Host string `json:"host"` 138 | Password string `json:"password"` 139 | } 140 | 141 | data := register{ 142 | User: jid.username, 143 | Host: jid.domain, 144 | Password: r.Password, 145 | } 146 | 147 | body, err := json.Marshal(data) 148 | if err != nil { 149 | return apiParams{}, err 150 | } 151 | 152 | return apiParams{ 153 | name: "register", 154 | version: 1, 155 | admin: true, 156 | 157 | method: "POST", 158 | query: query, 159 | body: body, 160 | }, nil 161 | } 162 | 163 | func (r registerRequest) parseResponse(body []byte) (Response, error) { 164 | var resp Register 165 | err := json.Unmarshal(body, &resp) 166 | if err != nil { 167 | return resp, APIError{Code: 99, Message: err.Error()} 168 | } 169 | return resp, nil 170 | } 171 | 172 | //============================================================================== 173 | 174 | // OfflineCount contains the result of the call to ejabberd 175 | // get_offline_count API. 176 | type OfflineCount struct { 177 | Name string `json:"name"` 178 | JID string `json:"jid"` 179 | Value int `json:"value"` 180 | } 181 | 182 | // JSON represents OfflineCount as a JSON string, for further 183 | // processing with other tools. 184 | func (o OfflineCount) JSON() string { 185 | body, _ := json.Marshal(o) 186 | return string(body) 187 | } 188 | 189 | type offlineCountRequest struct { 190 | JID string `json:"jid"` 191 | } 192 | 193 | func (o offlineCountRequest) params() (apiParams, error) { 194 | var query url.Values 195 | jid, err := parseJID(o.JID) 196 | if err != nil { 197 | return apiParams{}, err 198 | } 199 | 200 | type offlineCount struct { 201 | User string `json:"user"` 202 | Server string `json:"server"` 203 | } 204 | 205 | data := offlineCount{ 206 | User: jid.username, 207 | Server: jid.domain, 208 | } 209 | 210 | body, err := json.Marshal(data) 211 | if err != nil { 212 | return apiParams{}, err 213 | } 214 | 215 | if err != nil { 216 | return apiParams{}, err 217 | } 218 | 219 | return apiParams{ 220 | name: "get_offline_count", 221 | version: 1, 222 | 223 | method: "POST", 224 | query: query, 225 | body: body, 226 | }, nil 227 | } 228 | 229 | func (o offlineCountRequest) parseResponse(body []byte) (Response, error) { 230 | var resp OfflineCount 231 | err := json.Unmarshal(body, &resp) 232 | if err != nil { 233 | return resp, APIError{Code: 99, Message: err.Error()} 234 | } 235 | resp.Name = "offline_count" 236 | resp.JID = o.JID 237 | return resp, nil 238 | } 239 | 240 | func (o OfflineCount) String() string { 241 | return fmt.Sprintf("%d", o.Value) 242 | } 243 | 244 | //============================================================================== 245 | 246 | // UserResources contains the result of the call to ejabberd 247 | // user_resources API. 248 | type UserResources struct { 249 | JID string `json:"jid"` 250 | Resources []string `json:"resources"` 251 | } 252 | 253 | // JSON represents UserResources as a JSON string, for further 254 | // processing with other tools. 255 | func (u UserResources) JSON() string { 256 | body, _ := json.Marshal(u) 257 | return string(body) 258 | } 259 | 260 | type userResourcesRequest struct { 261 | JID string `json:"jid"` 262 | } 263 | 264 | func (u userResourcesRequest) params() (apiParams, error) { 265 | var query url.Values 266 | jid, err := parseJID(u.JID) 267 | if err != nil { 268 | return apiParams{}, err 269 | } 270 | 271 | type userResources struct { 272 | User string `json:"user"` 273 | Server string `json:"server"` 274 | } 275 | 276 | data := userResources{ 277 | User: jid.username, 278 | Server: jid.domain, 279 | } 280 | 281 | body, err := json.Marshal(data) 282 | if err != nil { 283 | return apiParams{}, err 284 | } 285 | 286 | if err != nil { 287 | return apiParams{}, err 288 | } 289 | 290 | return apiParams{ 291 | name: "user_resources", 292 | version: 1, 293 | 294 | method: "POST", 295 | query: query, 296 | body: body, 297 | }, nil 298 | } 299 | 300 | func (u userResourcesRequest) parseResponse(body []byte) (Response, error) { 301 | var resp UserResources 302 | 303 | var data []string 304 | err := json.Unmarshal(body, &data) 305 | if err != nil { 306 | return resp, APIError{Code: 99, Message: err.Error()} 307 | } 308 | resp.JID = u.JID 309 | resp.Resources = data 310 | return resp, nil 311 | } 312 | 313 | func (u UserResources) String() string { 314 | resources := strings.Join(u.Resources, ",") 315 | return fmt.Sprintf("%s", resources) 316 | } 317 | 318 | //============================================================================== 319 | 320 | // APIError represents ejabberd error returned by the server as result 321 | // of ejabberd API calls. 322 | type APIError struct { 323 | Status string `json:"status"` 324 | Code int `json:"code"` 325 | Message string `json:"message"` 326 | } 327 | 328 | func parseError(body []byte) (APIError, error) { 329 | var resp APIError 330 | err := json.Unmarshal(body, &resp) 331 | if err != nil { 332 | return resp, APIError{Code: 99, Message: err.Error()} 333 | } 334 | return resp, nil 335 | } 336 | 337 | // JSON represents ejabberd error response as a JSON string, for further 338 | // processing with other tools. 339 | func (e APIError) JSON() string { 340 | body, _ := json.Marshal(e) 341 | return string(body) 342 | } 343 | 344 | func (e APIError) Error() string { 345 | return fmt.Sprintf("Error %d: %s", e.Code, e.Message) 346 | } 347 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package ejabberd 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "net" 8 | "net/http" 9 | "reflect" 10 | "strconv" 11 | "time" 12 | ) 13 | 14 | // Client is an ejabberd client API wrapper. It is used to manage 15 | // ejabberd client API interactions. 16 | type Client struct { 17 | BaseURL string 18 | Token OAuthToken 19 | 20 | // Extra & Advanced features 21 | OAuthPath string 22 | APIPath string 23 | HTTPClient *http.Client 24 | } 25 | 26 | //============================================================================== 27 | 28 | // Generic Call functions 29 | 30 | // call performs HTTP call to ejabberd API given client parameters. It 31 | // returns a struct complying with Response interface. 32 | func (c Client) call(req request) (Response, error) { 33 | p, err := req.params() 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | var admin bool 39 | if p.admin { 40 | admin = true 41 | } else if needAdminForUser(req, c.Token.JID) { 42 | admin = true 43 | } 44 | 45 | code, result, err := c.CallRaw(p.body, p.name, admin) 46 | if err != nil { 47 | return APIError{Code: 99}, err 48 | } 49 | 50 | if code != 200 { 51 | apiError, err := parseError(result) 52 | if err != nil { 53 | return nil, err 54 | } 55 | return nil, apiError 56 | } 57 | 58 | return req.parseResponse(result) 59 | } 60 | 61 | // CallRaw performs HTTP call to ejabberd API and returns Raw Body 62 | // reponse from the server as slice of bytes. 63 | func (c Client) CallRaw(body []byte, name string, admin bool) (code int, result []byte, err error) { 64 | if c.HTTPClient == nil { 65 | c.HTTPClient = defaultHTTPClient(15 * time.Second) 66 | } 67 | 68 | var url string 69 | if url, err = apiURL(c.BaseURL, c.APIPath, name); err != nil { 70 | return 0, []byte{}, err 71 | } 72 | var r *http.Request 73 | if len(body) == 0 { 74 | r, _ = http.NewRequest("GET", url, nil) 75 | } else { 76 | r, _ = http.NewRequest("POST", url, bytes.NewBuffer(body)) 77 | } 78 | r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.Token.AccessToken)) 79 | r.Header.Set("Content-Type", "application/json") 80 | if admin { 81 | r.Header.Set("X-Admin", "true") 82 | } 83 | 84 | resp, err := c.HTTPClient.Do(r) 85 | if err != nil { 86 | return 0, []byte{}, err 87 | } 88 | 89 | // TODO: We should limit the amount of data the client reads from ejabberd as response 90 | result, err = ioutil.ReadAll(resp.Body) 91 | resp.Body.Close() 92 | 93 | return resp.StatusCode, result, err 94 | } 95 | 96 | // Check if Request struct has a field call JID. 97 | // If this is the case, compare with the JID of the user making the 98 | // query, based on the token data. 99 | // If JID from the request and JID from the token are different, then 100 | // we will need admin rights to perform user query 101 | func needAdminForUser(command interface{}, JID string) bool { 102 | cType := reflect.TypeOf(command) 103 | // if a pointer to a struct is passed, get the type of the dereferenced object 104 | if cType.Kind() == reflect.Ptr { 105 | cType = cType.Elem() 106 | } 107 | 108 | // If command type is not a struct, we stop there 109 | if cType.Kind() != reflect.Struct { 110 | return false 111 | } 112 | 113 | val := reflect.ValueOf(command) 114 | 115 | needAdmin := false 116 | for i := 0; i < val.NumField(); i++ { 117 | p := val.Type().Field(i) 118 | v := val.Field(i) 119 | if !p.Anonymous && p.Name == "JID" { 120 | switch v.Kind() { 121 | case reflect.String: 122 | if v.String() != JID { 123 | needAdmin = true 124 | } 125 | } 126 | } 127 | } 128 | 129 | return needAdmin 130 | } 131 | 132 | //============================================================================== 133 | 134 | // ==== Token ==== 135 | 136 | // TODO Get token from local file 137 | 138 | // GetToken calls ejabberd API to get a token for a given scope, given 139 | // valid jid and password. We also assume that the user has the right 140 | // to generate a token. In case of doubt you need to check ejabberd 141 | // access option 'oauth_access'. 142 | func (c Client) GetToken(sjid, password, scope string, duration time.Duration) (OAuthToken, error) { 143 | var j jid 144 | var t OAuthToken 145 | var err error 146 | 147 | // Set default values 148 | if c.HTTPClient == nil { 149 | c.HTTPClient = defaultHTTPClient(15 * time.Second) 150 | } 151 | 152 | if j, err = parseJID(sjid); err != nil { 153 | return t, err 154 | } 155 | 156 | var u string 157 | if u, err = tokenURL(c.BaseURL, c.OAuthPath); err != nil { 158 | return t, err 159 | } 160 | 161 | // Prepare token call parameters 162 | ttl := int(duration.Seconds()) 163 | params := tokenParams(j, password, prepareScope(scope), strconv.Itoa(ttl)) 164 | 165 | // Request token from server 166 | if t, err = httpGetToken(c.HTTPClient, u, params); err != nil { 167 | return t, err 168 | } 169 | return t, nil 170 | } 171 | 172 | //============================================================================== 173 | 174 | // Stats allows to query ejabberd for generic statistics. Supported statistic names are: 175 | // 176 | // registeredusers 177 | // onlineusers 178 | // onlineusersnode 179 | // uptimeseconds 180 | // processes 181 | func (c Client) Stats(name string) (Stats, error) { 182 | command := statsRequest{ 183 | Name: name, 184 | } 185 | 186 | result, err := c.call(command) 187 | if err != nil { 188 | return Stats{}, err 189 | } 190 | resp := result.(Stats) 191 | return resp, nil 192 | } 193 | 194 | //============================================================================== 195 | 196 | // RegisterUser creates a new user on a domain, from id (JID) and 197 | // password. It will fail if domain is not handle by server, if user 198 | // already exists, or if the user performing the registration does not 199 | // have the right to create new users on the server or domain. 200 | func (c Client) RegisterUser(bareJID string, password string) (Register, error) { 201 | command := registerRequest{ 202 | JID: bareJID, 203 | Password: password} 204 | 205 | result, err := c.call(command) 206 | if err != nil { 207 | return "", err 208 | } 209 | resp := result.(Register) 210 | return resp, nil 211 | } 212 | 213 | //============================================================================== 214 | 215 | // GetOfflineCount returns the number of message in offline storage 216 | // for a given user. It can be called as a user, if you try to read 217 | // your own offline message count. It can also be called as an admin 218 | // and in that case, you can read offline message count from any user 219 | // on the server. 220 | func (c Client) GetOfflineCount(bareJID string) (OfflineCount, error) { 221 | command := offlineCountRequest{ 222 | JID: bareJID, 223 | } 224 | 225 | result, err := c.call(command) 226 | if err != nil { 227 | return OfflineCount{}, err 228 | } 229 | resp := result.(OfflineCount) 230 | return resp, nil 231 | } 232 | 233 | //============================================================================== 234 | 235 | // UserResources returns the list of resources connected for a given 236 | // user. It can be called as a user, if you try to read your own 237 | // connected resources. It can also be called as an admin and in that 238 | // case, you can read the connected resources for any any user on the 239 | // server. 240 | func (c Client) UserResources(bareJID string) (UserResources, error) { 241 | command := userResourcesRequest{ 242 | JID: bareJID, 243 | } 244 | 245 | result, err := c.call(command) 246 | if err != nil { 247 | return UserResources{}, err 248 | } 249 | resp := result.(UserResources) 250 | return resp, nil 251 | } 252 | 253 | //============================================================================== 254 | 255 | // Prepare HTTP client settings with proper values, like default 256 | // timeout. 257 | func defaultHTTPClient(timeout time.Duration) *http.Client { 258 | return &http.Client{ 259 | Transport: &http.Transport{ 260 | Proxy: http.ProxyFromEnvironment, 261 | Dial: (&net.Dialer{ 262 | Timeout: timeout, 263 | KeepAlive: 30 * time.Second, 264 | }).Dial, 265 | TLSHandshakeTimeout: timeout, 266 | }, 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package ejabberd_test 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "testing" 10 | 11 | "github.com/processone/ejabberd-api" 12 | ) 13 | 14 | func Test_GetToken(t *testing.T) { 15 | accessToken := "12345" 16 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | w.WriteHeader(200) 18 | w.Header().Set("Content-Type", "application/json") 19 | fmt.Fprintln(w, `{"access_token": "`+accessToken+`"}`) 20 | })) 21 | defer server.Close() 22 | 23 | // Make a transport that reroutes all traffic to the example server 24 | transport := &http.Transport{ 25 | Proxy: func(req *http.Request) (*url.URL, error) { 26 | return url.Parse(server.URL) 27 | }, 28 | } 29 | 30 | client := ejabberd.Client{BaseURL: "http://localhost:5281", HTTPClient: &http.Client{Transport: transport}} 31 | token, err := client.GetToken("admin@localhost", "passw0rd", "ejabberd:admin", 3600) 32 | if err != nil { 33 | t.Errorf("GetToken failed: %s", err) 34 | } 35 | 36 | if token.AccessToken != accessToken { 37 | t.Errorf("Incorrect access token %s != %s", token.AccessToken, accessToken) 38 | } 39 | } 40 | 41 | // TODO provide const to specify token duration 42 | 43 | func ExampleClient_GetToken() { 44 | client := ejabberd.Client{BaseURL: "http://localhost:5281"} 45 | 46 | if token, err := client.GetToken("admin@localhost", "passw0rd", "ejabberd:admin", 3600); err != nil { 47 | fmt.Println(err) 48 | } else { 49 | fmt.Println("Retrieved access token:", token.AccessToken) 50 | } 51 | } 52 | 53 | func ExampleClient_Stats() { 54 | if flag.Lookup("test.v") == nil { 55 | fmt.Println("normal run") 56 | } else { 57 | fmt.Println("run under go test") 58 | } 59 | 60 | t := ejabberd.OAuthToken{AccessToken: "XjlJg0KF2wagT0A5dcYghePl8npsiEic"} 61 | client := ejabberd.Client{BaseURL: "http://localhost:5281", Token: t} 62 | 63 | if stats, err := client.Stats("registeredusers"); err != nil { 64 | fmt.Println(err) 65 | } else { 66 | fmt.Println(stats.Name, stats.Value) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /cmd/ejabberd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | 8 | "github.com/processone/ejabberd-api" 9 | "github.com/alecthomas/kingpin/v2" 10 | ) 11 | 12 | var ( 13 | app = kingpin.New("ejabberd", "A command-line front-end for ejabberd server API.").Version("0.0.1").Author("ProcessOne") 14 | file = app.Flag("file", "OAuth token JSON file.").Short('f').Default(".ejabberd-oauth.json").String() 15 | json = app.Flag("json", "JSON formatted output").Bool() 16 | 17 | // ========= token ========= 18 | token = app.Command("token", "Request an OAuth token.") 19 | tokenJID = token.Flag("jid", "JID of the user to generate token for.").Short('j').Required().String() 20 | tokenPassword = token.Flag("password", "Password to use to retrieve user token.").Short('p').String() 21 | tokenAskPass = token.Flag("prompt", "Prompt for password.").Short('P').Bool() 22 | tokenScope = token.Flag("scope", "Comma separated list of scope to associate to token").Short('s').Default("sasl_auth").String() 23 | tokenTTL = token.Flag("ttl", "Time before token expiration. Valid unit time are second (s), minutes (m), hours (h)").Default("8760h").Short('t').Duration() 24 | tokenEndpoint = token.Flag("endpoint", "ejabberd API endpoint.").Short('e').Default("http://localhost:5281/").String() 25 | tokenOauthURL = token.Flag("oauth-url", "Oauth suffix for oauth endpoint.").Default("/oauth/").String() 26 | 27 | // ========= stats ========= 28 | stats = app.Command("stats", "Get ejabberd statistics.") 29 | statsName = stats.Arg("name", "Name of stats to query.").Required().String() 30 | 31 | // ========= admin ========= 32 | register = app.Command("register", "Create a new user.") 33 | registerJID = register.Flag("jid", "JID of the user to create.").Short('j').Required().String() 34 | registerPassword = register.Flag("password", "Password to set for created user.").Short('p').Required().String() 35 | 36 | // ========= user ========= 37 | user = app.Command("user", "Operations to perform on users.") 38 | userOperation = user.Arg("operation", "Operation").Required().Enum("resources") 39 | userJID = user.Flag("jid", "JID of the user to perform operation on.").Short('j').String() 40 | 41 | // ========= offline ========= 42 | offline = app.Command("offline", "Operations to perform on offline store.") 43 | offlineOperation = offline.Arg("operation", "Operation").Required().Enum("count") 44 | offlineJID = offline.Flag("jid", "JID of the user to perform operation on, if different from token owner").Short('j').String() 45 | 46 | // ========= generic call ========= 47 | call = app.Command("call", "Call a command on ejabberd server, using your token credentials.") 48 | callFile = call.Flag("data-file", "File with JSON data to send to ejabberd. You can also use /dev/stdin").String() 49 | callData = call.Flag("data", "File with JSON data to send to ejabberd. Omit to read from STDIN").String() 50 | callName = call.Flag("name", "Name of command on server").Short('n').Required().String() 51 | callAdmin = call.Flag("admin", "Call as admin").Short('a').Bool() 52 | ) 53 | 54 | func main() { 55 | kingpin.CommandLine.HelpFlag.Short('h') // BUG(mr) Short help flag does not seem to work. 56 | kingpin.CommandLine.Help = "A command-line front-end for ejabberd server API." 57 | 58 | command := kingpin.MustParse(app.Parse(os.Args[1:])) 59 | switch command { 60 | case token.FullCommand(): 61 | getToken() 62 | default: 63 | execute(command) 64 | } 65 | } 66 | 67 | func execute(command string) { 68 | t, err := ejabberd.ReadOAuthToken(*file) 69 | if err != nil { 70 | kingpin.Fatalf("could not load token file %q: %s", *file, err) 71 | } 72 | if t.AccessToken == "" { 73 | kingpin.Fatalf("could not find access_token in file %q", *file) 74 | } 75 | c := ejabberd.Client{ 76 | BaseURL: t.Endpoint, 77 | APIPath: "api/", 78 | Token: t, 79 | } 80 | 81 | switch command { 82 | case call.FullCommand(): 83 | genericCommand(c, *callName, *callData, *callFile, *callAdmin) 84 | case register.FullCommand(): 85 | registerCommand(c, *registerJID, *registerPassword) 86 | case stats.FullCommand(): 87 | statsCommand(c) 88 | case user.FullCommand(): 89 | userCommand(c, *userOperation) 90 | case offline.FullCommand(): 91 | offlineCommand(c, *offlineOperation) 92 | } 93 | 94 | } 95 | 96 | func format(resp ejabberd.Response) { 97 | if *json { 98 | fmt.Println(resp.JSON()) 99 | } else { 100 | fmt.Println(resp) 101 | } 102 | } 103 | 104 | // ============================================================================= 105 | 106 | func getToken() { 107 | var token ejabberd.OAuthToken 108 | var err error 109 | client := ejabberd.Client{BaseURL: *tokenEndpoint, OAuthPath: *tokenOauthURL} 110 | if token, err = client.GetToken(*tokenJID, *tokenPassword, *tokenScope, *tokenTTL); err != nil { 111 | kingpin.Fatalf("could not retrieve token: %s", err) 112 | } 113 | 114 | token.JID = *tokenJID 115 | token.Endpoint = *tokenEndpoint 116 | if err = token.Save(*file); err != nil { 117 | kingpin.Fatalf("could not save token to file %q: %s", *file, err) 118 | } 119 | fmt.Println("Successfully saved token in file", *file) 120 | } 121 | 122 | //============================================================================== 123 | 124 | func registerCommand(c ejabberd.Client, j, p string) { 125 | resp, err := c.RegisterUser(j, p) 126 | if err != nil { 127 | kingpin.Fatalf("user registration error for %s: %s", j, err) 128 | } 129 | format(resp) 130 | } 131 | 132 | //============================================================================== 133 | 134 | func statsCommand(c ejabberd.Client) { 135 | resp, err := c.Stats(*statsName) 136 | if err != nil { 137 | kingpin.Fatalf("stats error %q: %s", *statsName, err) 138 | } 139 | format(resp) 140 | } 141 | 142 | //============================================================================== 143 | 144 | func userCommand(c ejabberd.Client, op string) { 145 | switch op { 146 | case "resources": 147 | resourcesCommand(c, *userJID) 148 | } 149 | } 150 | 151 | func resourcesCommand(c ejabberd.Client, jid string) { 152 | if jid == "" { 153 | jid = c.Token.JID 154 | } 155 | 156 | resp, err := c.UserResources(jid) 157 | if err != nil { 158 | kingpin.Fatalf("%s: %s", jid, err) 159 | } 160 | format(resp) 161 | } 162 | 163 | //============================================================================== 164 | 165 | func offlineCommand(c ejabberd.Client, op string) { 166 | switch op { 167 | case "count": 168 | offlineCountCommand(c, *offlineJID) 169 | } 170 | } 171 | 172 | func offlineCountCommand(c ejabberd.Client, jid string) { 173 | if jid == "" { 174 | jid = c.Token.JID 175 | } 176 | resp, err := c.GetOfflineCount(jid) 177 | if err != nil { 178 | kingpin.Fatalf("offline count error for %s: %s", jid, err) 179 | } 180 | format(resp) 181 | } 182 | 183 | //============================================================================== 184 | 185 | func genericCommand(c ejabberd.Client, commandName, input string, file string, admin bool) { 186 | var data []byte 187 | var err error 188 | 189 | if file != "" && input != "" { 190 | kingpin.Fatalf("Use either data or data-file option to pass input to ejabberd API.") 191 | } 192 | 193 | if input != "" { 194 | data = []byte(input) 195 | } else { 196 | switch file { 197 | case "/dev/stdin": 198 | data, err = ioutil.ReadAll(os.Stdin) 199 | case "": 200 | // Some valid ejabberd commands accept empty input 201 | default: 202 | data, err = ioutil.ReadFile(file) 203 | } 204 | } 205 | 206 | if err != nil { 207 | kingpin.Fatalf("%s", err) 208 | } 209 | 210 | code, result, err := c.CallRaw(data, commandName, admin) 211 | if err != nil { 212 | kingpin.Fatalf("%s", err) 213 | } 214 | if code != 200 { 215 | fmt.Printf("Response: %d\n", code) 216 | kingpin.Fatalf("%s", result) 217 | } 218 | fmt.Printf("%s", result) 219 | } 220 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package ejabberd exposes ejabberd API in Go programming language. 3 | 4 | Installation 5 | 6 | Both the library and the command-line tool can be installed from 7 | source with go get tool. 8 | 9 | If you have go installed in your environment you can install ejabberd 10 | command-line tool with: 11 | 12 | go get -u github.com/processone/ejabberd-api 13 | 14 | Configuring ejabberd OAuth API 15 | 16 | Before being able to interact with ejabberd API, you need to configure 17 | ejabberd with OAuth support enabled. This is is documented in ejabberd 18 | OAuth support documentation: 19 | https://docs.ejabberd.im/developer/ejabberd-api/oauth/ 20 | 21 | Here are example entries to check / change in your ejabberd 22 | configuration file: 23 | 24 | 1. Add a listener for OAuth and ReST API: 25 | 26 | listen: 27 | - 28 | # Using a separate port for oauth and API to make it easy to protect it 29 | # differently than BOSH and Websocket HTTP interface. 30 | port: 5281 31 | # oauth and API only listen on localhost interface for security reason 32 | # You can set ip to "0.0.0.0" to open it widely, but be careful! 33 | ip: "127.0.0.1" 34 | module: ejabberd_http 35 | request_handlers: 36 | "/oauth": ejabberd_oauth 37 | "/api": mod_http_api 38 | 39 | 2. You can then configure the OAuth commands you want to expose. Check 40 | `commands_admin_access` to make sure ACL for passing commands as admins 41 | are set properly: 42 | 43 | commands_admin_access: 44 | - allow: 45 | - user: "admin@localhost" 46 | commands: 47 | - add_commands: [user, admin, open] 48 | # Tokens are valid for a year as default: 49 | oauth_expire: 31536000 50 | oauth_access: all 51 | 52 | 3. Finally, make sure the modules, you need to use the command from 53 | are enabled, for example: 54 | 55 | modules: 56 | mod_admin_extra: {} 57 | 58 | Using ejabberd client API 59 | 60 | To use ejabberd Go client API, you need to have a token. If you do not 61 | have one, you can get one programatically using 'Client.GetToken' 62 | function. 63 | 64 | From there, you can use any ejabberd client function to call any 65 | ejabberd available command. 66 | 67 | Check 'ejabberd.Client' documentation for details on the currently 68 | available commands. 69 | 70 | OAuth Token file format 71 | 72 | As a default, the token is stored in a file called 73 | './.ejabberd-oauth.json' when using the command 'token' and read from 74 | the same file when you use any other commands. 75 | 76 | Option '-f file' will let you point to another file. 77 | 78 | The file contains a JSON structure with the following fields: 79 | 80 | access_token Actual token value. 81 | endpoint Base URL. 82 | jid JID for which user the token was generated. 83 | scope OAuth scope for which the token was generated. 84 | expiration Expiration date for the token. 85 | 86 | For example: 87 | 88 | {"access_token":"AaQTb0PUZqeZhFKYoaTQBb4KKkCTAolE", 89 | "endpoint":"http://localhost:5281/", 90 | "jid":"admin@localhost", 91 | "scope":"ejabberd:admin", 92 | "expiration":"2017-07-23T13:53:08.326421575+02:00"} 93 | 94 | */ 95 | package ejabberd 96 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/processone/ejabberd-api 2 | 3 | go 1.22 4 | 5 | require github.com/alecthomas/kingpin/v2 v2.4.0 6 | 7 | require ( 8 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect 9 | github.com/xhit/go-str2duration/v2 v2.1.0 // indirect 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= 2 | github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= 3 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= 4 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 9 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 10 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 11 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 12 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 13 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 14 | github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= 15 | github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 17 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 18 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 19 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 20 | -------------------------------------------------------------------------------- /helpers.go: -------------------------------------------------------------------------------- 1 | package ejabberd 2 | 3 | import "strings" 4 | 5 | // prepareScope ensures we return scopes as space separated. However, 6 | // we accept comma separated scopes as input as well for convenience. 7 | func prepareScope(s string) string { 8 | return strings.Replace(s, ",", " ", -1) 9 | } 10 | 11 | //============================================================================== 12 | // Internal helper functions 13 | 14 | // stringInSlice returns whether a string is a member of a string 15 | // slice. 16 | func stringInSlice(a string, list []string) bool { 17 | for _, b := range list { 18 | if b == a { 19 | return true 20 | } 21 | } 22 | return false 23 | } 24 | -------------------------------------------------------------------------------- /helpers_test.go: -------------------------------------------------------------------------------- 1 | package ejabberd 2 | 3 | import "testing" 4 | 5 | type testError struct{} 6 | 7 | func (e testError) Error() string { 8 | return "expected error" 9 | } 10 | 11 | func TestJoinURL(t *testing.T) { 12 | var tests = []struct { 13 | baseURL string 14 | suffix string 15 | want string 16 | err error 17 | }{ 18 | {"localhost:5281", "", "", testError{}}, 19 | } 20 | for _, test := range tests { 21 | var got string 22 | var err error 23 | if got, err = joinURL(test.baseURL, test.suffix); err != nil && test.err == nil { 24 | t.Errorf("error on JoinURL(%q, %q): %s", test.baseURL, test.suffix, err) 25 | return 26 | } 27 | if got != test.want { 28 | t.Errorf("JoinURL(%q, %q) = %q", test.baseURL, test.suffix, got) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /jid.go: -------------------------------------------------------------------------------- 1 | package ejabberd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | // JID processing 10 | // TODO update gox and import it directly from gox 11 | 12 | type jid struct { 13 | username string 14 | domain string 15 | resource string 16 | } 17 | 18 | func parseJID(sjid string) (jid, error) { 19 | var j jid 20 | 21 | s1 := strings.SplitN(sjid, "/", 2) 22 | if len(s1) > 1 { 23 | j.resource = s1[1] 24 | } 25 | 26 | s2 := strings.Split(s1[0], "@") 27 | if len(s2) != 2 { 28 | return jid{}, errors.New("invalid jid") 29 | } 30 | 31 | j.username = s2[0] 32 | j.domain = s2[1] 33 | return j, nil 34 | } 35 | 36 | func (j jid) bare() string { 37 | return fmt.Sprintf("%s@%s", j.username, j.domain) 38 | } 39 | -------------------------------------------------------------------------------- /jid_test.go: -------------------------------------------------------------------------------- 1 | package ejabberd 2 | 3 | import "testing" 4 | 5 | func Test_JID(t *testing.T) { 6 | var tests = []struct { 7 | input string 8 | want jid 9 | err error 10 | }{ 11 | {"username@domain/resource", jid{username: "username", domain: "domain", resource: "resource"}, nil}, 12 | {"username@domain", jid{username: "username", domain: "domain"}, nil}, 13 | {"username@domain/resourcewith/@", jid{username: "username", domain: "domain", resource: "resourcewith/@"}, nil}, 14 | {"user@name@domain/resource", jid{}, testError{}}, 15 | } 16 | for _, test := range tests { 17 | var got jid 18 | var err error 19 | if got, err = parseJID(test.input); err != nil && test.err == nil { 20 | t.Errorf("error on parseJID(%q): %s", test.input, err) 21 | return 22 | } 23 | if got != test.want { 24 | t.Errorf("parseJID(%q) = %q", test.input, got) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /oauth_token.go: -------------------------------------------------------------------------------- 1 | package ejabberd 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io/ioutil" 7 | "net/http" 8 | "net/url" 9 | "time" 10 | ) 11 | 12 | // OAuthToken defines how to store ejabberd OAuth token and 13 | // attributes. 14 | type OAuthToken struct { 15 | // Actual token value retrieved from server 16 | AccessToken string `json:"access_token"` 17 | Endpoint string `json:"endpoint"` 18 | 19 | // Parameters associated with the token, stored for reference 20 | JID string `json:"jid"` 21 | Scope string `json:"scope"` 22 | Expiration time.Time `json:"expiration"` 23 | } 24 | 25 | // Save writes ejabberd OAuth structure to file. 26 | func (t OAuthToken) Save(file string) error { 27 | b, err := json.Marshal(t) 28 | if err != nil { 29 | return err 30 | } 31 | 32 | return ioutil.WriteFile(file, b, 0640) 33 | } 34 | 35 | // ReadOAuthToken reads the content of JSon OAuth token file and 36 | // return proper OAuthToken structure. 37 | func ReadOAuthToken(file string) (OAuthToken, error) { 38 | var t OAuthToken 39 | data, err := ioutil.ReadFile(file) 40 | if err != nil { 41 | return t, err 42 | } 43 | 44 | err = json.Unmarshal(data, &t) 45 | return t, err 46 | } 47 | 48 | //============================================================================== 49 | 50 | //============================================================================== 51 | // HTTP 52 | 53 | func httpGetToken(c *http.Client, apiURL string, params url.Values) (OAuthToken, error) { 54 | // Performs HTTP request 55 | resp, err := c.PostForm(apiURL, params) 56 | if err != nil { 57 | return OAuthToken{}, err 58 | } 59 | defer resp.Body.Close() 60 | 61 | // Endpoint not found 62 | if resp.StatusCode == 404 { 63 | return OAuthToken{}, errors.New("oauth endpoint not found (404)") 64 | } 65 | 66 | // Cannot read HTTP response 67 | body, err := ioutil.ReadAll(resp.Body) 68 | if err != nil { 69 | return OAuthToken{}, errors.New("cannot read HTTP response from server") 70 | } 71 | 72 | // Bad request 73 | if resp.StatusCode == 400 { 74 | return OAuthToken{}, parseTokenError(body) 75 | } 76 | 77 | // Success 78 | return parseTokenResponse(body) 79 | } 80 | 81 | // tokenParams prepares HTTP form to retrieve token 82 | func tokenParams(j jid, password, scope, ttl string) url.Values { 83 | return url.Values{ 84 | "grant_type": {"password"}, 85 | // TODO It would be nice to have ejabberd password grant_type support client_id: 86 | // "client_id": {clientID}, 87 | "scope": {scope}, 88 | "username": {j.bare()}, 89 | "password": {password}, 90 | "ttl": {ttl}, 91 | } 92 | } 93 | 94 | // ==== 95 | // Process ejabberd HTTP token response 96 | 97 | func parseTokenError(body []byte) error { 98 | type jsonError struct { 99 | Error string `json:"error"` 100 | Description string `json:"error_description"` 101 | } 102 | var e jsonError 103 | 104 | if err := json.Unmarshal(body, &e); err != nil { 105 | return errors.New("bad request") 106 | } 107 | return errors.New(e.Description) 108 | } 109 | 110 | func parseTokenResponse(body []byte) (OAuthToken, error) { 111 | type jsonResp struct { 112 | AccessToken string `json:"access_token"` 113 | TokenType string `json:"token_type"` 114 | Scope string `json:"scope"` 115 | ExpiresIn int `json:"expires_in"` 116 | } 117 | var r jsonResp 118 | 119 | if err := json.Unmarshal(body, &r); err != nil { 120 | return OAuthToken{}, err 121 | } 122 | 123 | var t OAuthToken 124 | t.AccessToken = r.AccessToken 125 | t.Scope = r.Scope 126 | t.Expiration = time.Now().Add(time.Duration(r.ExpiresIn) * time.Second) 127 | 128 | return t, nil 129 | } 130 | -------------------------------------------------------------------------------- /url.go: -------------------------------------------------------------------------------- 1 | package ejabberd 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "path" 7 | ) 8 | 9 | // joinURL checks that Base URL is a valid URL and joins base URL with 10 | // the method suffix string. 11 | func joinURL(baseURL string, suffix string) (string, error) { 12 | var u *url.URL 13 | var err error 14 | 15 | if u, err = url.Parse(baseURL); err != nil { 16 | return "", fmt.Errorf("invalid url: %s", baseURL) 17 | } 18 | 19 | if u.Scheme != "http" && u.Scheme != "https" { 20 | return "", fmt.Errorf("invalid url scheme: %s", u.Scheme) 21 | } 22 | 23 | u.Path = path.Join(u.Path, suffix) 24 | return u.String(), nil 25 | } 26 | 27 | // tokenURL generates URL endpoint for retrieving a token using 28 | // password grant type. 29 | func tokenURL(baseURL, oauthPath string) (string, error) { 30 | var path string 31 | var err error 32 | 33 | if oauthPath == "" { 34 | path, err = joinURL(baseURL, "oauth") 35 | } else { 36 | path, err = joinURL(baseURL, oauthPath) 37 | } 38 | 39 | if err != nil { 40 | return baseURL, err 41 | } 42 | 43 | return joinURL(path, "token") 44 | } 45 | 46 | // apiURL generates URL endpoint for calling a given ejabberd API 47 | // command name. 48 | func apiURL(baseURL, apiPath, name string) (string, error) { 49 | var path string 50 | var err error 51 | 52 | if apiPath == "" { 53 | path, err = joinURL(baseURL, "api") 54 | } else { 55 | path, err = joinURL(baseURL, apiPath) 56 | } 57 | 58 | if err != nil { 59 | return baseURL, err 60 | } 61 | 62 | return joinURL(path, name+"/") 63 | } 64 | --------------------------------------------------------------------------------