]> git.kaiwu.me - njs.git/commitdiff
Fetch: added forward proxy support with HTTPS tunneling.
authorDmitry Volyntsev <xeioex@nginx.com>
Thu, 9 Oct 2025 23:28:24 +0000 (16:28 -0700)
committerDmitry Volyntsev <xeioexception@gmail.com>
Mon, 27 Oct 2025 16:03:09 +0000 (09:03 -0700)
Supports Basic authentication via Proxy-Authorization header.

- js_fetch_proxy - configures forward proxy URL. It takes proxy URL
    as a parameter. The URL may optionally contain user and password.
     Parameter value can contain variables. If value is empty,
     forward proxy is disabled.

example.conf:
...
  http {
      js_import main.js;

      server {
          listen 8080;

          resolver   127.0.0.1:5353;

          location /api {
              js_fetch_proxy http://user:pass@proxy.example.com:3128;
              js_content main.fetch_handler;
          }
      }
  }

This implements #956 feature request on Github.

14 files changed:
nginx/ngx_http_js_module.c
nginx/ngx_js.c
nginx/ngx_js.h
nginx/ngx_js_fetch.c
nginx/ngx_js_http.c
nginx/ngx_js_http.h
nginx/ngx_qjs_fetch.c
nginx/ngx_stream_js_module.c
nginx/t/js_fetch.t
nginx/t/js_fetch_proxy.t [new file with mode: 0644]
nginx/t/js_fetch_proxy_https.t [new file with mode: 0644]
nginx/t/js_fetch_proxy_keepalive.t [new file with mode: 0644]
nginx/t/js_fetch_proxy_variable.t [new file with mode: 0644]
nginx/t/stream_js_fetch_proxy.t [new file with mode: 0644]

index e3ee42719b49ecdcbd240fcf6af43121f0ac7a2e..8f31b80d7b6f977dbc438255d6a8965d7e26c4b7 100644 (file)
@@ -15,6 +15,8 @@
 typedef struct {
     NGX_JS_COMMON_LOC_CONF;
 
+    ngx_http_complex_value_t  fetch_proxy_cv;
+
     ngx_str_t              content;
     ngx_str_t              header_filter;
     ngx_str_t              body_filter;
@@ -380,6 +382,8 @@ static char *ngx_http_js_content(ngx_conf_t *cf, ngx_command_t *cmd,
     void *conf);
 static char *ngx_http_js_shared_dict_zone(ngx_conf_t *cf, ngx_command_t *cmd,
     void *conf);
+static char *ngx_http_js_fetch_proxy(ngx_conf_t *cf, ngx_command_t *cmd,
+    void *conf);
 static char *ngx_http_js_body_filter_set(ngx_conf_t *cf, ngx_command_t *cmd,
     void *conf);
 static ngx_int_t ngx_http_js_init_conf_vm(ngx_conf_t *cf,
@@ -593,6 +597,13 @@ static ngx_command_t  ngx_http_js_commands[] = {
       offsetof(ngx_http_js_loc_conf_t, fetch_keepalive_timeout),
       NULL },
 
+    { ngx_string("js_fetch_proxy"),
+      NGX_HTTP_MAIN_CONF|NGX_HTTP_SRV_CONF|NGX_HTTP_LOC_CONF|NGX_CONF_TAKE1,
+      ngx_http_js_fetch_proxy,
+      NGX_HTTP_LOC_CONF_OFFSET,
+      0,
+      NULL },
+
       ngx_null_command
 };
 
@@ -818,6 +829,16 @@ static njs_external_t  ngx_http_js_ext_request[] = {
         }
     },
 
