| #!/usr/bin/env node |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import http from 'node:http'; |
| import crypto from 'node:crypto'; |
| import { createClient } from 'redis'; |
|
|
| const REDIS_URL = process.env.SRH_CONNECTION_STRING || process.env.REDIS_URL || 'redis://redis:6379'; |
| const TOKEN = process.env.SRH_TOKEN || ''; |
| const PORT = parseInt(process.env.PORT || '80', 10); |
|
|
| |
| |
| |
| function maskRedisUrl(rawUrl) { |
| try { |
| const parsed = new URL(rawUrl); |
| if (parsed.password) parsed.password = '***'; |
| if (parsed.username) parsed.username = '***'; |
| return parsed.toString(); |
| } catch { |
| return '<unparsable redis URL>'; |
| } |
| } |
|
|
| const client = createClient({ url: REDIS_URL }); |
| client.on('error', (err) => console.error('Redis error:', err.message)); |
| await client.connect(); |
| console.log(`Connected to Redis at ${maskRedisUrl(REDIS_URL)}`); |
|
|
| function checkAuth(req) { |
| if (!TOKEN) return true; |
| const auth = req.headers.authorization || ''; |
| const prefix = 'Bearer '; |
| if (!auth.startsWith(prefix)) return false; |
| const provided = auth.slice(prefix.length); |
| if (provided.length !== TOKEN.length) return false; |
| return crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(TOKEN)); |
| } |
|
|
| |
| |
| const ALLOWED_COMMANDS = new Set([ |
| 'GET', 'SET', 'DEL', 'MGET', 'MSET', 'SCAN', |
| 'TTL', 'EXPIRE', 'PEXPIRE', 'EXISTS', 'TYPE', |
| 'HGET', 'HSET', 'HDEL', 'HGETALL', 'HMGET', 'HMSET', 'HKEYS', 'HVALS', 'HEXISTS', 'HLEN', |
| 'LPUSH', 'RPUSH', 'LPOP', 'RPOP', 'LRANGE', 'LLEN', 'LTRIM', |
| 'SADD', 'SREM', 'SMEMBERS', 'SISMEMBER', 'SCARD', |
| 'ZADD', 'ZREM', 'ZRANGE', 'ZRANGEBYSCORE', 'ZREVRANGE', 'ZSCORE', 'ZCARD', 'ZRANDMEMBER', |
| 'GEOADD', 'GEOSEARCH', 'GEOPOS', 'GEODIST', |
| 'INCR', 'DECR', 'INCRBY', 'DECRBY', |
| 'PING', 'ECHO', 'INFO', 'DBSIZE', |
| 'PUBLISH', 'SUBSCRIBE', |
| 'SETNX', 'SETEX', 'PSETEX', 'GETSET', |
| 'APPEND', 'STRLEN', |
| ]); |
|
|
| async function runCommand(args) { |
| const cmd = args[0].toUpperCase(); |
| if (!ALLOWED_COMMANDS.has(cmd)) { |
| throw new Error(`Command not allowed: ${cmd}`); |
| } |
| const cmdArgs = args.slice(1); |
| return client.sendCommand([cmd, ...cmdArgs.map(String)]); |
| } |
|
|
| const MAX_BODY_BYTES = 1024 * 1024; |
|
|
| async function readBody(req) { |
| const chunks = []; |
| let totalLength = 0; |
| for await (const chunk of req) { |
| totalLength += chunk.length; |
| if (totalLength > MAX_BODY_BYTES) { |
| req.destroy(); |
| throw new Error('Request body too large'); |
| } |
| chunks.push(chunk); |
| } |
| return Buffer.concat(chunks).toString(); |
| } |
|
|
| const server = http.createServer(async (req, res) => { |
| res.setHeader('content-type', 'application/json'); |
|
|
| if (!checkAuth(req)) { |
| res.writeHead(401); |
| res.end(JSON.stringify({ error: 'Unauthorized' })); |
| return; |
| } |
|
|
| try { |
| |
| if (req.method === 'POST' && (req.url === '/' || req.url === '')) { |
| const body = JSON.parse(await readBody(req)); |
| const result = await runCommand(body); |
| res.writeHead(200); |
| res.end(JSON.stringify({ result })); |
| return; |
| } |
|
|
| |
| if (req.method === 'POST' && req.url === '/pipeline') { |
| const commands = JSON.parse(await readBody(req)); |
| const results = []; |
| for (const cmd of commands) { |
| try { |
| const result = await runCommand(cmd); |
| results.push({ result }); |
| } catch (err) { |
| results.push({ error: err.message }); |
| } |
| } |
| res.writeHead(200); |
| res.end(JSON.stringify(results)); |
| return; |
| } |
|
|
| |
| if (req.method === 'POST' && req.url === '/multi-exec') { |
| const commands = JSON.parse(await readBody(req)); |
| const multi = client.multi(); |
| for (const cmd of commands) { |
| const cmdName = cmd[0].toUpperCase(); |
| if (!ALLOWED_COMMANDS.has(cmdName)) { |
| res.writeHead(403); |
| res.end(JSON.stringify({ error: `Command not allowed: ${cmdName}` })); |
| return; |
| } |
| multi.sendCommand(cmd.map(String)); |
| } |
| const results = await multi.exec(); |
| res.writeHead(200); |
| res.end(JSON.stringify(results.map((r) => ({ result: r })))); |
| return; |
| } |
|
|
| |
| if (req.method === 'GET' && (req.url === '/' || req.url === '')) { |
| res.writeHead(200); |
| res.end('"Welcome to Serverless Redis HTTP!"'); |
| return; |
| } |
|
|
| |
| if (req.method === 'GET') { |
| const pathname = new URL(req.url, 'http://localhost').pathname; |
| const parts = pathname.slice(1).split('/').map(decodeURIComponent); |
| if (parts.length === 0 || !parts[0]) { |
| res.writeHead(400); |
| res.end(JSON.stringify({ error: 'No command specified' })); |
| return; |
| } |
| const result = await runCommand(parts); |
| res.writeHead(200); |
| res.end(JSON.stringify({ result })); |
| return; |
| } |
|
|
| |
| |
| if (req.method === 'POST') { |
| const pathname = new URL(req.url, 'http://localhost').pathname; |
| const parts = pathname.slice(1).split('/').map(decodeURIComponent); |
| if (parts.length === 0 || !parts[0]) { |
| res.writeHead(400); |
| res.end(JSON.stringify({ error: 'No command specified' })); |
| return; |
| } |
| const result = await runCommand(parts); |
| res.writeHead(200); |
| res.end(JSON.stringify({ result })); |
| return; |
| } |
|
|
| |
| if (req.method === 'OPTIONS') { |
| res.writeHead(204); |
| res.end(); |
| return; |
| } |
|
|
| res.writeHead(404); |
| res.end(JSON.stringify({ error: 'Not found' })); |
| } catch (err) { |
| res.writeHead(500); |
| res.end(JSON.stringify({ error: err.message })); |
| } |
| }); |
|
|
| server.listen(PORT, '0.0.0.0', () => { |
| console.log(`Redis REST proxy listening on 0.0.0.0:${PORT}`); |
| }); |
|
|