├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── cmd └── rfront │ ├── client.go │ ├── config.go │ ├── main.go │ ├── namespace.go │ └── utils.go ├── go.mod └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | rfront 2 | !rfront/ 3 | config.json 4 | config.jsonc 5 | deploy.sh -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Josh Baker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: rfront 2 | 3 | .PHONY: rfront 4 | rfront: 5 | go build -o rfront cmd/rfront/*.go 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rfront 2 | 3 | An HTTP frontend for Redis-compatible services. 4 | 5 | ## Features 6 | 7 | - Supports HTTP, HTTP/2, and Websockets 8 | - Automatic Let's Encrypt certificates and host binding 9 | - Flexible access-control list for HTTP clients 10 | - Returns the raw RESP outputs 11 | - Works with Redis-compatible services like Tile38, Redcon, KeyDB, Uhaha, etc. 12 | 13 | ## Build and Run 14 | 15 | ```sh 16 | make 17 | ./rfront --config config.json 18 | ``` 19 | 20 | ## Configure 21 | 22 | A `config.json` file is always required. 23 | 24 | ### Configuration properties 25 | 26 | - `hosts`: Array of publically accessible https hosts. 27 | - `port`: Server port. Used for non-TLS http hosting. 28 | - `cluster.addrs`: Array of Redis server addresses. 29 | - `cluster.auth`: For authorizing the connection to the Redis servers. 30 | - `acl.tokens`: Array of tokens for authorizing http clients for the current policy. 31 | - `acl.access`: Default access for commands sent by http clients. 32 | - `allow`: Allow all incoming commands. 33 | - `disallow`: Deny all incoming commands. 34 | - `acl.except`: Array of commands that are exceptions to `acl.access`. 35 | - `namespaces`: Array of cluster and acl groups. See [namespaces](#namespaces). 36 | 37 | ### Automatic updates 38 | 39 | Changes to the `acl` and `cluster` properties of the `config.json` file will 40 | automatically be picked up by the running server, and do not require a server 41 | restart. 42 | 43 | ### Configuration Examples 44 | 45 | Bind the Redis server at `127.0.0.1:6379` to `http://localhost:8000` and 46 | allow all commands from all clients, except for the `SHUTDOWN` command. 47 | 48 | ```json 49 | { 50 | "port": 8000, 51 | "cluster": { 52 | "addrs": [ "127.0.0.1:6379" ], 53 | "auth": "" 54 | }, 55 | "acl": [ 56 | { 57 | "tokens": [ "" ], 58 | "access": "allow", 59 | "except": [ "shutdown" ] 60 | } 61 | ] 62 | } 63 | ``` 64 | 65 | Bind the Redis cluster at `10.0.0.1:6379,10.0.0.2:6379` to 66 | `https://example.com` and use the Redis `AUTH my-redis-auth`. 67 | This config includes two client tokens where one only allows the read-only 68 | commands `ping`, `get`, and `scan`. While the other also allows for the write 69 | commands `set` and `del`. 70 | 71 | ```json 72 | { 73 | "hosts": [ "example.com" ], 74 | "cluster": { 75 | "addrs": [ "10.0.0.1:6379", "10.0.0.2:6379" ], 76 | "auth": "my-redis-auth" 77 | }, 78 | "acl": [ 79 | { 80 | "tokens": [ "reader-client-token" ], 81 | "access": "disallow", 82 | "except": [ "ping", "get", "scan" ] 83 | }, { 84 | "tokens": [ "writer-client-token" ], 85 | "access": "disallow", 86 | "except": [ "ping", "get", "scan", "set", "del" ] 87 | } 88 | 89 | ] 90 | } 91 | ``` 92 | 93 | 94 | ## HTTP Client Examples 95 | 96 | Let's say you are using the first configuration above. 97 | 98 | Here's a client connecting over websockets using the 99 | [wscat](https://github.com/websockets/wscat) client. 100 | 101 | ``` 102 | $ wscat -c ws://localhost:8000 103 | connected (press CTRL+C to quit) 104 | > ping 105 | < +PONG 106 | 107 | > set hello world 108 | < +OK 109 | 110 | > get hello 111 | < $5 112 | world 113 | 114 | > 115 | ``` 116 | 117 | Notice that the responses are in the [RESP](https://redis.io/docs/reference/protocol-spec/) format. 118 | 119 | If you want to send HTTP requests: 120 | 121 | ``` 122 | $ curl 'http://localhost:8000?cmd=ping' 123 | +PONG 124 | $ curl 'http://localhost:8000?cmd=set+hello+world' 125 | +OK 126 | $ curl 'http://localhost:8000?cmd=get+hello' 127 | $5 128 | world 129 | ``` 130 | 131 | If you require an ACL client token, as in the last configuration above, 132 | you can use the `token` querystring key such as: 133 | 134 | 135 | ``` 136 | $ wscat -c wss://example.com?token=reader-client-token 137 | $ curl 'https://example.com?token=reader-client-token' 138 | ``` 139 | 140 | Or, you can provide the HTTP header `Authorization: Token reader-client-token` 141 | 142 | ## RESP Commands 143 | 144 | For simple HTTP request, a basic command can sent using `cmd=name+arg+arg` in 145 | the querystring, like: 146 | 147 | ``` 148 | $ curl 'http://localhost:8000?cmd=set+hello+world' 149 | ``` 150 | 151 | Or, you can send the raw RESP in the body of the request, like: 152 | 153 | ``` 154 | *3 155 | $3 156 | set 157 | $5 158 | hello 159 | $5 160 | world 161 | ``` 162 | 163 | ## Namespaces 164 | 165 | You can configure the server to point to multiple clusters and acl groups 166 | using namespaces. 167 | 168 | ```json 169 | { 170 | "hosts": [ "example.com" ], 171 | "namespaces": { 172 | "redis": { 173 | "cluster": { 174 | "addrs": [ "10.0.0.1:6379" ], 175 | "auth": "my-redis-auth" 176 | }, 177 | "acl": [ 178 | { 179 | "tokens": [ "reader-client-token" ], 180 | "access": "disallow", 181 | "except": [ "ping", "set", "del", "get" ] 182 | } 183 | ] 184 | }, 185 | "tile38": { 186 | "cluster": { 187 | "addrs": [ "10.0.0.1:9851" ], 188 | "auth": "my-tile38-auth" 189 | }, 190 | "acl": [ 191 | { 192 | "tokens": [ "reader-client-token" ], 193 | "access": "disallow", 194 | "except": [ "ping", "set", "del", "intersects" ] 195 | } 196 | ] 197 | }, 198 | } 199 | } 200 | ``` 201 | 202 | Then the client URL would include the namespace like: 203 | 204 | ``` 205 | https://example.com/tile38? 206 | https://example.com/redis? 207 | ``` 208 | 209 | -------------------------------------------------------------------------------- /cmd/rfront/client.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "io" 7 | "log" 8 | "net" 9 | "net/http" 10 | "net/url" 11 | "strings" 12 | "time" 13 | 14 | "github.com/gorilla/websocket" 15 | "github.com/tidwall/redcon" 16 | ) 17 | 18 | type proxyClient struct { 19 | req *http.Request 20 | eof bool 21 | hwr http.ResponseWriter 22 | ws *websocket.Conn 23 | rc net.Conn 24 | rd *bufio.Reader 25 | token string 26 | conn net.Conn // for raw hijacked connection 27 | pktin []byte // for raw hijacked connection 28 | bufout *bufio.Writer // for raw hijacked connection 29 | is InputStream 30 | query url.Values 31 | nspace string 32 | acl *aclMap 33 | acltok *aclToken 34 | cluster *clusterInfo 35 | cupdated uint64 36 | namespaces *namespaceMap 37 | } 38 | 39 | func newClient(w http.ResponseWriter, r *http.Request, namespaces *namespaceMap, 40 | ) (*proxyClient, error) { 41 | q := r.URL.Query() 42 | var ws *websocket.Conn 43 | var conn net.Conn 44 | var err error 45 | if r.Header.Get("Connection") == "Upgrade" { 46 | switch r.Header.Get("Upgrade") { 47 | case "raw": 48 | conn, err = hijackRaw(w, r) 49 | if err != nil { 50 | return nil, err 51 | } 52 | default: 53 | // Assume websocket as default upgrade. 54 | // The upgrader will complete handshake. 55 | ws, err = upgrader.Upgrade(w, r, nil) 56 | if err != nil { 57 | return nil, err 58 | } 59 | } 60 | } 61 | pc := &proxyClient{ 62 | req: r, 63 | hwr: w, 64 | ws: ws, 65 | query: q, 66 | conn: conn, 67 | token: getAuthToken(r.Header, q), 68 | namespaces: namespaces, 69 | nspace: r.URL.Path[1:], 70 | } 71 | if pc.conn != nil { 72 | pc.pktin = make([]byte, 8192) 73 | pc.bufout = bufio.NewWriterSize(pc.conn, 8192) 74 | } 75 | return pc, nil 76 | } 77 | 78 | func getAuthToken(h http.Header, q url.Values) string { 79 | token := h.Get("Authorization") 80 | if token == "" { 81 | return q.Get("token") 82 | } 83 | if strings.HasPrefix(token, "token ") { 84 | return token[6:] 85 | } 86 | if strings.HasPrefix(token, "Basic ") { 87 | return token[6:] 88 | } 89 | if strings.HasPrefix(token, "Bearer ") { 90 | return token[7:] 91 | } 92 | return "" 93 | } 94 | 95 | func (pc *proxyClient) Close() { 96 | if pc.ws != nil { 97 | // websocket client 98 | pc.ws.Close() 99 | } 100 | if pc.conn != nil { 101 | // hijacked client 102 | pc.conn.Close() 103 | } 104 | if pc.rc != nil { 105 | // redis server 106 | pc.rc.Close() 107 | } 108 | } 109 | 110 | func (pc *proxyClient) readMessage() (data []byte, err error) { 111 | if pc.conn != nil { 112 | // Hijacked Connection 113 | n, err := pc.conn.Read(pc.pktin) 114 | if err != nil { 115 | return nil, err 116 | } 117 | data = pc.pktin[:n] 118 | return data, nil 119 | } 120 | 121 | if pc.ws != nil { 122 | // Websocket 123 | _, msg, err := pc.ws.ReadMessage() 124 | if err != nil { 125 | return nil, err 126 | } 127 | data = msg 128 | } else { 129 | // Plain HTTP 130 | if pc.eof { 131 | return nil, io.EOF 132 | } 133 | pc.eof = true 134 | qcmd := pc.query.Get("cmd") 135 | if qcmd != "" { 136 | data = []byte(qcmd) 137 | } else { 138 | var err error 139 | data, err = io.ReadAll(pc.req.Body) 140 | if err != nil { 141 | return nil, err 142 | } 143 | } 144 | } 145 | // Append an extra new line onto the tail of the packet to that plain 146 | // telnet-like commands can be sent over a websocket connection without 147 | // the need for adding the extra line breaks. 148 | data = append(data, '\r', '\n') 149 | return data, nil 150 | } 151 | 152 | func appendAutoJSON(dst []byte, resp redcon.RESP) []byte { 153 | switch resp.Type { 154 | case redcon.Array: 155 | dst = append(dst, '[') 156 | var i int 157 | resp.ForEach(func(resp redcon.RESP) bool { 158 | if i > 0 { 159 | dst = append(dst, ',') 160 | } 161 | dst = appendAutoJSON(dst, resp) 162 | i++ 163 | return true 164 | }) 165 | dst = append(dst, ']') 166 | } 167 | return dst 168 | } 169 | 170 | func (pc *proxyClient) writeMessage(msg []byte) error { 171 | if pc.conn != nil { 172 | // hijacked connection 173 | _, err := pc.bufout.Write(msg) 174 | return err 175 | } 176 | if pc.ws != nil { 177 | // websocket 178 | return pc.ws.WriteMessage(2, msg) 179 | } 180 | // plain HTTP request 181 | _, err := pc.hwr.Write(msg) 182 | return err 183 | } 184 | 185 | func (pc *proxyClient) flushWrite() error { 186 | if pc.conn != nil { 187 | // hijacked connection 188 | return pc.bufout.Flush() 189 | } 190 | return nil 191 | } 192 | 193 | func (pc *proxyClient) allow(commandName string) bool { 194 | if pc.acltok == nil || !pc.acltok.valid() { 195 | pc.acltok = nil 196 | _, pc.acl, _ = pc.namespaces.get(pc.nspace) 197 | if pc.acl == nil { 198 | return false 199 | } 200 | pc.acltok = pc.acl.auth(pc.token) 201 | if pc.acltok == nil { 202 | return false 203 | } 204 | } 205 | if pc.acltok.allow { 206 | if pc.acltok.except[commandName] { 207 | return false 208 | } 209 | } else { 210 | if !pc.acltok.except[commandName] { 211 | return false 212 | } 213 | } 214 | return true 215 | } 216 | 217 | func (pc *proxyClient) proxy() error { 218 | for { 219 | in, err := pc.readMessage() 220 | if err != nil { 221 | // network level error 222 | return err 223 | } 224 | data := pc.is.Begin(in) 225 | var complete bool 226 | var args [][]byte 227 | for { 228 | complete, args, _, data, err = 229 | redcon.ReadNextCommand(data, args[:0]) 230 | if err != nil { 231 | return err 232 | } 233 | if !complete { 234 | break 235 | } 236 | if len(args) > 0 { 237 | sargs := make([]string, len(args)) 238 | for i, arg := range args { 239 | sargs[i] = string(arg) 240 | } 241 | sargs[0] = strings.ToLower(sargs[0]) 242 | if sargs[0] == "auth" { 243 | if len(sargs) != 2 { 244 | err = pc.writeMessage( 245 | []byte("-ERR wrong number of arguments\r\n")) 246 | } else { 247 | if sargs[1] != pc.token { 248 | pc.token = sargs[1] 249 | if pc.acltok != nil { 250 | pc.acltok = nil 251 | } 252 | } 253 | err = pc.writeMessage([]byte("+OK\r\n")) 254 | } 255 | } else if !pc.allow(sargs[0]) { 256 | err = pc.writeMessage([]byte("-ERR unauthorized\r\n")) 257 | } else { 258 | err = pc.execCommand(sargs) 259 | } 260 | if err != nil { 261 | return err 262 | } 263 | } 264 | for len(data) >= 2 && string(data[:2]) == "\r\n" { 265 | data = data[2:] 266 | } 267 | } 268 | pc.is.End(data) 269 | if err := pc.flushWrite(); err != nil { 270 | return err 271 | } 272 | } 273 | } 274 | 275 | func (pc *proxyClient) ensureValidCluster() (updated bool, err error) { 276 | if pc.cluster != nil && pc.cluster.valid() { 277 | // Appears to be valid 278 | return false, nil 279 | } 280 | 281 | // The namespace cluster has been updated. 282 | // Close the client connection to the redis server and clone the 283 | // the updated cluster. 284 | pc.cluster = nil 285 | if pc.rc != nil { 286 | pc.rc.Close() 287 | pc.rc = nil 288 | } 289 | 290 | pc.cluster, _, _ = pc.namespaces.get(pc.nspace) 291 | if pc.cluster == nil { 292 | return false, errors.New("namespace not found") 293 | } 294 | pc.cluster = pc.cluster.copy() 295 | return true, nil 296 | } 297 | 298 | // execCommand will run a single command on the redis cluster 299 | func (pc *proxyClient) execCommand(args []string) error { 300 | // Prepare the command for sending to the server 301 | cmdData := redcon.AppendArray(nil, len(args)) 302 | for _, arg := range args { 303 | cmdData = redcon.AppendBulkString(cmdData, arg) 304 | } 305 | 306 | var ignoreAddr []string 307 | var usedAddr string 308 | var execOK bool 309 | defer func() { 310 | if execOK && usedAddr != "" && pc.cluster != nil { 311 | pc.cluster.leader.set(usedAddr) 312 | } 313 | }() 314 | 315 | var leaderAddr string 316 | 317 | // Attempt to write the command to the server. 318 | // Try for 15 seconds and then timeout. 319 | start := time.Now() 320 | for time.Since(start) < 15*time.Second { 321 | 322 | // ensure that the cluster is valid 323 | updated, err := pc.ensureValidCluster() 324 | if err != nil { 325 | log.Printf("ensure cluster: %s", err) 326 | return pc.writeMessage([]byte("-ERR unauthorized\r\n")) 327 | } 328 | if updated { 329 | leaderAddr = pc.cluster.leader.get() 330 | } 331 | 332 | var addr string 333 | if pc.rc == nil { 334 | // Client is not connected. 335 | // Attempt to connect to a server 336 | var err error 337 | if leaderAddr != "" { 338 | // A leader is recommended 339 | addr = leaderAddr 340 | leaderAddr = "" // do not reuse the same leader recommendation. 341 | pc.rc, err = net.Dial("tcp", addr) 342 | } else { 343 | for i := 0; i < len(pc.cluster.addrs); i++ { 344 | addr = pc.cluster.addrs[i] 345 | var ignore bool 346 | for _, addr2 := range ignoreAddr { 347 | if addr == addr2 { 348 | ignore = true 349 | break 350 | } 351 | } 352 | if ignore { 353 | continue 354 | } 355 | pc.rc, err = net.Dial("tcp", addr) 356 | if err == nil { 357 | break 358 | } 359 | time.Sleep(time.Millisecond * 250) 360 | } 361 | } 362 | if err != nil { 363 | // could not connect, sleep and try again 364 | ignoreAddr = append(ignoreAddr, addr) 365 | log.Printf("dial: %s", err) 366 | time.Sleep(time.Millisecond * 250) 367 | continue 368 | } 369 | if pc.rc == nil { 370 | // could not find a valid server to connect to. 371 | break 372 | } 373 | pc.rd = bufio.NewReader(pc.rc) 374 | if pc.cluster.auth != "" { 375 | auth := redcon.AppendArray(nil, 2) 376 | auth = redcon.AppendBulkString(auth, "AUTH") 377 | auth = redcon.AppendBulkString(auth, pc.cluster.auth) 378 | _, err := pc.rc.Write(auth) 379 | if err != nil { 380 | return err 381 | } 382 | resp, err := readRESP(nil, pc.rd) 383 | if err != nil { 384 | return err 385 | } 386 | if string(resp) != "+OK\r\n" { 387 | pc.rc.Close() 388 | pc.rc = nil 389 | return pc.writeMessage([]byte("-ERR unauthorized\r\n")) 390 | } 391 | } 392 | } 393 | 394 | // We are now connected to a server in the cluster. 395 | // Write the actual bytes to the server. 396 | if _, err := pc.rc.Write(cmdData); err != nil { 397 | // Error writing data, due to network issue, or the server 398 | // going down, etc. 399 | // Close the connection and try again. 400 | pc.rc.Close() 401 | pc.rc = nil 402 | continue 403 | } 404 | 405 | // Read the response. 406 | resp, err := readRESP(nil, pc.rd) 407 | if err != nil { 408 | if err == errInvalidRESP { 409 | // Invalid resp means that the redis server sent back that 410 | // contained invalid data. 411 | // Consider this broken and close the connection. 412 | pc.writeMessage([]byte("-ERR invalid internal response\r\n")) 413 | return err 414 | } else { 415 | // Error reading data, due to network issue, or the server 416 | // going down, etc. 417 | // Close the connection and try again. 418 | pc.rc.Close() 419 | pc.rc = nil 420 | continue 421 | } 422 | } 423 | if resp[0] == '-' { 424 | emsg := strings.TrimSpace(string(resp[1:])) 425 | if isLeadershipError(emsg) { 426 | // Leadership error is one that requires closing the connection 427 | // and attempting to reconnect to a valid leader. 428 | pc.rc.Close() 429 | pc.rc = nil 430 | if strings.HasPrefix(emsg, "TRY ") { 431 | leaderAddr = err.Error()[4:] 432 | } else if strings.HasPrefix(emsg, "MOVED ") { 433 | parts := strings.Split(emsg, " ") 434 | if len(parts) == 3 { 435 | leaderAddr = parts[2] 436 | } 437 | } else { 438 | // CLUSTERDOWN or a leadership change in progress. 439 | // Give a small delay and beore retrying 440 | time.Sleep(time.Millisecond * 250) 441 | } 442 | continue 443 | } else if strings.HasPrefix(emsg, "NOAUTH") { 444 | // Make error consistent 445 | resp = []byte("-ERR unauthorized\r\n") 446 | } 447 | } 448 | usedAddr = addr 449 | execOK = true 450 | return pc.writeMessage(resp) 451 | } 452 | return pc.writeMessage([]byte("-ERR connection timeout\r\n")) 453 | } 454 | -------------------------------------------------------------------------------- /cmd/rfront/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "os" 10 | "path/filepath" 11 | "sync" 12 | 13 | "github.com/fsnotify/fsnotify" 14 | "github.com/tidwall/gjson" 15 | "github.com/tidwall/jsonc" 16 | ) 17 | 18 | type configCluster struct { 19 | Addrs []string `json:"addrs"` 20 | Auth string `json:"auth"` 21 | } 22 | 23 | type configACL struct { 24 | Tokens []string `json:"tokens"` 25 | Access string `json:"access"` 26 | Except []string `json:"except"` 27 | } 28 | 29 | type configNamespace struct { 30 | Cluster configCluster `json:"cluster"` 31 | ACL []configACL `json:"acl"` 32 | } 33 | 34 | type config struct { 35 | Port int `json:"port"` 36 | Hosts []string `json:"hosts"` 37 | Namespaces map[string]configNamespace `json:"namespaces"` 38 | } 39 | 40 | func jsonEquals(a, b interface{}) bool { 41 | data1, err := json.Marshal(a) 42 | if err != nil { 43 | return false 44 | } 45 | data2, err := json.Marshal(b) 46 | if err != nil { 47 | return false 48 | } 49 | return bytes.Equal(data1, data2) 50 | } 51 | 52 | func readConfig(path string) (cfg config, err error) { 53 | data, err := os.ReadFile(path) 54 | if err != nil { 55 | return cfg, err 56 | } 57 | data = jsonc.ToJSONInPlace(data) 58 | if err := json.Unmarshal(data, &cfg); err != nil { 59 | return cfg, err 60 | } 61 | // Load root-level namespace 62 | var defnspace configNamespace 63 | vcluster := gjson.GetBytes(data, "cluster") 64 | vacl := gjson.GetBytes(data, "acl") 65 | if vcluster.Exists() || vacl.Exists() { 66 | if _, ok := cfg.Namespaces[""]; ok { 67 | return cfg, errors.New("Ambiguous default namespace. " + 68 | "Cannot have both root-level 'cluster' and 'acl' fields and " + 69 | "a default namespace at the same time.") 70 | } 71 | if err := json.Unmarshal(data, &defnspace); err != nil { 72 | return cfg, err 73 | } 74 | if cfg.Namespaces == nil { 75 | cfg.Namespaces = make(map[string]configNamespace) 76 | } 77 | cfg.Namespaces[""] = defnspace 78 | } 79 | return cfg, nil 80 | } 81 | 82 | // loadConfigAndFollowChanges loads the configuration file and continues to 83 | // monitor and updates the systems when changes happen. 84 | func loadConfigAndFollowChanges(path string, namespace *namespaceMap) ( 85 | config, error, 86 | ) { 87 | var ferr error 88 | var fcfg config 89 | w, err := fsnotify.NewWatcher() 90 | if err != nil { 91 | return fcfg, err 92 | } 93 | var wg sync.WaitGroup 94 | wg.Add(2) 95 | go func() { 96 | ferr = w.Add(filepath.Dir(path)) 97 | wg.Done() 98 | if ferr != nil { 99 | wg.Done() 100 | return 101 | } 102 | 103 | var once bool // for first run detection 104 | var lcfg config // last known config 105 | 106 | for { 107 | if once { 108 | for { 109 | e := <-w.Events 110 | if e.Op == fsnotify.Write { 111 | break 112 | } 113 | } 114 | } 115 | // An event changed in the 116 | cfg2, err := readConfig(path) 117 | if err != nil { 118 | if !once { 119 | ferr = err 120 | wg.Done() 121 | return 122 | } else { 123 | log.Printf("%s", err) 124 | } 125 | continue 126 | } 127 | 128 | if !once || !jsonEquals(cfg2, lcfg) { 129 | // A change to the configurate has occurred. 130 | if !once || !jsonEquals(cfg2.Hosts, lcfg.Hosts) || 131 | !jsonEquals(cfg2.Port, lcfg.Port) { 132 | // cannot dyanically update Hosts or Port 133 | if once { 134 | log.Printf( 135 | "server: updated (requires restarting program)") 136 | } 137 | } 138 | // Update all new/existing namespaces 139 | for name, ncfg := range cfg2.Namespaces { 140 | lncfg, lnok := lcfg.Namespaces[name] 141 | cluster, acl, exists := namespace.get(name) 142 | if cluster == nil { 143 | cluster = &clusterInfo{ 144 | leader: new(leaderAddr), 145 | } 146 | } 147 | if acl == nil { 148 | acl = new(aclMap) 149 | } 150 | var clusterUpdated bool 151 | var aclUpdated bool 152 | err := func() error { 153 | var err error 154 | if !once || !jsonEquals(ncfg.Cluster, lncfg.Cluster) { 155 | err = cluster.update(&ncfg.Cluster) 156 | if err != nil { 157 | return err 158 | } 159 | clusterUpdated = true 160 | } 161 | if !once || !jsonEquals(ncfg.ACL, lncfg.ACL) { 162 | err = acl.update(&ncfg.ACL) 163 | if err != nil { 164 | return err 165 | } 166 | aclUpdated = true 167 | } 168 | return nil 169 | }() 170 | if err != nil { 171 | err = fmt.Errorf("namespace '%s': %s", name, err) 172 | if !once { 173 | ferr = err 174 | wg.Done() 175 | return 176 | } else { 177 | log.Printf("%s", err) 178 | } 179 | } 180 | if !exists { 181 | namespace.set(name, cluster, acl) 182 | } 183 | if once { 184 | if !lnok { 185 | log.Printf("namespace '%s': added", name) 186 | } else { 187 | if clusterUpdated { 188 | log.Printf("namespace '%s': cluster updated", 189 | name) 190 | } 191 | if aclUpdated { 192 | log.Printf("namespace '%s': acl updated", name) 193 | } 194 | } 195 | } 196 | } 197 | 198 | // delete removed namespaces 199 | var deletedNames []string 200 | for name := range lcfg.Namespaces { 201 | _, ok := cfg2.Namespaces[name] 202 | if !ok { 203 | deletedNames = append(deletedNames, name) 204 | } 205 | } 206 | namespace.delete(deletedNames...) 207 | for _, name := range deletedNames { 208 | log.Printf("namespace '%s': deleted", name) 209 | } 210 | 211 | lcfg = cfg2 212 | } 213 | if !once { 214 | once = true 215 | fcfg = lcfg 216 | wg.Done() 217 | } 218 | } 219 | }() 220 | 221 | wg.Wait() 222 | return fcfg, ferr 223 | } 224 | -------------------------------------------------------------------------------- /cmd/rfront/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Joshua J Baker. All rights reserved. 2 | // Use of this source code is governed by an MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "crypto/tls" 9 | "flag" 10 | "fmt" 11 | "log" 12 | "net" 13 | "net/http" 14 | "strings" 15 | "time" 16 | 17 | "github.com/gorilla/websocket" 18 | "golang.org/x/crypto/acme/autocert" 19 | ) 20 | 21 | func main() { 22 | var path string 23 | flag.StringVar(&path, "config", "config.json", "Path to config file") 24 | flag.Parse() 25 | 26 | log.SetFlags(0) 27 | namespaces := &namespaceMap{ 28 | namespaces: make(map[string]*namespaceInfo), 29 | } 30 | cfg, err := loadConfigAndFollowChanges(path, namespaces) 31 | if err != nil { 32 | log.Fatal(err) 33 | } 34 | 35 | handler := &server{ 36 | namespaces: namespaces, 37 | } 38 | 39 | makeServer := func(addr string) *http.Server { 40 | return &http.Server{ 41 | ReadTimeout: 15 * time.Second, 42 | WriteTimeout: 15 * time.Second, 43 | IdleTimeout: 120 * time.Second, 44 | Handler: handler, 45 | Addr: addr, 46 | } 47 | } 48 | 49 | var s1 *http.Server 50 | var s2 *http.Server 51 | hosts := cfg.Hosts 52 | port := cfg.Port 53 | for { 54 | if len(hosts) > 0 { 55 | mgr := &autocert.Manager{ 56 | Prompt: autocert.AcceptTOS, 57 | HostPolicy: autocert.HostWhitelist(hosts...), 58 | Cache: autocert.DirCache("./certs"), 59 | } 60 | s1 = &http.Server{Addr: ":http", Handler: mgr.HTTPHandler(nil)} 61 | s2 = makeServer(":https") 62 | s2.TLSConfig = &tls.Config{GetCertificate: mgr.GetCertificate} 63 | ln1, err := net.Listen("tcp", s1.Addr) 64 | if err != nil { 65 | log.Fatal(err) 66 | } 67 | ln2, err := net.Listen("tcp", s2.Addr) 68 | if err != nil { 69 | log.Fatal(err) 70 | } 71 | for _, host := range hosts { 72 | log.Printf("Listening at https://%s", host) 73 | } 74 | go func() { 75 | log.Fatal(s1.Serve(ln1)) 76 | }() 77 | log.Fatal(s2.ServeTLS(ln2, "", "")) 78 | } else { 79 | s1 = makeServer(fmt.Sprintf(":%d", port)) 80 | ln, err := net.Listen("tcp", s1.Addr) 81 | if err != nil { 82 | log.Fatal(err) 83 | } 84 | log.Printf("Listening at http://0.0.0.0:%d", port) 85 | log.Fatal(s1.Serve(ln)) 86 | } 87 | } 88 | } 89 | 90 | var upgrader = websocket.Upgrader{} // use default options 91 | 92 | type server struct { 93 | namespaces *namespaceMap 94 | } 95 | 96 | func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 97 | pc, err := newClient(w, r, s.namespaces) 98 | if err != nil { 99 | log.Print("upgrade:", err) 100 | return 101 | } 102 | defer pc.Close() 103 | err = pc.proxy() 104 | if err != nil { 105 | emsg := err.Error() 106 | if !strings.Contains(emsg, "websocket: close ") && emsg != "EOF" { 107 | log.Printf("proxy: %s", err) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /cmd/rfront/namespace.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "sync" 7 | "sync/atomic" 8 | ) 9 | 10 | type namespaceInfo struct { 11 | mu sync.RWMutex 12 | cluster *clusterInfo 13 | acl *aclMap 14 | } 15 | 16 | type namespaceMap struct { 17 | mu sync.RWMutex 18 | namespaces map[string]*namespaceInfo 19 | } 20 | 21 | func (nmap *namespaceMap) get(nspace string) (*clusterInfo, *aclMap, bool) { 22 | nmap.mu.RLock() 23 | defer nmap.mu.RUnlock() 24 | ninfo, ok := nmap.namespaces[nspace] 25 | if !ok { 26 | return nil, nil, false 27 | } 28 | return ninfo.cluster, ninfo.acl, true 29 | } 30 | 31 | func (nmap *namespaceMap) set(nspace string, 32 | cluster *clusterInfo, acl *aclMap, 33 | ) bool { 34 | nmap.mu.Lock() 35 | defer nmap.mu.Unlock() 36 | ninfo, ok := nmap.namespaces[nspace] 37 | if !ok { 38 | ninfo = &namespaceInfo{} 39 | nmap.namespaces[nspace] = ninfo 40 | } 41 | ninfo.cluster = cluster 42 | ninfo.acl = acl 43 | return ok 44 | } 45 | 46 | func (nmap *namespaceMap) delete(names ...string) { 47 | var infos []*namespaceInfo 48 | 49 | // delete namespaces so new connections cannot use. 50 | nmap.mu.Lock() 51 | for _, name := range names { 52 | ninfo, ok := nmap.namespaces[name] 53 | if ok { 54 | infos = append(infos, ninfo) 55 | delete(nmap.namespaces, name) 56 | } 57 | } 58 | nmap.mu.Unlock() 59 | 60 | // invalid old namespace info so existing connections stop using. 61 | for _, ninfo := range infos { 62 | 63 | for _, acltok := range ninfo.acl.tokens { 64 | acltok.invalidate() 65 | } 66 | ninfo.cluster.mu.Lock() 67 | ninfo.cluster.updated++ 68 | ninfo.cluster.mu.Unlock() 69 | } 70 | } 71 | 72 | type leaderAddr struct { 73 | mu sync.RWMutex 74 | addr string 75 | } 76 | 77 | func (la *leaderAddr) get() string { 78 | la.mu.RLock() 79 | addr := la.addr 80 | la.mu.RUnlock() 81 | return addr 82 | } 83 | func (la *leaderAddr) set(addr string) { 84 | la.mu.Lock() 85 | la.addr = addr 86 | la.mu.Unlock() 87 | } 88 | 89 | type clusterInfo struct { 90 | mu sync.RWMutex 91 | updated uint64 92 | addrs []string 93 | leader *leaderAddr // leader address, atomic 94 | auth string 95 | parent *clusterInfo 96 | } 97 | 98 | func (cluster *clusterInfo) update(cfg *configCluster) error { 99 | cluster.mu.Lock() 100 | cluster.updated++ 101 | cluster.addrs = append([]string{}, cfg.Addrs...) 102 | cluster.auth = cfg.Auth 103 | cluster.mu.Unlock() 104 | return nil 105 | } 106 | 107 | func (cluster *clusterInfo) copy() *clusterInfo { 108 | cluster.mu.RLock() 109 | copy := &clusterInfo{ 110 | updated: cluster.updated, 111 | leader: cluster.leader, 112 | addrs: cluster.addrs, 113 | auth: cluster.auth, 114 | parent: cluster, 115 | } 116 | cluster.mu.RUnlock() 117 | return copy 118 | } 119 | 120 | func (cluster *clusterInfo) valid() bool { 121 | var valid bool 122 | cluster.mu.RLock() 123 | if cluster.parent != nil { 124 | cluster.parent.mu.RLock() 125 | valid = cluster.parent.updated == cluster.updated 126 | cluster.parent.mu.RUnlock() 127 | } 128 | cluster.mu.RUnlock() 129 | return valid 130 | } 131 | 132 | type aclToken struct { 133 | invalid int32 // atomic: bool 134 | allow bool 135 | except map[string]bool 136 | } 137 | 138 | func (acltok *aclToken) valid() bool { 139 | return atomic.LoadInt32(&acltok.invalid) == 0 140 | } 141 | func (acltok *aclToken) invalidate() { 142 | atomic.StoreInt32(&acltok.invalid, 1) 143 | } 144 | 145 | type aclMap struct { 146 | mu sync.RWMutex 147 | tokens map[string]*aclToken 148 | } 149 | 150 | func (acl *aclMap) auth(token string) *aclToken { 151 | acl.mu.RLock() 152 | defer acl.mu.RUnlock() 153 | return acl.tokens[token] 154 | } 155 | 156 | func (acl *aclMap) update(cfg *[]configACL) error { 157 | tokens := make(map[string]*aclToken) 158 | for i, acl := range *cfg { 159 | var allow bool 160 | switch acl.Access { 161 | case "allow": 162 | allow = true 163 | case "disallow": 164 | allow = false 165 | default: 166 | if acl.Access == "" { 167 | return fmt.Errorf("acl %d: missing kind\n", i) 168 | } 169 | return fmt.Errorf("acl %d: invalid kind: %s\n", i, acl.Access) 170 | } 171 | acltok := aclToken{ 172 | allow: allow, 173 | except: make(map[string]bool), 174 | } 175 | for _, cmd := range acl.Except { 176 | acltok.except[strings.ToLower(cmd)] = true 177 | } 178 | for _, token := range acl.Tokens { 179 | tokens[token] = &acltok 180 | } 181 | } 182 | // all is good, update acl now. 183 | acl.mu.Lock() 184 | oldtoks := acl.tokens 185 | acl.tokens = tokens 186 | acl.mu.Unlock() 187 | 188 | // invalid old tokens, this will cause the connected users 189 | for _, acltok := range oldtoks { 190 | acltok.invalidate() 191 | } 192 | return nil 193 | } 194 | -------------------------------------------------------------------------------- /cmd/rfront/utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "io" 7 | "net" 8 | "net/http" 9 | "strconv" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | func isLeadershipError(emsg string) bool { 15 | switch { 16 | case strings.HasPrefix(emsg, "MOVED "): 17 | return true 18 | case strings.HasPrefix(emsg, "CLUSTERDOWN "): 19 | return true 20 | case strings.HasPrefix(emsg, "TRYAGAIN "): 21 | return true 22 | case strings.HasPrefix(emsg, "TRY "): 23 | return true 24 | case strings.HasPrefix(emsg, "LOADING "): 25 | return true 26 | case emsg == "ERR node is not the leader": 27 | return true 28 | case emsg == "ERR leadership lost while committing log": 29 | return true 30 | case emsg == "ERR leadership transfer in progress": 31 | return true 32 | } 33 | return false 34 | } 35 | 36 | func hijackRaw(w http.ResponseWriter, r *http.Request) (net.Conn, error) { 37 | h, ok := w.(http.Hijacker) 38 | if !ok { 39 | return nil, errors.New("response cannot be hijacked") 40 | } 41 | conn, brw, err := h.Hijack() 42 | if err != nil { 43 | return nil, err 44 | } 45 | if err := conn.SetDeadline(time.Time{}); err != nil { 46 | conn.Close() 47 | return nil, err 48 | } 49 | if brw.Reader.Buffered() > 0 || brw.Writer.Buffered() > 0 { 50 | conn.Close() 51 | return nil, err 52 | } 53 | return conn, nil 54 | } 55 | 56 | var errInvalidRESP = errors.New("invalid resp") 57 | 58 | // readRESP from reader and append to dst. 59 | func readRESP(dst []byte, r *bufio.Reader) ([]byte, error) { 60 | b, err := r.ReadByte() 61 | if err != nil { 62 | return nil, err 63 | } 64 | dst = append(dst, b) 65 | mark := len(dst) 66 | line, err := r.ReadBytes('\n') 67 | if err != nil { 68 | return nil, err 69 | } 70 | dst = append(dst, line...) 71 | switch b { 72 | case '-', '+', ':': 73 | return dst, nil 74 | case '*', '$': 75 | buf := dst[mark : len(dst)-1] 76 | if len(buf) > 0 && buf[len(buf)-1] == '\r' { 77 | buf = buf[:len(buf)-1] 78 | } 79 | n, err := strconv.ParseInt(string(buf), 10, 64) 80 | if err != nil { 81 | return nil, errInvalidRESP 82 | } 83 | if b == '*' { 84 | for n > 0 { 85 | dst, err = readRESP(dst, r) 86 | if err != nil { 87 | return nil, err 88 | } 89 | n-- 90 | } 91 | } else { 92 | if n >= 0 { 93 | data := make([]byte, n+2) 94 | _, err := io.ReadFull(r, data) 95 | if err != nil { 96 | return nil, err 97 | } 98 | dst = append(dst, data...) 99 | } 100 | } 101 | default: 102 | return nil, errInvalidRESP 103 | } 104 | return dst, nil 105 | } 106 | 107 | // InputStream is a helper type for managing input streams from inside 108 | // the Data event. 109 | type InputStream struct{ b []byte } 110 | 111 | // Begin accepts a new packet and returns a working sequence of 112 | // unprocessed bytes. 113 | func (is *InputStream) Begin(packet []byte) (data []byte) { 114 | data = packet 115 | if len(is.b) > 0 { 116 | is.b = append(is.b, data...) 117 | data = is.b 118 | } 119 | return data 120 | } 121 | 122 | // End shifts the stream to match the unprocessed data. 123 | func (is *InputStream) End(data []byte) { 124 | if len(data) > 0 { 125 | if len(data) != len(is.b) { 126 | is.b = append(is.b[:0], data...) 127 | } 128 | } else if len(is.b) > 0 { 129 | is.b = is.b[:0] 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tidwall/rfront 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/fsnotify/fsnotify v1.5.4 7 | github.com/gorilla/websocket v1.5.0 8 | github.com/tidwall/gjson v1.14.1 9 | github.com/tidwall/jsonc v0.3.2 10 | github.com/tidwall/redcon v1.4.5 11 | golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa 12 | ) 13 | 14 | require ( 15 | github.com/tidwall/btree v1.1.0 // indirect 16 | github.com/tidwall/match v1.1.1 // indirect 17 | github.com/tidwall/pretty v1.2.0 // indirect 18 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect 19 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad // indirect 20 | golang.org/x/text v0.3.6 // indirect 21 | ) 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= 2 | github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= 3 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 4 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 5 | github.com/tidwall/btree v1.1.0 h1:5P+9WU8ui5uhmcg3SoPyTwoI0mVyZ1nps7YQzTZFkYM= 6 | github.com/tidwall/btree v1.1.0/go.mod h1:TzIRzen6yHbibdSfK6t8QimqbUnoxUSrZfeW7Uob0q4= 7 | github.com/tidwall/gjson v1.14.1 h1:iymTbGkQBhveq21bEvAQ81I0LEBork8BFe1CUZXdyuo= 8 | github.com/tidwall/gjson v1.14.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 9 | github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc= 10 | github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE= 11 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 12 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 13 | github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= 14 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 15 | github.com/tidwall/redcon v1.4.5 h1:KHzmVSwymjvfipvKFps1kP+skAjjxjQVdgnO8PrqyxQ= 16 | github.com/tidwall/redcon v1.4.5/go.mod h1:p5Wbsgeyi2VSTBWOcA5vRXrOb9arFTcU2+ZzFjqV75Y= 17 | golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c= 18 | golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 19 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE= 20 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 21 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0= 22 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 23 | golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= 24 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 25 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 26 | --------------------------------------------------------------------------------