;;; elnode-proxy.el -- proxying with elnode -*- lexical-binding: t -*- ;;; Commentary: ;; This is stuff to let you make proxy servers with Elnode. ;;; Code: (require 's) (require 'dash) (require 'web) (require 'elnode) (require 'kv) (require 'cl) ; for destructuring-bind and defun* (defun elnode-proxy/web-hdr-hash->alist (web-hdr) (-filter (lambda (hdr-pair) (unless (member (downcase (symbol-name (car hdr-pair))) '("status-code" "status-string" "status-version")) (cons (symbol-name (car hdr-pair)) (cdr hdr-pair)))) (kvhash->alist web-hdr))) (defun elnode--proxy-x-forwarded-for (httpcon) "Return an X-Forwaded-For header." (let ((ipaddr (elnode-get-remote-ipaddr httpcon)) (hdr (elnode-http-header httpcon "X-Forwarded-For"))) (if hdr (concat hdr (format ", %s" ipaddr)) ipaddr))) (defun elnode-proxy/web-client (httpc header data httpcon web-url header-filter) "The web client used for the proxying. WEB-URL is the origin URL. HEADER-FILTER is a function that will filter the alist of headers." (unless (elnode/con-get httpcon :elnode-proxy-header-sent) (let* ((headers (elnode-proxy/web-hdr-hash->alist header)) (headers-x (condition-case err (funcall header-filter web-url headers) (error (prog1 headers (message "elnode-proxy/web-client: got an error %S while filtering headers %S" err headers)))))) (apply 'elnode-http-start httpcon 200 headers-x)) (elnode/con-put httpcon :elnode-proxy-header-sent t)) (if (eq data :done) (elnode-http-return httpcon) (elnode-http-send-string httpcon data))) (cl-defun elnode-proxy-do (httpcon url &key header-filter) "Do proxying to URL on HTTPCON. A request is made to the specified URL. The URL may include `s-format' patterns for interpolation with any of these variables: path - the path from the HTTPCON params - the params from the HTTPCON query - the params from the HTTPCON as a query For example, \"http://myserver:8000${path}${query}\" would cause \"myserver\" on port 8000 to get the query from the user with the specified path and query. :HEADER-FILTER is an optional function which can be used to filter the headers returned from the HTTP call to the origin. The function is called with the origin URL and the headers as an a-list of symbols." (let* ((method (elnode-http-method httpcon)) (path (elnode-http-pathinfo httpcon)) (params (web-to-query-string (elnode-http-params httpcon))) (params-alist (list (cons "path" path) (cons "query" (if (s-blank? params) "" (concat "?" params))) (cons "params" params))) (web-url (s-format url 'aget params-alist)) hdr-sent) (let ((web-con (web-http-call method (lambda (httpc hdr data) (elnode-proxy/web-client httpc hdr data httpcon web-url (if (functionp header-filter) header-filter ;; Else just pass through (lambda (url headers) headers)))) :mode 'stream :url web-url :extra-headers `(("X-Forwarded-For" . ,(elnode--proxy-x-forwarded-for httpcon)) ("X-Proxy-Client" . "elnode/web"))))) (elnode/con-put httpcon :elnode-child-process web-con)))) (defun elnode-proxy-bounce (httpcon handler host-port) "Bounce this request. If HTTPCON is not a request for port HOST-PORT then bounce to HOST-PORT, else it is a request on HOST-PORT so pass to HANDLER." (destructuring-bind (hostname this-port) (split-string (elnode-server-info httpcon) ":") (if (equal (format "%s" this-port) (format "%s" host-port)) (funcall handler httpcon) (elnode-proxy-do httpcon (format "http://%s:%s${path}${query}" hostname host-port))))) (defun elnode-proxy-make-bouncer (handler host-port) "Make a proxy bouncer handler for HANDLER proc on OTHER-PORT. This is for managing proxy calls. If the resulting handler receives a call on anything than HOST-PORT then it proxies the request to the HOST-PORT. Otherwise it just handles the request." (lambda (httpcon) (elnode-proxy-bounce httpcon handler host-port))) ;;;###autoload (defun elnode-make-proxy (url) "Make a proxy handler sending requests to URL. See `elnode-proxy-do' for how URL is handled. An HTTP user-agent with a specified HTTP proxy sends the full request as the path, eg: GET http://somehost:port/path?query HTTP/1.1 So `elnode-make-proxy' can make (something like) a full proxy server with: (elnode-make-proxy \"${path}${query}\") There may be many things that a full proxy does that this does not do however. Reverse proxying is a simpler and perhaps more useful. Proxying is a form of shortcut evaluation. This function returns having bound it's HTTP connection paremeter to a process which will deliver the content from the downstream HTTP connection." (lambda (httpcon) (elnode-proxy-do httpcon url))) (defvar elnode--proxy-server-port-history nil "History variable used for proxy server port reading.") (defvar elnode--proxy-server-goto-url-history nil "History variable used for proxy goto urls.") ;;;###autoload (defun elnode-make-proxy-server (port &optional url) "Make a proxy server on the specified PORT. Optionally have requests go to URL. If URL is not specified it is \"${path}${query}\". Interactively use C-u to specify the URL." (interactive (list (read-from-minibuffer "proxy server port:" nil nil nil 'elnode--proxy-server-port-history) (if current-prefix-arg (read-from-minibuffer "proxy server goto url:" "${path}${query}" nil nil 'elnode--proxy-server-goto-url-history "${path}${query}") "${path}${query}"))) (let ((proxy-handler (elnode-make-proxy (or url "${path}${query}")))) (elnode-start proxy-handler :port port))) (defun elnode-send-proxy-redirect (httpcon location) "Send back a proxy redirect to LOCATION. A proxy redirect is setting \"X-Accel-Redirect\" to a location, proxies can interpret the header with some kind of internal only URL resolution mechanism and do dispatch to another backend without sending the redirect back to the origin UA." (elnode-http-header-set httpcon "X-Accel-Redirect" location) ;; This is an nginx specific hack because it seems nginx kills the ;; socket once the accel header arrives (condition-case err (elnode-send-redirect httpcon location) (error (unless (string-match "\\(SIGPIPE\\|no longer connected\\)" (format "%s" (cdr err))) (signal (car err) (cdr err)))))) (defun elnode-send-proxy-location (httpcon location) "Send LOCATION with proxying techniques. If the HTTPCON comes from a proxy (detected by checking the \"X-Forwarded-For\") then an `elnode-send-proxy-redirect' to location is sent. Alternately it sets up a direct proxy call to the current server for the location. So, either way, this call causes a shortcut evaluation. Either the upstream proxy server handles the request or we return having bound the current HTTPCON to an internal proxy connection." (if (and (elnode-http-header httpcon "X-Forwarded-For") (not (equal "elnode/web" (elnode-http-header httpcon "X-Proxy-Client")))) (elnode-send-proxy-redirect httpcon location) ;; Else we're not behind a proxy, send a proxy version (let* ((server (elnode-server-info httpcon)) (url (format "http://%s%s" server location))) (funcall (elnode-make-proxy url) httpcon)))) (defun* elnode-proxy-post (httpcon path &key (mode 'batch) callback data extra-headers) "Make an HTTP call to localhost or the first upstream proxy." (let* ((hp-pair (if (elnode-http-header httpcon "X-Forwarded-For") (elnode-get-remote-ipaddr httpcon) (elnode-server-info httpcon))) (url (format "http://%s%s" hp-pair path))) (web-http-post (or callback (lambda (httpc hdr data) (elnode-error "%s post response %S %s" httpcon hdr data))) :url url :mode mode :data data :extra-headers extra-headers))) (defun elnode/proxy-route (httpcon service handler path) "Proxies a particular route from `elnode-route'." (let* ((server (elnode/con-get httpcon :server)) (p2 path) (maps (process-get server :elnode-service-map)) (port (or (kva service maps) (string-to-number (cadr (split-string (elnode-server-info httpcon) ":")))))) ;; Wrap the handler in a bouncer (elnode-proxy-bounce httpcon handler port))) (defun elnode-route (httpcon routes) "Pass HTTPCON to the handler decided by ROUTES. ROUTES is a routing table matching regexs to handlers with extra meta information. Routes may do additional things like cause a route to be proxyed to another server. Using ROUTES you can describe complex multi-process, multi-port elnode configurations. ROUTES is an alist where each element looks like: (REGEXP . FUNCTION) or: (REGEXP FUNCTION `:service' SERVICE-NAME) FUNCTION is a normal elnode handler. SERVICE-NAME is a name that may be attached to the route so that it can be mapped to a TCP port, or even another Emacs process. Mapping service names is done by `elnode-start'." (let* (services (rtable (loop for (path . resource) in table collect (if (atom resource) (list path resource) ;; Else it's a more complex resource description (let* ((handler (car resource)) (service (plist-get (cdr resource) :service)) ;; Make the function from the resource description (func (lambda (httpcon) (elnode/proxy-route httpcon service handler path)))) (when service (push service services)) (list path func)))))) (elnode-hostpath-dispatcher httpcon rtable))) (provide 'elnode-proxy) ;;; elnode-proxy.el ends here