+    {
+        .flags = NJS_EXTERN_PROPERTY,
+        .name.string = njs_str("requestLine"),
+        .enumerable = 1,
+        .u.property = {
+            .handler = ngx_js_ext_string,
+            .magic32 = offsetof(ngx_http_request_t, request_line),
+        }
+    },
+
     {
         .flags = NJS_EXTERN_PROPERTY,
         .name.string = njs_str("requestText"),
@@ -1080,6 +1101,8 @@ static const JSCFunctionListEntry ngx_http_qjs_ext_request[] = {
     JS_CGETSET_DEF("remoteAddress", ngx_http_qjs_ext_remote_address, NULL),
     JS_CGETSET_MAGIC_DEF("requestBuffer", ngx_http_qjs_ext_request_body, NULL,
                          NGX_JS_BUFFER),
+    JS_CGETSET_MAGIC_DEF("requestLine", ngx_http_qjs_ext_string, NULL,
+                         offsetof(ngx_http_request_t, request_line)),
     JS_CGETSET_MAGIC_DEF("requestText", ngx_http_qjs_ext_request_body, NULL,
                          NGX_JS_STRING),
     JS_CGETSET_MAGIC_DEF("responseBuffer", ngx_http_qjs_ext_response_body, NULL,
@@ -8016,6 +8039,60 @@ ngx_http_js_set(ngx_conf_t *cf, ngx_command_t *cmd, void *conf)
 }
 
 
+static ngx_int_t
+ngx_http_js_eval_proxy_url(ngx_pool_t *pool, void *request,
+    void *module_conf, ngx_url_t **url_out, ngx_str_t *auth_out)
+{
+    ngx_str_t                value;
+    ngx_http_request_t      *r;
+    ngx_http_js_loc_conf_t  *jlcf;
+
+    r = request;
+    jlcf = module_conf;
+
+    if (ngx_http_complex_value(r, &jlcf->fetch_proxy_cv, &value) != NGX_OK) {
+        return NGX_ERROR;
+    }
+
+    return ngx_js_parse_proxy_url(pool, r->connection->log, &value,
+                                  url_out, auth_out);
+}
+
+
+static char *
+ngx_http_js_fetch_proxy(ngx_conf_t *cf, ngx_command_t *cmd, void *conf)
+{
+    ngx_str_t                        *value;
+    ngx_uint_t                        n;
+    ngx_http_js_loc_conf_t           *jlcf;
+    ngx_http_compile_complex_value_t  ccv;
+
+    value = cf->args->elts;
+
+    n = ngx_http_script_variables_count(&value[1]);
+
+    if (n) {
+        ngx_memzero(&ccv, sizeof(ngx_http_compile_complex_value_t));
+
+        jlcf = conf;
+
+        ccv.cf = cf;
+        ccv.value = &value[1];
+        ccv.complex_value = &jlcf->fetch_proxy_cv;
+
+        if (ngx_http_compile_complex_value(&ccv) != NGX_OK) {
+            return NGX_CONF_ERROR;
+        }
+
+        jlcf->eval_proxy_url = ngx_http_js_eval_proxy_url;
+
+        return NGX_CONF_OK;
+    }
+
+    return ngx_js_fetch_proxy(cf, cmd, conf);
+}
+
+
 static char *
 ngx_http_js_var(ngx_conf_t *cf, ngx_command_t *cmd, void *conf)
 {
index 1b466a0e23aedbe5fd87f423d94872cbdb3aa97f..71df075d1dde20e03d5cf98f0344966255017db0 100644 (file)
@@ -3267,6 +3267,195 @@ ngx_js_preload_object(ngx_conf_t *cf, ngx_command_t *cmd, void *conf)
 }
 
 
+static ngx_int_t
+ngx_js_build_proxy_auth_header(ngx_pool_t *pool, ngx_str_t *auth_header,
+    ngx_str_t *user, ngx_str_t *pass)
+{
+    u_char     *p;
+    size_t      len;
+    ngx_str_t   userpass, b64;
+
+    userpass.len = user->len + 1 + pass->len;
+    userpass.data = ngx_pnalloc(pool, userpass.len);
+    if (userpass.data == NULL) {
+        return NGX_ERROR;
+    }
+
+    p = ngx_cpymem(userpass.data, user->data, user->len);
+    *p++ = ':';
+    ngx_memcpy(p, pass->data, pass->len);
+
+    b64.len = ngx_base64_encoded_length(userpass.len);
+    b64.data = ngx_pnalloc(pool, b64.len);
+    if (b64.data == NULL) {
+        return NGX_ERROR;
+    }
+
+    ngx_encode_base64(&b64, &userpass);
+
+    len = sizeof("Proxy-Authorization: Basic \r\n") - 1 + b64.len;
+    p = ngx_pnalloc(pool, len);
+    if (p == NULL) {
+        return NGX_ERROR;
+    }
+
+    ngx_sprintf(p, "Proxy-Authorization: Basic %V\r\n", &b64);
+    auth_header->data = p;
+    auth_header->len = len;
+
+    return NGX_OK;
+}
+
+
+ngx_int_t
+ngx_js_parse_proxy_url(ngx_pool_t *pool, ngx_log_t *log, ngx_str_t *url,
+    ngx_url_t **url_out, ngx_str_t *auth_header_out)
+{
+    u_char     *p, *at, *colon, *host_start, *user_start, *pass_start;
+    u_char     *decoded_user, *decoded_pass, *decoded_end;
+    size_t      user_len, pass_len;
+    ngx_url_t  *u;
+    ngx_str_t   user, pass;
+
+    if (url->len == 0) {
+        *url_out = NULL;
+        ngx_str_null(auth_header_out);
+        return NGX_OK;
+    }
+
+    if (ngx_strncmp(url->data, "http://", sizeof("http://") - 1) != 0) {
+        ngx_log_error(NGX_LOG_ERR, log, 0,
+                      "js_fetch_proxy URL must use http:// scheme");
+        return NGX_ERROR;
+    }
+
+    host_start = url->data + (sizeof("http://") - 1);
+    at = ngx_strlchr(host_start, url->data + url->len, '@');
+
+    ngx_str_null(auth_header_out);
+
+    if (at != NULL) {
+        colon = NULL;
+
+        for (p = at - 1; p > host_start; p--) {
+            if (*p == ':') {
+                colon = p;
+                break;
+            }
+        }
+
+        if (colon == NULL) {
+            ngx_log_error(NGX_LOG_ERR, log, 0,
+                          "js_fetch_proxy URL credentials must be in "
+                          "user:password format");
+            return NGX_ERROR;
+        }
+
+        user_start = host_start;
+        user_len = colon - host_start;
+        pass_start = colon + 1;
+        pass_len = at - pass_start;
+
+        decoded_user = ngx_pnalloc(pool, 128);
+        if (decoded_user == NULL) {
+            return NGX_ERROR;
+        }
+
+        decoded_pass = ngx_pnalloc(pool, 128);
+        if (decoded_pass == NULL) {
+            return NGX_ERROR;
+        }
+
+        p = user_start;
+        decoded_end = decoded_user;
+        ngx_unescape_uri(&decoded_end, &p, user_len, NGX_UNESCAPE_URI);
+
+        user_len = decoded_end - decoded_user;
+        if (user_len == 0 || user_len > 127) {
+            ngx_log_error(NGX_LOG_ERR, log, 0,
+                          "js_fetch_proxy username invalid or too long "
+                          "(max 127 bytes after decoding)");
+            return NGX_ERROR;
+        }
+
+        p = pass_start;
+        decoded_end = decoded_pass;
+        ngx_unescape_uri(&decoded_end, &p, pass_len, NGX_UNESCAPE_URI);
+
+        pass_len = decoded_end - decoded_pass;
+        if (pass_len == 0 || pass_len > 127) {
+            ngx_log_error(NGX_LOG_ERR, log, 0,
+                          "js_fetch_proxy password invalid or too long "
+                          "(max 127 bytes after decoding)");
+            return NGX_ERROR;
+        }
+
+        user.data = decoded_user;
+        user.len = user_len;
+        pass.data = decoded_pass;
+        pass.len = pass_len;
+
+        if (ngx_js_build_proxy_auth_header(pool, auth_header_out,
+                                           &user, &pass)
+            != NGX_OK)
+        {
+            return NGX_ERROR;
+        }
+
+        host_start = at + 1;
+    }
+
+    u = ngx_pcalloc(pool, sizeof(ngx_url_t));
+    if (u == NULL) {
+        return NGX_ERROR;
+    }
+
+    u->url.data = host_start;
+    u->url.len = url->data + url->len - host_start;
+    u->default_port = 3128;
+    u->no_resolve = 1;
+
+    if (ngx_parse_url(pool, u) != NGX_OK) {
+        ngx_log_error(NGX_LOG_ERR, log, 0, "invalid proxy URL: %V", url);
+        return NGX_ERROR;
+    }
+
+    *url_out = u;
+
+    return NGX_OK;
+}
+
+
+char *
+ngx_js_fetch_proxy(ngx_conf_t *cf, ngx_command_t *cmd, void *conf)
+{
+    ngx_str_t          *value;
+    ngx_js_loc_conf_t  *jscf;
+
+    jscf = conf;
+
+    value = cf->args->elts;
+
+    if (ngx_js_parse_proxy_url(cf->pool, cf->log, &value[1],
+                               &jscf->fetch_proxy_url,
+                               &jscf->fetch_proxy_auth_header)
+        != NGX_OK)
+    {
+        ngx_conf_log_error(NGX_LOG_EMERG, cf, 0,
+                           "invalid proxy URL: %V", &value[1]);
+        return NGX_CONF_ERROR;
+    }
+
+    if (jscf->fetch_proxy_url == NULL) {
+        ngx_conf_log_error(NGX_LOG_EMERG, cf, 0,
+                           "proxy host is empty in URL: %V", &value[1]);
+        return NGX_CONF_ERROR;
+    }
+
+    return NGX_CONF_OK;
+}
+
+
 static ngx_int_t
 ngx_js_init_preload_vm(njs_vm_t *vm, ngx_js_loc_conf_t *conf)
 {
@@ -3946,6 +4135,7 @@ ngx_js_create_conf(ngx_conf_t *cf, size_t size)
      * set by ngx_pcalloc():
      *
      *     conf->reuse_queue = NULL;
+     *     conf->fetch_proxy_auth_header = { 0, NULL };
      */
 
     conf->paths = NGX_CONF_UNSET_PTR;
@@ -3963,6 +4153,8 @@ ngx_js_create_conf(ngx_conf_t *cf, size_t size)
     conf->fetch_keepalive_requests = NGX_CONF_UNSET_UINT;
     conf->fetch_keepalive_time = NGX_CONF_UNSET_MSEC;
     conf->fetch_keepalive_timeout = NGX_CONF_UNSET_MSEC;
+    conf->fetch_proxy_url = NGX_CONF_UNSET_PTR;
+    conf->eval_proxy_url = NGX_CONF_UNSET_PTR;
 
     return conf;
 }
@@ -4082,10 +4274,15 @@ ngx_js_merge_conf(ngx_conf_t *cf, void *parent, void *child,
                               prev->fetch_keepalive_time, 3600000);
     ngx_conf_merge_msec_value(conf->fetch_keepalive_timeout,
                               prev->fetch_keepalive_timeout, 60000);
-
     ngx_queue_init(&conf->fetch_keepalive_cache);
     ngx_queue_init(&conf->fetch_keepalive_free);
 
+    ngx_conf_merge_ptr_value(conf->fetch_proxy_url, prev->fetch_proxy_url,
+                             NULL);
+    ngx_conf_merge_ptr_value(conf->eval_proxy_url, prev->eval_proxy_url, NULL);
+    ngx_conf_merge_str_value(conf->fetch_proxy_auth_header,
+                             prev->fetch_proxy_auth_header, "");
+
     if (ngx_js_merge_vm(cf, (ngx_js_loc_conf_t *) conf,
                         (ngx_js_loc_conf_t *) prev,
                         init_vm)
index 803476696a158714a1d01870d4b447dbe1105e95..f3c2493b804d521470607d1b75c37ee7aeba7427 100644 (file)
@@ -141,7 +141,24 @@ typedef struct {
     ngx_msec_t             fetch_keepalive_time;                              \
     ngx_msec_t             fetch_keepalive_timeout;                           \
     ngx_queue_t            fetch_keepalive_cache;                             \
-    ngx_queue_t            fetch_keepalive_free
+    ngx_queue_t            fetch_keepalive_free;                              \
+                                                                              \
+    ngx_url_t              *fetch_proxy_url;                                  \
+    ngx_str_t               fetch_proxy_auth_header;                          \
+                                                                              \
+    ngx_int_t             (*eval_proxy_url)(ngx_pool_t *pool,                 \
+                                            void *request,                    \
+                                            void *module_conf,                \
+                                            ngx_url_t **url_out,              \
+                                            ngx_str_t *auth_out)
+
+#define ngx_js_conf_dynamic_proxy(conf)                                       \
+     ((conf)->eval_proxy_url != NULL)
+
+#define ngx_js_conf_proxy(conf)                                               \
+    (((conf)->fetch_proxy_url != NULL                                         \
+      && (conf)->fetch_proxy_url->host.len > 0)                               \
+     || ngx_js_conf_dynamic_proxy(conf))
 
 
 #if (NGX_SSL)
@@ -424,6 +441,9 @@ void ngx_js_logger(ngx_connection_t *c, ngx_uint_t level,
 char * ngx_js_import(ngx_conf_t *cf, ngx_command_t *cmd, void *conf);
 char * ngx_js_engine(ngx_conf_t *cf, ngx_command_t *cmd, void *conf);
 char * ngx_js_preload_object(ngx_conf_t *cf, ngx_command_t *cmd, void *conf);
+char * ngx_js_fetch_proxy(ngx_conf_t *cf, ngx_command_t *cmd, void *conf);
+ngx_int_t ngx_js_parse_proxy_url(ngx_pool_t *pool, ngx_log_t *log,
+    ngx_str_t *url_str, ngx_url_t **url_out, ngx_str_t *auth_header_out);
 ngx_int_t ngx_js_merge_vm(ngx_conf_t *cf, ngx_js_loc_conf_t *conf,
     ngx_js_loc_conf_t *prev,
     ngx_int_t (*init_vm)(ngx_conf_t *cf, ngx_js_loc_conf_t *conf));
index 992cc1d0d1e141fcf827db061b8274fb14325134..09d0e19476597018b8d8916af37e47b2996c383c 100644 (file)
@@ -508,6 +508,7 @@ ngx_js_ext_fetch(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs,
 {
     njs_int_t            ret;
     ngx_url_t            u;
+    ngx_str_t           *resolve_host;
     ngx_pool_t          *pool;
     njs_value_t         *init, *value;
     ngx_js_http_t       *http;
@@ -595,12 +596,45 @@ ngx_js_ext_fetch(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs,
     NJS_CHB_MP_INIT(&http->chain, njs_vm_memory_pool(vm));
     NJS_CHB_MP_INIT(&http->response.chain, njs_vm_memory_pool(vm));
 
-    ngx_js_fetch_build_request(http, &request, &u.uri, &u);
+    resolve_host = NULL;
+    http->connect_port = http->port;
+
+    if (ngx_js_conf_proxy(http->conf)) {
+        if (ngx_js_conf_dynamic_proxy(http->conf)) {
+            if (http->conf->eval_proxy_url(http->pool, external, http->conf,
+                                           &http->proxy.url, &http->proxy.auth)
+                != NGX_OK)
+            {
+                njs_vm_error(vm, "failed to evaluate proxy URL");
+                goto fail;
+            }
+
+        } else {
+            http->proxy.url = http->conf->fetch_proxy_url;
+            http->proxy.auth = http->conf->fetch_proxy_auth_header;
+        }
+
+        if (ngx_js_http_proxy(http) && http->proxy.url->addrs == NULL) {
+            resolve_host = &http->proxy.url->host;
+            http->connect_port = http->proxy.url->port;
+        }
+    }
+
+    if (!ngx_js_http_proxy(http) && u.addrs == NULL) {
+        resolve_host = &u.host;
+    }
+
+    ngx_js_fetch_build_request(http, &request, &u.uri, &u,
+                               ngx_js_http_proxy(http) && !ngx_js_https(&u));
+
+    if (resolve_host != NULL) {
+        ngx_log_debug0(NGX_LOG_DEBUG_EVENT, http->log, 0,
+                       "js http fetch: resolving");
 
-    if (u.addrs == NULL) {
         ctx = ngx_js_http_resolve(http, ngx_external_resolver(vm, external),
-                                  &u.host,
+                                  resolve_host,
                                   ngx_external_resolver_timeout(vm, external));
+
         if (ctx == NULL) {
             njs_vm_memory_error(vm);
             return NJS_ERROR;
@@ -617,9 +651,15 @@ ngx_js_ext_fetch(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs,
     }
 
     http->naddrs = 1;
-    ngx_memcpy(&http->addr, &u.addrs[0], sizeof(ngx_addr_t));
     http->addrs = &http->addr;
 
+    if (ngx_js_http_proxy(http)) {
+        ngx_memcpy(&http->addr, &http->proxy.url->addrs[0], sizeof(ngx_addr_t));
+
+    } else {
+        ngx_memcpy(&http->addr, &u.addrs[0], sizeof(ngx_addr_t));
+    }
+
     ngx_js_http_connect(http);
 
     njs_value_assign(retval, njs_value_arg(&fetch->promise));
@@ -1031,6 +1071,12 @@ ngx_js_fetch_alloc(njs_vm_t *vm, ngx_pool_t *pool, ngx_log_t *log,
         goto failed;
     }
 
+    /*
+     * set by ngx_pcalloc():
+     *
+     * fetch->http.proxy.state = HTTP_STATE_DIRECT;
+     */
+
     http = &fetch->http;
 
     http->pool = pool;
index 9f150f0caa78a6144c866d2746c896436538b5d1..32c59461312c14192d4bd812f58dc3e312f9ff3b 100644 (file)
@@ -49,13 +49,15 @@ static ngx_int_t ngx_js_http_parse_chunked(ngx_js_http_chunk_parse_t *hcp,
     ngx_buf_t *b, njs_chb_t *chain);
 
 static void ngx_js_fetch_append_request_headers(njs_chb_t *chain,
-    ngx_js_request_t *request);
+    ngx_js_request_t *request, njs_bool_t is_proxy);
 
 #if (NGX_SSL)
 static void ngx_js_http_ssl_init_connection(ngx_js_http_t *http);
 static void ngx_js_http_ssl_handshake_handler(ngx_connection_t *c);
 static void ngx_js_http_ssl_handshake(ngx_js_http_t *http);
 static ngx_int_t ngx_js_http_ssl_name(ngx_js_http_t *http);
+static void ngx_js_http_build_connect_request(ngx_js_http_t *http);
+static ngx_int_t ngx_js_http_process_connect_response(ngx_js_http_t *http);
 #endif
 
 
@@ -166,7 +168,7 @@ ngx_js_http_resolve_handler(ngx_resolver_ctx_t *ctx)
         }
 
         ngx_memcpy(sockaddr, ctx->addrs[i].sockaddr, socklen);
-        ngx_inet_set_port(sockaddr, http->port);
+        ngx_inet_set_port(sockaddr, http->connect_port);
 
         http->addrs[i].sockaddr = sockaddr;
         http->addrs[i].socklen = socklen;
@@ -301,16 +303,37 @@ ngx_js_http_connect(ngx_js_http_t *http)
     c->write->handler = ngx_js_http_write_handler;
     c->read->handler = ngx_js_http_read_handler;
 
-    http->process = ngx_js_http_process_status_line;
-
     ngx_add_timer(c->read, http->conf->timeout);
     ngx_add_timer(c->write, http->conf->timeout);
 
 #if (NGX_SSL)
-    if (http->ssl != NULL && c->ssl == NULL) {
+    if (ngx_js_conf_proxy(http->conf) && http->ssl != NULL && c->ssl == NULL) {
+        http->proxy.pending = ngx_js_chain_to_buf(http->pool, &http->chain);
+        if (http->proxy.pending == NULL) {
+            ngx_js_http_error(http, "memory error");
+            return;
+        }
+
+        njs_chb_destroy(&http->chain);
+        ngx_js_http_build_connect_request(http);
+
+        http->proxy.state = HTTP_STATE_PROXY_CONNECT_PENDING;
+        http->process = ngx_js_http_process_connect_response;
+
+    } else {
+        if (ngx_js_conf_proxy(http->conf) && http->ssl != NULL && c->ssl != NULL) {
+            http->proxy.state = HTTP_STATE_PROXY_TUNNEL_READY;
+        }
+
+        http->process = ngx_js_http_process_status_line;
+    }
+
+    if (http->ssl != NULL && c->ssl == NULL && !ngx_js_conf_proxy(http->conf)) {
         ngx_js_http_ssl_init_connection(http);
         return;
     }
+#else
+    http->process = ngx_js_http_process_status_line;
 #endif
 
     if (rc == NGX_OK) {
@@ -409,6 +432,15 @@ ngx_js_http_ssl_handshake(ngx_js_http_t *http)
             ngx_post_event(c->read, &ngx_posted_events);
         }
 
+        if (http->proxy.state == HTTP_STATE_PROXY_TUNNEL_READY) {
+            ngx_log_debug0(NGX_LOG_DEBUG_EVENT, http->log, 0,
+                           "js http send origin request");
+
+            http->buffer = http->proxy.pending;
+            http->proxy.pending = NULL;
+            http->proxy.state = HTTP_STATE_ORIGIN_REQUEST_SENT;
+        }
+
         http->process = ngx_js_http_process_status_line;
         ngx_js_http_write_handler(c->write);
 
@@ -470,6 +502,104 @@ done:
     return NGX_OK;
 }
 
+
+static void
+ngx_js_http_build_connect_request(ngx_js_http_t *http)
+{
+    NGX_CHB_CTX_INIT(&http->chain, http->pool);
+
+    njs_chb_append_literal(&http->chain, "CONNECT ");
+
+    njs_chb_append(&http->chain, http->host.data, http->host.len);
+    njs_chb_sprintf(&http->chain, 32, ":%d HTTP/1.1" CRLF, http->port);
+
+    njs_chb_append_literal(&http->chain, "Host: ");
+
+    njs_chb_append(&http->chain, http->host.data, http->host.len);
+    njs_chb_sprintf(&http->chain, 32, ":%d" CRLF, http->port);
+
+    if (http->proxy.auth.len != 0) {
+        njs_chb_append(&http->chain, http->proxy.auth.data,
+                       http->proxy.auth.len);
+        njs_chb_append_literal(&http->chain, CRLF);
+    }
+}
+
+
+static ngx_int_t
+ngx_js_http_process_connect_response(ngx_js_http_t *http)
+{
+    ngx_int_t             rc;
+    ngx_buf_t            *b;
+    ngx_js_http_parse_t  *hp;
+
+    b = http->buffer;
+    hp = &http->http_parse;
+
+    ngx_log_debug0(NGX_LOG_DEBUG_EVENT, http->log, 0,
+                   "js http process CONNECT response");
+
+    rc = ngx_js_http_parse_status_line(hp, b);
+
+    if (rc == NGX_AGAIN) {
+        return NGX_AGAIN;
+    }
+
+    if (rc != NGX_OK) {
+        ngx_js_http_error(http, "proxy CONNECT: invalid status line");
+        return NGX_ERROR;
+    }
+
+    if (hp->code != 200) {
+        ngx_js_http_error(http, "proxy CONNECT failed with status %ui",
+                         hp->code);
+        return NGX_ERROR;
+    }
+
+    ngx_log_debug1(NGX_LOG_DEBUG_EVENT, http->log, 0,
+                   "js http proxy CONNECT status: %ui", hp->code);
+
+    for (;;) {
+        rc = ngx_js_http_parse_header_line(hp, b);
+
+        if (rc == NGX_OK) {
+            ngx_log_debug2(NGX_LOG_DEBUG_EVENT, http->log, 0,
+                          "js http CONNECT header: \"%*s\"",
+                          hp->header_end - hp->header_name_start,
+                          hp->header_name_start);
+            continue;
+        }
+
+        if (rc == NGX_DONE) {
+            ngx_log_debug0(NGX_LOG_DEBUG_EVENT, http->log, 0,
+                          "js http proxy tunnel established");
+            break;
+        }
+
+        if (rc == NGX_AGAIN) {
+            return NGX_AGAIN;
+        }
+
+        ngx_js_http_error(http, "proxy CONNECT: invalid headers");
+        return NGX_ERROR;
+    }
+
+    http->proxy.state = HTTP_STATE_PROXY_TUNNEL_READY;
+
+    ngx_memzero(hp, sizeof(*hp));
+
+    if (http->ssl == NULL) {
+        ngx_js_http_error(http, "proxy CONNECT: SSL not configured");
+        return NGX_ERROR;
+    }
+
+    ngx_log_debug0(NGX_LOG_DEBUG_EVENT, http->log, 0,
+                   "js http init SSL through proxy tunnel");
+
+    ngx_js_http_ssl_init_connection(http);
+    return NGX_OK;
+}
+
 #endif
 
 
@@ -513,7 +643,9 @@ ngx_js_http_write_handler(ngx_event_t *wev)
     }
 
 #if (NGX_SSL)
-    if (http->ssl != NULL && http->peer.connection->ssl == NULL) {
+    if (http->ssl != NULL && http->peer.connection->ssl == NULL
+        && !ngx_js_conf_proxy(http->conf))
+    {
         ngx_js_http_ssl_init_connection(http);
         return;
     }
@@ -1878,7 +2010,7 @@ ngx_js_chain_to_buf(ngx_pool_t *pool, njs_chb_t *chain)
 
 static void
 ngx_js_fetch_append_request_headers(njs_chb_t *chain,
-    ngx_js_request_t *request)
+    ngx_js_request_t *request, njs_bool_t is_proxy)
 {
     ngx_uint_t        i;
     ngx_list_part_t  *part;
@@ -1923,6 +2055,13 @@ ngx_js_fetch_append_request_headers(njs_chb_t *chain,
             continue;
         }
 
+        if (is_proxy && h[i].key.len == 19
+            && ngx_strncasecmp(h[i].key.data, (u_char *) "Proxy-Authorization",
+                               19) == 0)
+        {
+            continue;
+        }
+
         njs_chb_append(chain, h[i].key.data, h[i].key.len);
         njs_chb_append_literal(chain, ": ");
         njs_chb_append(chain, h[i].value.data, h[i].value.len);
@@ -1933,7 +2072,7 @@ ngx_js_fetch_append_request_headers(njs_chb_t *chain,
 
 void
 ngx_js_fetch_build_request(ngx_js_http_t *http, ngx_js_request_t *request,
-    ngx_str_t *path, ngx_url_t *u)
+    ngx_str_t *path, ngx_url_t *u, njs_bool_t is_proxy)
 {
     ngx_str_t         method;
     ngx_uint_t        i;
@@ -1944,6 +2083,15 @@ ngx_js_fetch_build_request(ngx_js_http_t *http, ngx_js_request_t *request,
     njs_chb_append(&http->chain, request->method.data, request->method.len);
     njs_chb_append_literal(&http->chain, " ");
 
+    if (is_proxy) {
+        njs_chb_append_literal(&http->chain, "http://");
+        njs_chb_append(&http->chain, http->host.data, http->host.len);
+
+        if (http->port != u->default_port) {
+            njs_chb_sprintf(&http->chain, 32, ":%d", http->port);
+        }
+    }
+
     if (path->len == 0 || path->data[0] != '/') {
         njs_chb_append_literal(&http->chain, "/");
     }
@@ -2011,7 +2159,12 @@ ngx_js_fetch_build_request(ngx_js_http_t *http, ngx_js_request_t *request,
         njs_chb_append_literal(&http->chain, CRLF);
     }
 
-    ngx_js_fetch_append_request_headers(&http->chain, request);
+    if (is_proxy && http->proxy.auth.len != 0) {
+        njs_chb_append(&http->chain, http->proxy.auth.data,
+                       http->proxy.auth.len);
+    }
+
+    ngx_js_fetch_append_request_headers(&http->chain, request, is_proxy);
 
     if (!http->keepalive) {
         njs_chb_append_literal(&http->chain, "Connection: close" CRLF);
index bb6496852a9365176b5c25b4abc01200878d7ac4..93fa1966d3e520b239d7fdbe11c8e42a92fd394a 100644 (file)
@@ -120,6 +120,7 @@ struct ngx_js_http_s {
     ngx_uint_t                     naddr;
     ngx_str_t                      host;
     in_port_t                      port;
+    in_port_t                      connect_port;
 
     ngx_peer_connection_t          peer;
 
@@ -155,6 +156,20 @@ struct ngx_js_http_s {
     void                         (*ready_handler)(ngx_js_http_t *http);
     void                         (*error_handler)(ngx_js_http_t *http,
                                                   const char *err);
+
+    struct {
+        enum {
+            HTTP_STATE_DIRECT = 0,
+            HTTP_STATE_PROXY_CONNECT_PENDING,
+            HTTP_STATE_PROXY_TUNNEL_READY,
+            HTTP_STATE_ORIGIN_REQUEST_SENT
+        }                          state;
+
+        ngx_buf_t                 *pending;
+#define ngx_js_http_proxy(http)  ((http)->proxy.url != NULL)
+        ngx_url_t                 *url;
+        ngx_str_t                  auth;
+    } proxy;
 };
 
 
@@ -170,7 +185,7 @@ ngx_int_t ngx_js_check_header_name(u_char *name, size_t len);
 ngx_buf_t *ngx_js_chain_to_buf(ngx_pool_t *pool, njs_chb_t *chain);
 
 void ngx_js_fetch_build_request(ngx_js_http_t *http, ngx_js_request_t *request,
-    ngx_str_t *path, ngx_url_t *u);
+    ngx_str_t *path, ngx_url_t *u, njs_bool_t is_proxy);
 
 
 #endif /* _NGX_JS_HTTP_H_INCLUDED_ */
index afaeb2d51d8173e8d2284a2404fecfbb89733a3b..0b3f3269c3f27caf47996d55a05f163dd24fa0e6 100644 (file)
@@ -236,6 +236,7 @@ ngx_qjs_ext_fetch(JSContext *cx, JSValueConst this_val, int argc,
     JSValue              init, value, promise;
     ngx_int_t            rc;
     ngx_url_t            u;
+    ngx_str_t           *resolve_host;
     ngx_pool_t          *pool;
     ngx_js_ctx_t        *ctx;
     ngx_js_http_t       *http;
@@ -332,12 +333,45 @@ ngx_qjs_ext_fetch(JSContext *cx, JSValueConst this_val, int argc,
     NJS_CHB_MP_INIT(&http->chain, ctx->engine->pool);
     NJS_CHB_MP_INIT(&http->response.chain, ctx->engine->pool);
 
-    ngx_js_fetch_build_request(http, &request, &u.uri, &u);
+    resolve_host = NULL;
+    http->connect_port = http->port;
+
+    if (ngx_js_conf_proxy(http->conf)) {
+        if (ngx_js_conf_dynamic_proxy(http->conf)) {
+            if (http->conf->eval_proxy_url(http->pool, external, http->conf,
+                                           &http->proxy.url, &http->proxy.auth)
+                != NGX_OK)
+            {
+                JS_ThrowInternalError(cx, "failed to evaluate proxy URL");
+                return JS_EXCEPTION;
+            }
+
+        } else {
+            http->proxy.url = http->conf->fetch_proxy_url;
+            http->proxy.auth = http->conf->fetch_proxy_auth_header;
+        }
+
+        if (ngx_js_http_proxy(http) && http->proxy.url->addrs == NULL) {
+            resolve_host = &http->proxy.url->host;
+            http->connect_port = http->proxy.url->port;
+        }
+    }
+
+    if (!ngx_js_http_proxy(http) && u.addrs == NULL) {
+        resolve_host = &u.host;
+    }
+
+    ngx_js_fetch_build_request(http, &request, &u.uri, &u,
+                               ngx_js_http_proxy(http) && !ngx_js_https(&u));
+
+    if (resolve_host != NULL) {
+        ngx_log_debug0(NGX_LOG_DEBUG_EVENT, http->log, 0,
+                       "js http fetch: resolving");
 
-    if (u.addrs == NULL) {
         rs = ngx_js_http_resolve(http, ngx_qjs_external_resolver(cx, external),
-                                 &u.host,
+                               resolve_host,
                                ngx_qjs_external_resolver_timeout(cx, external));
+
         if (rs == NULL) {
             JS_FreeValue(cx, promise);
             return JS_ThrowOutOfMemory(cx);
@@ -352,9 +386,15 @@ ngx_qjs_ext_fetch(JSContext *cx, JSValueConst this_val, int argc,
     }
 
     http->naddrs = 1;
-    ngx_memcpy(&http->addr, &u.addrs[0], sizeof(ngx_addr_t));
     http->addrs = &http->addr;
 
+    if (ngx_js_http_proxy(http)) {
+        ngx_memcpy(&http->addr, &http->proxy.url->addrs[0], sizeof(ngx_addr_t));
+
+    } else {
+        ngx_memcpy(&http->addr, &u.addrs[0], sizeof(ngx_addr_t));
+    }
+
     ngx_js_http_connect(http);
 
     return promise;
@@ -1089,6 +1129,12 @@ ngx_qjs_fetch_alloc(JSContext *cx, ngx_pool_t *pool, ngx_log_t *log,
         return NULL;
     }
 
+    /*
+     * set by ngx_pcalloc():
+     *
+     * fetch->http.proxy.state = HTTP_STATE_DIRECT;
+     */
+
     http = &fetch->http;
 
     http->pool = pool;
index 40ce004263f1a97135143f8c5352a38271cb4865..cbceaf92366db909689a3476c2869968338820d1 100644 (file)
@@ -17,9 +17,11 @@ typedef struct ngx_stream_js_ctx_s  ngx_stream_js_ctx_t;
 typedef struct {
     NGX_JS_COMMON_LOC_CONF;
 
-    ngx_str_t              access;
-    ngx_str_t              preread;
-    ngx_str_t              filter;
+    ngx_stream_complex_value_t  fetch_proxy_cv;
+
+    ngx_str_t                   access;
+    ngx_str_t                   preread;
+    ngx_str_t                   filter;
 } ngx_stream_js_srv_conf_t;
 
 
@@ -222,6 +224,8 @@ static char *ngx_stream_js_merge_srv_conf(ngx_conf_t *cf, void *parent,
     void *child);
 static char *ngx_stream_js_shared_dict_zone(ngx_conf_t *cf, ngx_command_t *cmd,
     void *conf);
+static char *ngx_stream_js_fetch_proxy(ngx_conf_t *cf, ngx_command_t *cmd,
+    void *conf);
 
 
 static ngx_conf_bitmask_t  ngx_stream_js_engines[] = {
@@ -425,6 +429,13 @@ static ngx_command_t  ngx_stream_js_commands[] = {
       0,
       NULL },
 
+    { ngx_string("js_fetch_proxy"),
+      NGX_STREAM_MAIN_CONF|NGX_STREAM_SRV_CONF|NGX_CONF_TAKE1,
+      ngx_stream_js_fetch_proxy,
+      NGX_STREAM_SRV_CONF_OFFSET,
+      0,
+      NULL },
+
       ngx_null_command
 };
 
@@ -3675,6 +3686,62 @@ ngx_stream_js_shared_dict_zone(ngx_conf_t *cf, ngx_command_t *cmd,
 }
 
 
+static ngx_int_t
+ngx_stream_js_eval_proxy_url(ngx_pool_t *pool, void *request,
+    void *module_conf, ngx_url_t **url_out, ngx_str_t *auth_out)
+{
+    ngx_str_t                   value;
+    ngx_stream_session_t       *s;
+    ngx_stream_js_srv_conf_t   *jscf;
+
+    s = request;
+    jscf = module_conf;
+
+    if (ngx_stream_complex_value(s, &jscf->fetch_proxy_cv, &value)
+        != NGX_OK)
+    {
+        return NGX_ERROR;
+    }
+
+    return ngx_js_parse_proxy_url(pool, s->connection->log, &value,
+                                  url_out, auth_out);
+}
+
+
+static char *
+ngx_stream_js_fetch_proxy(ngx_conf_t *cf, ngx_command_t *cmd, void *conf)
+{
+    ngx_str_t                         *value;
+    ngx_uint_t                         n;
+    ngx_stream_js_srv_conf_t          *jscf;
+    ngx_stream_compile_complex_value_t ccv;
+
+    value = cf->args->elts;
+
+    n = ngx_stream_script_variables_count(&value[1]);
+
+    if (n) {
+        ngx_memzero(&ccv, sizeof(ngx_stream_compile_complex_value_t));
+
+        jscf = conf;
+
+        ccv.cf = cf;
+        ccv.value = &value[1];
+        ccv.complex_value = &jscf->fetch_proxy_cv;
+
+        if (ngx_stream_compile_complex_value(&ccv) != NGX_OK) {
+            return NGX_CONF_ERROR;
+        }
+
+        jscf->eval_proxy_url = ngx_stream_js_eval_proxy_url;
+
+        return NGX_CONF_OK;
+    }
+
+    return ngx_js_fetch_proxy(cf, cmd, conf);
+}
+
+
 static ngx_int_t
 ngx_stream_js_init(ngx_conf_t *cf)
 {
index d26e664a84d80b878031a2d107d272ddd9a19944..9bbacf5bc50c59f8b20e4908e19171ee9cd0661e 100644 (file)
@@ -548,7 +548,7 @@ like(http_get('/body_content_length'), qr/200 OK/s,
 }
 
 like(http_get('/user_agent_header'),
-       qr/200 OK.*nginx-js\/1.0$/s,
+       qr/200 OK.*nginx-js$/s,
        'fetch default user-agent header');
 like(http_get('/user_agent_header?ua=My-User-Agent'),
        qr/200 OK.*My-User-Agent$/s, 'fetch user-agent header');
diff --git a/nginx/t/js_fetch_proxy.t b/nginx/t/js_fetch_proxy.t
new file mode 100644 (file)
index 0000000..8b2a71f
--- /dev/null
@@ -0,0 +1,193 @@
+#!/usr/bin/perl
+
+# (C) Dmitry Volyntsev
+# (C) F5, Inc.
+
+# Tests for http njs module, fetch method with forward proxy.
+
+###############################################################################
+
+use warnings;
+use strict;
+
+use Test::More;
+
+use Socket qw/ CRLF /;
+
+BEGIN { use FindBin; chdir($FindBin::Bin); }
+
+use lib 'lib';
+use Test::Nginx;
+
+###############################################################################
+
+select STDERR; $| = 1;
+select STDOUT; $| = 1;
+
+my $t = Test::Nginx->new()->has(qw/http/)
+       ->write_file_expand('nginx.conf', <<'EOF');
+
+%%TEST_GLOBALS%%
+
+daemon off;
+
+events {
+}
+
+http {
+    %%TEST_GLOBALS_HTTP%%
+
+    js_import test.js;
+
+    server {
+        listen       127.0.0.1:8080;
+        server_name  localhost;
+
+        location /engine {
+            js_content test.engine;
+        }
+
+        location /http_via_proxy {
+            js_fetch_proxy http://testuser:testpass@127.0.0.1:%%PORT_8081%%;
+            js_content test.http_fetch;
+        }
+
+        location /http_no_proxy {
+            js_content test.http_fetch;
+        }
+
+        location /http_via_proxy_no_auth {
+            js_fetch_proxy http://127.0.0.1:%%PORT_8081%%;
+            js_content test.http_fetch;
+        }
+
+        location /http_via_proxy_bad_auth {
+            js_fetch_proxy http://wronguser:wrongpass@127.0.0.1:%%PORT_8081%%;
+            js_content test.http_fetch_status;
+        }
+
+        location /http_via_proxy_encoded {
+            js_fetch_proxy http://user%40domain:p%40ss%3Aword@127.0.0.1:%%PORT_8081%%;
+            js_content test.http_fetch;
+        }
+    }
+
+    server {
+        listen       127.0.0.1:%%PORT_8081%%;
+        server_name  localhost;
+
+        location = /test {
+            js_content test.endpoint;
+        }
+    }
+}
+
+EOF
+
+my $p = port(8081);
+
+$t->write_file('test.js', <<EOF);
+    function engine(r) {
+        r.return(200, njs.engine);
+    }
+
+    function endpoint(r) {
+        let proxy_auth = r.headersIn['Proxy-Authorization'] || '';
+        let valid_creds = [
+            'Basic dGVzdHVzZXI6dGVzdHBhc3M=',
+            'Basic dXNlckBkb21haW46cEBzczp3b3Jk'
+        ];
+
+        if (proxy_auth && !valid_creds.includes(proxy_auth)) {
+            r.return(407, 'Proxy Authentication Required');
+            return;
+        }
+
+        let s = `METHOD: \${r.method}\\n`;
+        s += `URI: \${r.requestLine.split(" ")[1]}\\n`;
+
+        if (proxy_auth) {
+            s += `PROXY-AUTH: \${proxy_auth}\\n`;
+        }
+
+        s += 'BODY: ' + (r.requestText || '') + '\\n';
+
+        r.return(200, s + 'ORIGIN:response');
+    }
+
+    async function http_fetch(r) {
+        try {
+            let domain = decodeURIComponent(r.args.domain);
+            let method = r.method;
+            let body = r.requestText;
+
+            let reply = await ngx.fetch(`http://\${domain}:$p/test`,
+                                        {method, body});
+            body = await reply.text();
+            r.return(200, body);
+        } catch (e) {
+            r.return(500, e.message);
+        }
+    }
+
+    async function http_fetch_status(r) {
+        try {
+            let reply = await ngx.fetch('http://127.0.0.1:$p/test');
+            r.return(200, 'STATUS:' + reply.status);
+        } catch (e) {
+            r.return(500, e.message);
+        }
+    }
+
+    export default {engine, endpoint, http_fetch, http_fetch_status};
+
+EOF
+
+$t->try_run('no js_fetch_proxy')->plan(11);
+
+###############################################################################
+
+my $resp = http_get('/http_via_proxy?domain=127.0.0.1');
+like($resp, qr/METHOD: GET/, 'proxy received GET method');
+like($resp, qr/URI: http:\/\/127\.0\.0\.1:$p\/test/,
+       'proxy received absolute-form URI');
+like($resp, qr/PROXY-AUTH: Basic\s+dGVzdHVzZXI6dGVzdHBhc3M=/,
+    'proxy Proxy-Authorization has expected Basic credentials');
+
+$resp = http_post('/http_via_proxy?domain=127.0.0.1');
+like($resp, qr/METHOD: POST/, 'proxy received POST method');
+like($resp, qr/BODY: REQ-BODY/, 'proxy received request body');
+
+like(http_get('/http_via_proxy?domain=example.com'),
+       qr/URI: http:\/\/example\.com:/, 'proxy received example.com URI');
+
+$resp = http_get('/http_via_proxy_no_auth?domain=127.0.0.1');
+like($resp, qr/URI: http:\/\/127\.0\.0\.1:$p\/test/,
+       'proxy received absolute-form URI');
+unlike($resp, qr/PROXY-AUTH:/, 'proxy received no Proxy-Authorization header');
+
+like(http_get('/http_via_proxy_bad_auth'), qr/STATUS:407/,
+       'Proxy-Authorization is invalid');
+
+like(http_get('/http_no_proxy?domain=127.0.0.1'),
+       qr/ORIGIN:response/, 'origin response without proxy');
+
+like(http_get('/http_via_proxy_encoded?domain=127.0.0.1'),
+       qr/PROXY-AUTH: Basic\s+dXNlckBkb21haW46cEBzczp3b3Jk/,
+       'encoded username and password with special chars decoded correctly');
+
+###############################################################################
+
+sub http_post {
+       my ($url, %extra) = @_;
+
+       my $p = "POST $url HTTP/1.0" . CRLF .
+               "Host: localhost" . CRLF .
+               "Content-Length: 8" . CRLF .
+               CRLF .
+               "REQ-BODY";
+
+       return http($p, %extra);
+}
+
+###############################################################################
diff --git a/nginx/t/js_fetch_proxy_https.t b/nginx/t/js_fetch_proxy_https.t
new file mode 100644 (file)
index 0000000..6589cd3
--- /dev/null
@@ -0,0 +1,481 @@
+#!/usr/bin/perl
+
+# (C) Dmitry Volyntsev
+# (C) F5, Inc.
+
+# Tests for http njs module, fetch method with HTTPS through forward proxy.
+
+###############################################################################
+
+use warnings;
+use strict;
+
+use Test::More;
+
+use Socket qw/ CRLF SOCK_STREAM /;
+use IO::Select;
+
+BEGIN { use FindBin; chdir($FindBin::Bin); }
+
+use lib 'lib';
+use Test::Nginx;
+
+###############################################################################
+
+select STDERR; $| = 1;
+select STDOUT; $| = 1;
+
+my $t = Test::Nginx->new()->has(qw/http http_ssl/)
+       ->write_file_expand('nginx.conf', <<'EOF');
+
+%%TEST_GLOBALS%%
+
+daemon off;
+
+events {
+}
+
+http {
+    %%TEST_GLOBALS_HTTP%%
+
+    js_import test.js;
+
+    resolver   127.0.0.1:%%PORT_8981_UDP%%;
+    resolver_timeout 1s;
+
+    server {
+        listen       127.0.0.1:8080;
+        server_name  localhost;
+
+        js_fetch_trusted_certificate myca.crt;
+
+        location /engine {
+            js_content test.engine;
+        }
+
+        location /https_via_proxy {
+            js_fetch_proxy http://user:pass@forward.proxy.net:%%PORT_8082%%;
+            js_content test.https_fetch;
+        }
+
+        location /https_via_broken_proxy {
+            js_fetch_proxy http://user:pass@nonexistent.domain:%%PORT_8082%%;
+            js_content test.https_fetch;
+        }
+
+        location /https_no_proxy {
+            js_content test.https_fetch;
+        }
+    }
+
+    server {
+        listen       127.0.0.1:%%PORT_8083%% ssl;
+        server_name  example.com;
+
+        ssl_certificate example.com.chained.crt;
+        ssl_certificate_key example.com.key;
+
+        location = /test {
+            js_content test.endpoint;
+        }
+    }
+}
+
+EOF
+
+my $p2 = port(8082);
+my $p3 = port(8083);
+
+$t->write_file('test.js', <<EOF);
+    function engine(r) {
+        r.return(200, njs.engine);
+    }
+
+    function endpoint(r) {
+        let s = `METHOD: \${r.method}\\n`;
+        s += `URI: \${r.requestLine.split(" ")[1]}\\n`;
+        s += 'BODY: ' + (r.requestText || '') + '\\n';
+
+        r.return(200, s + 'ORIGIN');
+    }
+
+    async function https_fetch(r) {
+        try {
+            let domain = decodeURIComponent(r.args.domain);
+            let method = r.method;
+            let body = r.requestText;
+            let reply = await ngx.fetch(`https://\${domain}:$p3/test`,
+                                        {method, body});
+            body = await reply.text();
+            r.return(200, body);
+
+        } catch (e) {
+            r.return(500, e.message);
+        }
+    }
+
+    export default {engine, endpoint, https_fetch};
+
+EOF
+
+my $d = $t->testdir();
+
+$t->write_file('openssl.conf', <<EOF);
+[ req ]
+default_bits = 2048
+encrypt_key = no
+distinguished_name = req_distinguished_name
+x509_extensions = myca_extensions
+[ req_distinguished_name ]
+[ myca_extensions ]
+basicConstraints = critical,CA:TRUE
+EOF
+
+$t->write_file('myca.conf', <<EOF);
+[ ca ]
+default_ca = myca
+
+[ myca ]
+new_certs_dir = $d
+database = $d/certindex
+default_md = sha256
+policy = myca_policy
+serial = $d/certserial
+default_days = 1
+x509_extensions = myca_extensions
+
+[ myca_policy ]
+commonName = supplied
+
+[ myca_extensions ]
+basicConstraints = critical,CA:TRUE
+EOF
+
+system('openssl req -x509 -new '
+       . "-config $d/openssl.conf -subj /CN=myca/ "
+       . "-out $d/myca.crt -keyout $d/myca.key "
+       . ">>$d/openssl.out 2>&1") == 0
+       or die "Can't create self-signed certificate for CA: $!\n";
+
+foreach my $name ('intermediate', 'example.com') {
+       system("openssl req -new "
+               . "-config $d/openssl.conf -subj /CN=$name/ "
+               . "-out $d/$name.csr -keyout $d/$name.key "
+               . ">>$d/openssl.out 2>&1") == 0
+               or die "Can't create certificate signing req for $name: $!\n";
+}
+
+$t->write_file('certserial', '1000');
+$t->write_file('certindex', '');
+
+system("openssl ca -batch -config $d/myca.conf "
+       . "-keyfile $d/myca.key -cert $d/myca.crt "
+       . "-subj /CN=intermediate/ -in $d/intermediate.csr "
+       . "-out $d/intermediate.crt "
+       . ">>$d/openssl.out 2>&1") == 0
+       or die "Can't sign certificate for intermediate: $!\n";
+
+foreach my $name ('example.com') {
+       system("openssl ca -batch -config $d/myca.conf "
+               . "-keyfile $d/intermediate.key -cert $d/intermediate.crt "
+               . "-subj /CN=$name/ -in $d/$name.csr -out $d/$name.crt "
+               . ">>$d/openssl.out 2>&1") == 0
+               or die "Can't sign certificate for $name $!\n";
+       $t->write_file("$name.chained.crt", $t->read_file("$name.crt")
+               . $t->read_file('intermediate.crt'));
+}
+
+$t->try_run('no js_fetch_proxy')->plan(8);
+
+$t->run_daemon(\&https_proxy_daemon, $p2);
+$t->run_daemon(\&dns_daemon, port(8981), $t);
+$t->waitforsocket('127.0.0.1:' . $p2);
+$t->waitforfile($t->testdir . '/' . port(8981));
+
+###############################################################################
+
+my $resp = http_get('/https_via_proxy?domain=example.com');
+like($resp, qr/METHOD: GET/, 'https through proxy received GET method');
+like($resp, qr/URI: \/test/, 'https through proxy received /test URI');
+like($resp, qr/ORIGIN$/, 'https through proxy origin response');
+
+$resp = http_post('/https_via_proxy?domain=example.com');
+like($resp, qr/METHOD: POST/, 'https through proxy received POST method');
+like($resp, qr/BODY: REQ-BODY/, 'https through proxy received request body');
+
+like(http_get('/https_no_proxy?domain=example.com'), qr/ORIGIN/,
+       'https without proxy');
+
+like(http_get('/https_via_proxy?domain=nonexistent.dest.domain'),
+       qr/connect failed/, 'https through proxy nonexistent.dest.domain');
+like(http_get('/https_via_broken_proxy?domain=example.com'),
+       qr/\"nonexistent.domain\" could not be res/, 'https through broken proxy');
+
+###############################################################################
+
+sub https_proxy_daemon {
+       my ($port) = @_;
+
+       my $server = IO::Socket::INET->new(
+               Proto     => 'tcp',
+               LocalAddr => "127.0.0.1:$port",
+               Listen    => 128,
+               Reuse     => 1
+       ) or die "Can't create listening socket: $!\n";
+
+       local $SIG{PIPE} = 'IGNORE';
+
+       my $s = {
+               sel       => IO::Select->new($server),
+               pending   => {},   # fd -> { sock, buf }
+               conn      => {},   # client fd -> { client, origin }
+               o2c       => {},   # origin fd -> client fd
+       };
+
+       while (1) {
+               my @ready = $s->{sel}->can_read(1.0);
+               for my $sock (@ready) {
+                       if ($sock == $server) {
+                               my $client = $server->accept();
+                               next unless $client;
+                               $client->autoflush(1);
+                               $s->{sel}->add($client);
+                               $s->{pending}->{ fileno($client) } = {
+                                       sock       => $client,
+                                       buf        => '',
+                               };
+
+                               next;
+                       }
+
+                       my $fd = fileno($sock);
+                       next unless defined $fd;
+
+                       if (exists $s->{o2c}->{$fd}) {
+                               my $buf;
+                               my $cfd = $s->{o2c}->{$fd};
+                               my $client = $s->{conn}->{$cfd}{client};
+
+                               my $n = sysread($sock, $buf, 4096);
+                               if (!defined($n) || $n == 0) {
+                                       _cleanup($s, $client, $sock);
+                                       next;
+                               }
+
+                               syswrite($client, $buf);
+                               next;
+                       }
+
+                       if (exists $s->{conn}->{$fd}) {
+                               my $buf;
+                               my $origin = $s->{conn}->{$fd}{origin};
+
+                               my $n = sysread($sock, $buf, 4096);
+                               if (!defined($n) || $n == 0) {
+                                       _cleanup($s, $sock, $origin);
+                                       next;
+                               }
+
+                               syswrite($origin, $buf);
+                               next;
+                       }
+
+                       if (exists $s->{pending}->{$fd}) {
+                               my $buf;
+                               my $p = $s->{pending}->{$fd};
+
+                               my $n = sysread($sock, $buf, 4096);
+                               if (!defined($n) || $n == 0) {
+                                       $s->{sel}->remove($sock);
+                                       delete $s->{pending}->{$fd};
+                                       close $sock;
+                                       next;
+                               }
+
+                               $p->{buf} .= $buf;
+
+                               if ($p->{buf} =~ /(\x0d\x0a)\1/s) {
+                                       my $method = '', my $proxy_auth = '', my $target = '';
+
+                                       my ($headers) = split(/\r\n\r\n/, $p->{buf}, 2);
+                                       for my $line (split(/\r\n/, $headers)) {
+                                               if ($method eq '' && $line =~ /^(\S+)\s+(\S+)\s+HTTP/i) {
+                                                       $method = $1;
+                                                       $target = $2;
+                                                       next;
+                                               }
+
+                                               if ($line =~ /^Proxy-Authorization:\s*(.+?)\s*$/i) {
+                                                       $proxy_auth = $1;
+                                                       next;
+                                               }
+                                       }
+
+                                       if (uc($method) eq 'CONNECT'
+                                               && $proxy_auth =~ /^Basic\s+dXNlcjpwYXNz$/i)
+                                       {
+                                               print $sock "HTTP/1.1 200 established" . CRLF . CRLF;
+
+                                               my ($host, $port) = split(/:/, $target);
+
+                                               my $origin = IO::Socket::INET->new(
+                                                       PeerAddr => "127.0.0.1:$port",
+                                                       Proto    => 'tcp',
+                                                       Type     => SOCK_STREAM
+                                               );
+
+                                               if (!$origin) {
+                                                       $s->{sel}->remove($sock);
+                                                       delete $s->{pending}->{$fd};
+                                                       close $sock;
+                                                       next;
+                                               }
+
+                                               $origin->autoflush(1);
+                                               $s->{sel}->add($origin);
+
+                                               $s->{conn}->{$fd} = {
+                                                       client => $sock,
+                                                       origin => $origin,
+                                               };
+
+                                               $s->{o2c}->{ fileno($origin) } = $fd;
+                                               delete $s->{pending}->{$fd};
+
+                                       } else {
+                                               print $sock
+                                                       "HTTP/1.1 407 Proxy Auth Required" . CRLF .
+                                                       "Proxy-Authenticate: Basic realm=\"proxy\"" . CRLF .
+                                                       "Content-Length: 0" . CRLF .
+                                                       "Connection: close" . CRLF . CRLF;
+                                               $s->{sel}->remove($sock);
+                                               delete $s->{pending}->{$fd};
+                                               close $sock;
+                                       }
+                               }
+
+                               next;
+                       }
+
+                       $s->{sel}->remove($sock);
+                       close $sock;
+               }
+       }
+}
+
+sub _cleanup {
+       my ($s, $client, $origin) = @_;
+
+       my $cfd = fileno($client);
+       my $ofd = fileno($origin);
+
+       delete $s->{o2c}->{$ofd} if defined $ofd;
+
+       if (defined $cfd) {
+               delete $s->{conn}->{$cfd};
+               delete $s->{pending}->{$cfd} if exists $s->{pending}->{$cfd};
+       }
+
+       if ($client) {
+               $s->{sel}->remove($client);
+               close $client;
+       }
+
+       if ($origin) {
+               $s->{sel}->remove($origin);
+               close $origin;
+       }
+}
+
+###############################################################################
+
+sub reply_handler {
+       my ($recv_data, $port, %extra) = @_;
+
+       my (@name, @rdata);
+
+       use constant NOERROR    => 0;
+       use constant A          => 1;
+       use constant IN         => 1;
+
+       # default values
+
+       my ($hdr, $rcode, $ttl) = (0x8180, NOERROR, 3600);
+
+       # decode name
+
+       my ($len, $offset) = (undef, 12);
+       while (1) {
+               $len = unpack("\@$offset C", $recv_data);
+               last if $len == 0;
+               $offset++;
+               push @name, unpack("\@$offset A$len", $recv_data);
+               $offset += $len;
+       }
+
+       $offset -= 1;
+       my ($id, $type, $class) = unpack("n x$offset n2", $recv_data);
+
+       my $name = join('.', @name);
+
+       if ($name eq 'example.com' || $name eq 'forward.proxy.net') {
+               if ($type == A) {
+                       push @rdata, rd_addr($ttl, '127.0.0.1');
+               }
+       }
+
+       $len = @name;
+       pack("n6 (C/a*)$len x n2", $id, $hdr | $rcode, 1, scalar @rdata,
+               0, 0, @name, $type, $class) . join('', @rdata);
+}
+
+sub rd_addr {
+       my ($ttl, $addr) = @_;
+
+       my $code = 'split(/\./, $addr)';
+
+       return pack 'n3N', 0xc00c, A, IN, $ttl if $addr eq '';
+
+       pack 'n3N nC4', 0xc00c, A, IN, $ttl, eval "scalar $code", eval($code);
+}
+
+sub dns_daemon {
+       my ($port, $t) = @_;
+
+       my ($data, $recv_data);
+       my $socket = IO::Socket::INET->new(
+               LocalAddr    => '127.0.0.1',
+               LocalPort    => $port,
+               Proto        => 'udp',
+       )
+               or die "Can't create listening socket: $!\n";
+
+       local $SIG{PIPE} = 'IGNORE';
+
+       # signal we are ready
+
+       open my $fh, '>', $t->testdir() . '/' . $port;
+       close $fh;
+
+       while (1) {
+               $socket->recv($recv_data, 65536);
+               $data = reply_handler($recv_data, $port);
+               $socket->send($data);
+       }
+}
+
+###############################################################################
+
+sub http_post {
+       my ($url, %extra) = @_;
+
+       my $p = "POST $url HTTP/1.0" . CRLF .
+               "Host: localhost" . CRLF .
+               "Content-Length: 8" . CRLF .
+               CRLF .
+               "REQ-BODY";
+
+       return http($p, %extra);
+}
+
+###############################################################################
diff --git a/nginx/t/js_fetch_proxy_keepalive.t b/nginx/t/js_fetch_proxy_keepalive.t
new file mode 100644 (file)
index 0000000..422cf1b
--- /dev/null
@@ -0,0 +1,459 @@
+#!/usr/bin/perl
+
+# (C) Dmitry Volyntsev
+# (C) F5, Inc.
+
+# Tests for http njs module, fetch method with keepalive and forward proxy.
+
+###############################################################################
+
+use warnings;
+use strict;
+
+use Test::More;
+
+use Socket qw/ CRLF SOCK_STREAM /;
+use IO::Select;
+
+BEGIN { use FindBin; chdir($FindBin::Bin); }
+
+use lib 'lib';
+use Test::Nginx;
+
+###############################################################################
+
+select STDERR; $| = 1;
+select STDOUT; $| = 1;
+
+my $t = Test::Nginx->new()->has(qw/http http_ssl/)
+       ->write_file_expand('nginx.conf', <<'EOF');
+
+%%TEST_GLOBALS%%
+
+daemon off;
+
+events {
+}
+
+http {
+    %%TEST_GLOBALS_HTTP%%
+
+    js_import test.js;
+
+    resolver   127.0.0.1:%%PORT_8981_UDP%%;
+    resolver_timeout 1s;
+
+    server {
+        listen       127.0.0.1:8080;
+        server_name  localhost;
+
+        js_fetch_trusted_certificate myca.crt;
+
+        location /engine {
+            js_content test.engine;
+        }
+
+        location /https_via_proxy_keepalive {
+            js_fetch_keepalive 4;
+            js_fetch_proxy http://user:pass@127.0.0.1:%%PORT_8082%%;
+            js_content test.https_fetch;
+        }
+    }
+
+    server {
+        listen       127.0.0.1:%%PORT_8083%% ssl;
+        server_name  example.com;
+
+        keepalive_requests 100;
+
+        ssl_certificate example.com.chained.crt;
+        ssl_certificate_key example.com.key;
+
+        location = /test {
+            return 200 "COM:$connection_requests";
+        }
+    }
+
+    server {
+        listen       127.0.0.1:%%PORT_8084%% ssl;
+        server_name  example.org;
+
+        keepalive_requests 100;
+
+        ssl_certificate example.org.chained.crt;
+        ssl_certificate_key example.org.key;
+
+        location = /test {
+            return 200 "ORG:$connection_requests";
+        }
+    }
+}
+
+EOF
+
+my $p2 = port(8082);
+my $p3 = port(8083);
+my $p4 = port(8084);
+
+$t->write_file('test.js', <<EOF);
+    function engine(r) {
+        r.return(200, njs.engine);
+    }
+
+    async function https_fetch(r) {
+        try {
+            let domain = decodeURIComponent(r.args.domain);
+            let p = r.args.port || $p3;
+            let scheme = r.args.scheme || 'https';
+            let reply = await ngx.fetch(`\${scheme}://\${domain}:\${p}/test`);
+            let body = await reply.text();
+            r.return(200, body);
+
+        } catch (e) {
+            r.return(500, e.message);
+        }
+    }
+
+    export default {engine, https_fetch};
+
+EOF
+
+my $d = $t->testdir();
+
+$t->write_file('openssl.conf', <<EOF);
+[ req ]
+default_bits = 2048
+encrypt_key = no
+distinguished_name = req_distinguished_name
+x509_extensions = myca_extensions
+[ req_distinguished_name ]
+[ myca_extensions ]
+basicConstraints = critical,CA:TRUE
+EOF
+
+$t->write_file('myca.conf', <<EOF);
+[ ca ]
+default_ca = myca
+
+[ myca ]
+new_certs_dir = $d
+database = $d/certindex
+default_md = sha256
+policy = myca_policy
+serial = $d/certserial
+default_days = 1
+x509_extensions = myca_extensions
+
+[ myca_policy ]
+commonName = supplied
+
+[ myca_extensions ]
+basicConstraints = critical,CA:TRUE
+EOF
+
+system('openssl req -x509 -new '
+       . "-config $d/openssl.conf -subj /CN=myca/ "
+       . "-out $d/myca.crt -keyout $d/myca.key "
+       . ">>$d/openssl.out 2>&1") == 0
+       or die "Can't create self-signed certificate for CA: $!\n";
+
+foreach my $name ('intermediate', 'example.com', 'example.org') {
+       system("openssl req -new "
+               . "-config $d/openssl.conf -subj /CN=$name/ "
+               . "-out $d/$name.csr -keyout $d/$name.key "
+               . ">>$d/openssl.out 2>&1") == 0
+               or die "Can't create certificate signing req for $name: $!\n";
+}
+
+$t->write_file('certserial', '1000');
+$t->write_file('certindex', '');
+
+system("openssl ca -batch -config $d/myca.conf "
+       . "-keyfile $d/myca.key -cert $d/myca.crt "
+       . "-subj /CN=intermediate/ -in $d/intermediate.csr "
+       . "-out $d/intermediate.crt "
+       . ">>$d/openssl.out 2>&1") == 0
+       or die "Can't sign certificate for intermediate: $!\n";
+
+foreach my $name ('example.com', 'example.org') {
+       system("openssl ca -batch -config $d/myca.conf "
+               . "-keyfile $d/intermediate.key -cert $d/intermediate.crt "
+               . "-subj /CN=$name/ -in $d/$name.csr -out $d/$name.crt "
+               . ">>$d/openssl.out 2>&1") == 0
+               or die "Can't sign certificate for $name $!\n";
+       $t->write_file("$name.chained.crt", $t->read_file("$name.crt")
+               . $t->read_file('intermediate.crt'));
+}
+
+$t->try_run('no js_fetch_proxy')->plan(4);
+
+$t->run_daemon(\&https_proxy_daemon, $p2);
+$t->run_daemon(\&dns_daemon, port(8981), $t);
+$t->waitforsocket('127.0.0.1:' . $p2);
+$t->waitforfile($t->testdir . '/' . port(8981));
+
+###############################################################################
+
+like(http_get("/https_via_proxy_keepalive?domain=example.com"),
+       qr/COM:1$/, 'https keepalive through proxy 1');
+like(http_get("/https_via_proxy_keepalive?domain=example.org&port=$p4"),
+       qr/ORG:1$/, 'https keepalive through proxy different hostnames');
+like(http_get('/https_via_proxy_keepalive?domain=example.com'),
+       qr/COM:2$/, 'https keepalive through proxy 2');
+like(http_get("/https_via_proxy_keepalive?domain=example.org&port=$p4"),
+       qr/ORG:2$/, 'https keepalive through proxy different hostnames 2');
+
+###############################################################################
+
+sub https_proxy_daemon {
+       my ($port) = @_;
+
+       my $server = IO::Socket::INET->new(
+               Proto     => 'tcp',
+               LocalAddr => "127.0.0.1:$port",
+               Listen    => 128,
+               Reuse     => 1
+       ) or die "Can't create listening socket: $!\n";
+
+       local $SIG{PIPE} = 'IGNORE';
+
+       my $s = {
+               sel       => IO::Select->new($server),
+               pending   => {},   # fd -> { sock, buf }
+               conn      => {},   # client fd -> { client, origin }
+               o2c       => {},   # origin fd -> client fd
+       };
+
+       while (1) {
+               my @ready = $s->{sel}->can_read(1.0);
+               for my $sock (@ready) {
+                       if ($sock == $server) {
+                               my $client = $server->accept();
+                               next unless $client;
+                               $client->autoflush(1);
+                               $s->{sel}->add($client);
+                               $s->{pending}->{ fileno($client) } = {
+                                       sock       => $client,
+                                       buf        => '',
+                               };
+
+                               next;
+                       }
+
+                       my $fd = fileno($sock);
+                       next unless defined $fd;
+
+                       if (exists $s->{o2c}->{$fd}) {
+                               my $buf;
+                               my $cfd = $s->{o2c}->{$fd};
+                               my $client = $s->{conn}->{$cfd}{client};
+
+                               my $n = sysread($sock, $buf, 4096);
+                               if (!defined($n) || $n == 0) {
+                                       _cleanup($s, $client, $sock);
+                                       next;
+                               }
+
+                               syswrite($client, $buf);
+                               next;
+                       }
+
+                       if (exists $s->{conn}->{$fd}) {
+                               my $buf;
+                               my $origin = $s->{conn}->{$fd}{origin};
+
+                               my $n = sysread($sock, $buf, 4096);
+                               if (!defined($n) || $n == 0) {
+                                       _cleanup($s, $sock, $origin);
+                                       next;
+                               }
+
+                               syswrite($origin, $buf);
+                               next;
+                       }
+
+                       if (exists $s->{pending}->{$fd}) {
+                               my $buf;
+                               my $p = $s->{pending}->{$fd};
+
+                               my $n = sysread($sock, $buf, 4096);
+                               if (!defined($n) || $n == 0) {
+                                       $s->{sel}->remove($sock);
+                                       delete $s->{pending}->{$fd};
+                                       close $sock;
+                                       next;
+                               }
+
+                               $p->{buf} .= $buf;
+
+                               if ($p->{buf} =~ /(\x0d\x0a)\1/s) {
+                                       my $method = '', my $proxy_auth = '', my $target = '';
+
+                                       my ($headers) = split(/\r\n\r\n/, $p->{buf}, 2);
+                                       for my $line (split(/\r\n/, $headers)) {
+                                               if ($method eq '' && $line =~ /^(\S+)\s+(\S+)\s+HTTP/i) {
+                                                       $method = $1;
+                                                       $target = $2;
+                                                       next;
+                                               }
+
+                                               if ($line =~ /^Proxy-Authorization:\s*(.+?)\s*$/i) {
+                                                       $proxy_auth = $1;
+                                                       next;
+                                               }
+                                       }
+
+                                       if (uc($method) eq 'CONNECT'
+                                               && $proxy_auth =~ /^Basic\s+dXNlcjpwYXNz$/i)
+                                       {
+                                               print $sock "HTTP/1.1 200 established" . CRLF . CRLF;
+
+                                               my ($host, $port) = split(/:/, $target);
+
+                                               my $origin = IO::Socket::INET->new(
+                                                       PeerAddr => "127.0.0.1:$port",
+                                                       Proto    => 'tcp',
+                                                       Type     => SOCK_STREAM
+                                               );
+
+                                               if (!$origin) {
+                                                       $s->{sel}->remove($sock);
+                                                       delete $s->{pending}->{$fd};
+                                                       close $sock;
+                                                       next;
+                                               }
+
+                                               $origin->autoflush(1);
+                                               $s->{sel}->add($origin);
+
+                                               $s->{conn}->{$fd} = {
+                                                       client => $sock,
+                                                       origin => $origin,
+                                               };
+
+                                               $s->{o2c}->{ fileno($origin) } = $fd;
+                                               delete $s->{pending}->{$fd};
+
+                                       } else {
+                                               print $sock
+                                                       "HTTP/1.1 407 Proxy Auth Required" . CRLF .
+                                                       "Proxy-Authenticate: Basic realm=\"proxy\"" . CRLF .
+                                                       "Content-Length: 0" . CRLF .
+                                                       "Connection: close" . CRLF . CRLF;
+                                               $s->{sel}->remove($sock);
+                                               delete $s->{pending}->{$fd};
+                                               close $sock;
+                                       }
+                               }
+
+                               next;
+                       }
+
+                       $s->{sel}->remove($sock);
+                       close $sock;
+               }
+       }
+}
+
+sub _cleanup {
+       my ($s, $client, $origin) = @_;
+
+       my $cfd = fileno($client);
+       my $ofd = fileno($origin);
+
+       delete $s->{o2c}->{$ofd} if defined $ofd;
+
+       if (defined $cfd) {
+               delete $s->{conn}->{$cfd};
+               delete $s->{pending}->{$cfd} if exists $s->{pending}->{$cfd};
+       }
+
+       if ($client) {
+               $s->{sel}->remove($client);
+               close $client;
+       }
+
+       if ($origin) {
+               $s->{sel}->remove($origin);
+               close $origin;
+       }
+}
+
+###############################################################################
+
+sub reply_handler {
+       my ($recv_data, $port, %extra) = @_;
+
+       my (@name, @rdata);
+
+       use constant NOERROR    => 0;
+       use constant A          => 1;
+       use constant IN         => 1;
+
+       # default values
+
+       my ($hdr, $rcode, $ttl) = (0x8180, NOERROR, 3600);
+
+       # decode name
+
+       my ($len, $offset) = (undef, 12);
+       while (1) {
+               $len = unpack("\@$offset C", $recv_data);
+               last if $len == 0;
+               $offset++;
+               push @name, unpack("\@$offset A$len", $recv_data);
+               $offset += $len;
+       }
+
+       $offset -= 1;
+       my ($id, $type, $class) = unpack("n x$offset n2", $recv_data);
+
+       my $name = join('.', @name);
+
+       if ($name eq 'example.com' || $name eq 'example.org') {
+               if ($type == A) {
+                       push @rdata, rd_addr($ttl, '127.0.0.1');
+               }
+       }
+
+       $len = @name;
+       pack("n6 (C/a*)$len x n2", $id, $hdr | $rcode, 1, scalar @rdata,
+               0, 0, @name, $type, $class) . join('', @rdata);
+}
+
+sub rd_addr {
+       my ($ttl, $addr) = @_;
+
+       my $code = 'split(/\./, $addr)';
+
+       return pack 'n3N', 0xc00c, A, IN, $ttl if $addr eq '';
+
+       pack 'n3N nC4', 0xc00c, A, IN, $ttl, eval "scalar $code", eval($code);
+}
+
+sub dns_daemon {
+       my ($port, $t) = @_;
+
+       my ($data, $recv_data);
+       my $socket = IO::Socket::INET->new(
+               LocalAddr    => '127.0.0.1',
+               LocalPort    => $port,
+               Proto        => 'udp',
+       )
+               or die "Can't create listening socket: $!\n";
+
+       local $SIG{PIPE} = 'IGNORE';
+
+       # signal we are ready
+
+       open my $fh, '>', $t->testdir() . '/' . $port;
+       close $fh;
+
+       while (1) {
+               $socket->recv($recv_data, 65536);
+               $data = reply_handler($recv_data, $port);
+               $socket->send($data);
+       }
+}
+
+###############################################################################
diff --git a/nginx/t/js_fetch_proxy_variable.t b/nginx/t/js_fetch_proxy_variable.t
new file mode 100644 (file)
index 0000000..7399d28
--- /dev/null
@@ -0,0 +1,142 @@
+#!/usr/bin/perl
+
+# (C) Dmitry Volyntsev
+# (C) F5, Inc.
+
+# Tests for http njs module, fetch method with variable proxy URLs.
+
+###############################################################################
+
+use warnings;
+use strict;
+
+use Test::More;
+
+use Socket qw/ CRLF /;
+
+BEGIN { use FindBin; chdir($FindBin::Bin); }
+
+use lib 'lib';
+use Test::Nginx;
+
+###############################################################################
+
+select STDERR; $| = 1;
+select STDOUT; $| = 1;
+
+my $t = Test::Nginx->new()->has(qw/http/)
+       ->write_file_expand('nginx.conf', <<'EOF');
+
+%%TEST_GLOBALS%%
+
+daemon off;
+
+events {
+}
+
+http {
+    %%TEST_GLOBALS_HTTP%%
+
+    js_import test.js;
+
+    server {
+        listen       127.0.0.1:8080;
+        server_name  localhost;
+
+        location /static_proxy {
+            js_fetch_proxy http://testuser:testpass@127.0.0.1:%%PORT_8081%%;
+            js_content test.http_fetch;
+        }
+
+        location /dynamic_proxy {
+            set $proxy_url http://testuser:testpass@127.0.0.1:%%PORT_8081%%;
+            js_fetch_proxy $proxy_url;
+            js_content test.http_fetch;
+        }
+
+        location /dynamic_empty_proxy {
+            set $proxy_url "";
+            js_fetch_proxy $proxy_url;
+            js_content test.http_fetch;
+        }
+    }
+
+    server {
+        listen       127.0.0.1:%%PORT_8081%%;
+        server_name  localhost;
+
+        location = /test {
+            js_content test.proxy_endpoint;
+        }
+    }
+
+    server {
+        listen       127.0.0.1:%%PORT_8082%%;
+        server_name  localhost;
+
+        location = /test {
+            js_content test.origin_endpoint;
+        }
+    }
+}
+
+EOF
+
+my $p1 = port(8081);
+my $p2 = port(8082);
+
+$t->write_file('test.js', <<EOF);
+    function proxy_endpoint(r) {
+        let proxy_auth = r.headersIn['Proxy-Authorization'] || '';
+        let expected = 'Basic dGVzdHVzZXI6dGVzdHBhc3M=';
+
+        if (!proxy_auth) {
+            r.return(500, 'PROXY:NO-AUTH');
+            return;
+        }
+
+        if (proxy_auth !== expected) {
+            r.return(407, 'PROXY:BAD-AUTH');
+            return;
+        }
+
+        r.return(200, 'PROXY:' + proxy_auth);
+    }
+
+    function origin_endpoint(r) {
+        let proxy_auth = r.headersIn['Proxy-Authorization'] || '';
+
+        if (proxy_auth) {
+            r.return(500, 'ORIGIN:HAS-PROXY-AUTH');
+            return;
+        }
+
+        r.return(200, 'ORIGIN:OK');
+    }
+
+    async function http_fetch(r) {
+        try {
+            let reply = await ngx.fetch('http://127.0.0.1:$p2/test');
+            let body = await reply.text();
+            r.return(200, body);
+        } catch (e) {
+            r.return(500, e.message);
+        }
+    }
+
+    export default {proxy_endpoint, origin_endpoint, http_fetch};
+
+EOF
+
+$t->try_run('no js_fetch_proxy')->plan(3);
+
+###############################################################################
+
+like(http_get('/static_proxy'), qr/PROXY:Basic\s+dGVzdHVzZXI6dGVzdHBhc3M=/,
+    'static proxy URL with auth');
+like(http_get('/dynamic_proxy'), qr/PROXY:Basic\s+dGVzdHVzZXI6dGVzdHBhc3M=/,
+    'dynamic proxy URL with auth');
+like(http_get('/dynamic_empty_proxy'), qr/ORIGIN:OK/,
+    'dynamic empty proxy URL bypasses proxy');
+
+###############################################################################
diff --git a/nginx/t/stream_js_fetch_proxy.t b/nginx/t/stream_js_fetch_proxy.t
new file mode 100644 (file)
index 0000000..d4ca9fb
--- /dev/null
@@ -0,0 +1,198 @@
+#!/usr/bin/perl
+
+# (C) Dmitry Volyntsev
+# (C) F5, Inc.
+
+# Tests for stream njs module, fetch method with forward proxy.
+
+###############################################################################
+
+use warnings;
+use strict;
+
+use Test::More;
+
+BEGIN { use FindBin; chdir($FindBin::Bin); }
+
+use lib 'lib';
+use Test::Nginx;
+use Test::Nginx::Stream qw/ stream /;
+
+###############################################################################
+
+select STDERR; $| = 1;
+select STDOUT; $| = 1;
+
+my $t = Test::Nginx->new()->has(qw/http stream stream_map/)
+       ->write_file_expand('nginx.conf', <<'EOF');
+
+%%TEST_GLOBALS%%
+
+daemon off;
+
+events {
+}
+
+http {
+    %%TEST_GLOBALS_HTTP%%
+
+    js_import test.js;
+
+    server {
+        listen       127.0.0.1:%%PORT_8080%%;
+        server_name  localhost;
+
+        location = /test {
+            js_content test.origin_endpoint;
+        }
+    }
+
+    server {
+        listen       127.0.0.1:%%PORT_8081%%;
+        server_name  localhost;
+
+        location = /test {
+            js_content test.proxy_endpoint;
+        }
+    }
+}
+
+stream {
+    %%TEST_GLOBALS_STREAM%%
+
+    js_import test.js;
+
+    server {
+        listen      127.0.0.1:%%PORT_8091%%;
+        js_fetch_proxy http://testuser:testpass@127.0.0.1:%%PORT_8081%%;
+        js_filter   test.http_fetch;
+        proxy_pass  127.0.0.1:%%PORT_8094%%;
+    }
+
+    server {
+        listen      127.0.0.1:%%PORT_8092%%;
+
+        set $proxy_url http://testuser:testpass@127.0.0.1:%%PORT_8081%%;
+        js_fetch_proxy $proxy_url;
+        js_filter   test.http_fetch;
+        proxy_pass  127.0.0.1:%%PORT_8094%%;
+    }
+
+    server {
+        listen      127.0.0.1:%%PORT_8093%%;
+
+        set $proxy_url "";
+        js_fetch_proxy $proxy_url;
+        js_filter   test.http_fetch;
+        proxy_pass  127.0.0.1:%%PORT_8094%%;
+    }
+}
+
+EOF
+
+my $p0 = port(8080);
+my $p1 = port(8081);
+
+$t->write_file('test.js', <<EOF);
+    function origin_endpoint(r) {
+        let proxy_auth = r.headersIn['Proxy-Authorization'] || '';
+
+        if (proxy_auth) {
+            r.return(500, 'ORIGIN:HAS-PROXY-AUTH');
+            return;
+        }
+
+        r.return(200, 'ORIGIN:OK');
+    }
+
+    function proxy_endpoint(r) {
+        let proxy_auth = r.headersIn['Proxy-Authorization'] || '';
+        let expected = 'Basic dGVzdHVzZXI6dGVzdHBhc3M=';
+
+        if (!proxy_auth) {
+            r.return(500, 'PROXY:NO-AUTH');
+            return;
+        }
+
+        if (proxy_auth !== expected) {
+            r.return(407, 'PROXY:BAD-AUTH');
+            return;
+        }
+
+        r.return(200, 'PROXY:' + proxy_auth);
+    }
+
+    function http_fetch(s) {
+        var collect = '';
+
+        s.on('upload', async function (data, flags) {
+            collect += data;
+
+            if (collect.length > 0) {
+                s.off('upload');
+
+                let reply = await ngx.fetch('http://127.0.0.1:$p0/test');
+                let body = await reply.text();
+
+                s.send(body, flags);
+            }
+        });
+    }
+
+    export default {origin_endpoint, proxy_endpoint, http_fetch};
+
+EOF
+
+$t->try_run('no js_fetch_proxy available')->plan(3);
+
+$t->run_daemon(\&stream_daemon, port(8094));
+$t->waitforsocket('127.0.0.1:' . port(8094));
+
+###############################################################################
+
+is(stream('127.0.0.1:' . port(8091))->io('TEST'),
+       'PROXY:Basic dGVzdHVzZXI6dGVzdHBhc3M=', 'static proxy');
+is(stream('127.0.0.1:' . port(8092))->io('TEST'),
+       'PROXY:Basic dGVzdHVzZXI6dGVzdHBhc3M=', 'dynamic proxy');
+is(stream('127.0.0.1:' . port(8093))->io('TEST'), 'ORIGIN:OK', 'no proxy');
+
+###############################################################################
+
+$t->stop();
+
+###############################################################################
+
+sub stream_daemon {
+       my ($port) = @_;
+
+       my $server = IO::Socket::INET->new(
+               Proto => 'tcp',
+               LocalAddr => "127.0.0.1:$port",
+               Listen => 5,
+               Reuse => 1
+       ) or die "Can't create listening socket: $!\n";
+
+       local $SIG{PIPE} = 'IGNORE';
+
+       while (my $client = $server->accept()) {
+               $client->autoflush(1);
+
+               log2c("(new connection $client)");
+
+               $client->sysread(my $buffer, 65536);
+
+               log2i("$client $buffer");
+
+               $client->syswrite($buffer);
+
+               log2o("$client $buffer");
+
+               $client->close();
+       }
+}
+
+sub log2i { Test::Nginx::log_core('|| <<', @_); }
+sub log2o { Test::Nginx::log_core('|| >>', @_); }
+sub log2c { Test::Nginx::log_core('||', @_); }
+
+###############################################################################