Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

http2: add altsvc support #17917

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 10 additions & 0 deletions doc/api/errors.md
Expand Up @@ -821,6 +821,16 @@ that.

Occurs with multiple attempts to shutdown an HTTP/2 session.

<a id="ERR_HTTP2_ALTSVC_INVALID_ORIGIN"></a>
### ERR_HTTP2_ALTSVC_INVALID_ORIGIN

HTTP/2 ALTSVC frames require a valid origin.

<a id="ERR_HTTP2_ALTSVC_LENGTH"></a>
### ERR_HTTP2_ALTSVC_LENGTH

HTTP/2 ALTSVC frames are limited to a maximum of 16,382 payload bytes.

<a id="ERR_HTTP2_CONNECT_AUTHORITY"></a>
### ERR_HTTP2_CONNECT_AUTHORITY

Expand Down
94 changes: 94 additions & 0 deletions doc/api/http2.md
Expand Up @@ -558,11 +558,103 @@ added: REPLACEME
Calls [`unref()`][`net.Socket.prototype.unref`] on this `Http2Session`
instance's underlying [`net.Socket`].

### Class: ServerHttp2Session
<!-- YAML
added: v8.4.0
-->

#### serverhttp2session.altsvc(alt, originOrStream)
<!-- YAML
added: REPLACEME
-->

* `alt` {string} A description of the alternative service configuration as
defined by [RFC 7838][].
* `originOrStream` {number|string|URL|Object} Either a URL string specifying
the origin (or an Object with an `origin` property) or the numeric identifier
of an active `Http2Stream` as given by the `http2stream.id` property.

Submits an `ALTSVC` frame (as defined by [RFC 7838][]) to the connected client.

```js
const http2 = require('http2');

const server = http2.createServer();
server.on('session', (session) => {
// Set altsvc for origin https://example.org:80
session.altsvc('h2=":8000"', 'https://example.org:80');
});

server.on('stream', (stream) => {
// Set altsvc for a specific stream
stream.session.altsvc('h2=":8000"', stream.id);
});
```

Sending an `ALTSVC` frame with a specific stream ID indicates that the alternate
service is associated with the origin of the given `Http2Stream`.

The `alt` and origin string *must* contain only ASCII bytes and are
strictly interpreted as a sequence of ASCII bytes. The special value `'clear'`
may be passed to clear any previously set alternative service for a given
domain.

When a string is passed for the `originOrStream` argument, it will be parsed as
a URL and the origin will be derived. For insetance, the origin for the
HTTP URL `'https://example.org/foo/bar'` is the ASCII string
`'https://example.org'`. An error will be thrown if either the given string
cannot be parsed as a URL or if a valid origin cannot be derived.

A `URL` object, or any object with an `origin` property, may be passed as
`originOrStream`, in which case the value of the `origin` property will be
used. The value of the `origin` property *must* be a properly serialized
ASCII origin.

#### Specifying alternative services

The format of the `alt` parameter is strictly defined by [RFC 7838][] as an
ASCII string containing a comma-delimited list of "alternative" protocols
associated with a specific host and port.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this could mention the clear special value as well.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, good idea


For example, the value `'h2="example.org:81"'` indicates that the HTTP/2
protocol is available on the host `'example.org'` on TCP/IP port 81. The
host and port *must* be contained within the quote (`"`) characters.

Multiple alternatives may be specified, for instance: `'h2="example.org:81",
h2=":82"'`

The protocol identifier (`'h2'` in the examples) may be any valid
[ALPN Protocol ID][].

The syntax of these values is not validated by the Node.js implementation and
are passed through as provided by the user or received from the peer.

### Class: ClientHttp2Session
<!-- YAML
added: v8.4.0
-->

#### Event: 'altsvc'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I sort of feel this name should correspond with the name of the method on the server side. I’m ambivalent about whether alternateService is a better name than altsvc though: I feel eventually most people would refer to it as ALTSVC (HTTP/2 frame) or Alt-Svc (HTTP/1.1 header), while technically the name of the header is alternateServices.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@addaleax ... given that you were the one that made the original rename suggestion, what do you think here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jasnell If everybody here agrees that it’s going to be referred to as ALTSVC anyway, yea, probably best to stick with it?

Either way, yes, +1 for being consistent :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think altsvc is definitely going to be the most common given the way the header and frame type are defined. I'll switch the API call back :-)

<!-- YAML
added: REPLACEME
-->

The `'altsvc'` event is emitted whenever an `ALTSVC` frame is received by
the client. The event is emitted with the `ALTSVC` value, origin, and stream
ID, if any. If no `origin` is provided in the `ALTSVC` frame, `origin` will
be an empty string.

```js
const http2 = require('http2');
const client = http2.connect('https://example.org');

client.on('altsvc', (alt, origin, stream) => {
console.log(alt);
console.log(origin);
console.log(stream);
});
```

#### clienthttp2session.request(headers[, options])
<!-- YAML
added: v8.4.0
Expand Down Expand Up @@ -2850,6 +2942,7 @@ following additional properties:


[ALPN negotiation]: #http2_alpn_negotiation
[ALPN Protocol ID]: https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#alpn-protocol-ids
[Compatibility API]: #http2_compatibility_api
[HTTP/1]: http.html
[HTTP/2]: https://tools.ietf.org/html/rfc7540
Expand All @@ -2858,6 +2951,7 @@ following additional properties:
[Http2Session and Sockets]: #http2_http2session_and_sockets
[Performance Observer]: perf_hooks.html
[Readable Stream]: stream.html#stream_class_stream_readable
[RFC 7838]: https://tools.ietf.org/html/rfc7838
[Settings Object]: #http2_settings_object
[Using options.selectPadding]: #http2_using_options_selectpadding
[Writable Stream]: stream.html#stream_writable_streams
Expand Down
4 changes: 4 additions & 0 deletions lib/internal/errors.js
Expand Up @@ -304,6 +304,10 @@ E('ERR_FS_INVALID_SYMLINK_TYPE',
'Symlink type must be one of "dir", "file", or "junction". Received "%s"');
E('ERR_HTTP2_ALREADY_SHUTDOWN',
'Http2Session is already shutdown or destroyed');
E('ERR_HTTP2_ALTSVC_INVALID_ORIGIN',
'HTTP/2 ALTSVC frames require a valid origin');
E('ERR_HTTP2_ALTSVC_LENGTH',
'HTTP/2 ALTSVC frames are limited to 16382 bytes');
E('ERR_HTTP2_CONNECT_AUTHORITY',
':authority header is required for CONNECT requests');
E('ERR_HTTP2_CONNECT_PATH',
Expand Down
62 changes: 62 additions & 0 deletions lib/internal/http2/core.js
Expand Up @@ -32,6 +32,9 @@ const kMaxFrameSize = (2 ** 24) - 1;
const kMaxInt = (2 ** 32) - 1;
const kMaxStreams = (2 ** 31) - 1;

// eslint-disable-next-line no-control-regex
const kQuotedString = /^[\x09\x20-\x5b\x5d-\x7e\x80-\xff]*$/;

const {
assertIsObject,
assertValidPseudoHeaderResponse,
Expand Down Expand Up @@ -364,6 +367,16 @@ function onFrameError(id, type, code) {
process.nextTick(emit, emitter, 'frameError', type, code, id);
}

function onAltSvc(stream, origin, alt) {
const session = this[kOwner];
if (session.destroyed)
return;
debug(`Http2Session ${sessionName(session[kType])}: altsvc received: ` +
`stream: ${stream}, origin: ${origin}, alt: ${alt}`);
session[kUpdateTimer]();
process.nextTick(emit, session, 'altsvc', alt, origin, stream);
}

// Receiving a GOAWAY frame from the connected peer is a signal that no
// new streams should be created. If the code === NGHTTP2_NO_ERROR, we
// are going to send our close, but allow existing frames to close
Expand Down Expand Up @@ -706,6 +719,7 @@ function setupHandle(socket, type, options) {
handle.onheaders = onSessionHeaders;
handle.onframeerror = onFrameError;
handle.ongoawaydata = onGoawayData;
handle.onaltsvc = onAltSvc;

if (typeof options.selectPadding === 'function')
handle.ongetpadding = onSelectPadding(options.selectPadding);
Expand Down Expand Up @@ -1154,6 +1168,54 @@ class ServerHttp2Session extends Http2Session {
get server() {
return this[kServer];
}

// Submits an altsvc frame to be sent to the client. `stream` is a
// numeric Stream ID. origin is a URL string that will be used to get
// the origin. alt is a string containing the altsvc details. No fancy
// API is provided for that.
altsvc(alt, originOrStream) {
if (this.destroyed)
throw new errors.Error('ERR_HTTP2_INVALID_SESSION');

let stream = 0;
let origin;

if (typeof originOrStream === 'string') {
origin = (new URL(originOrStream)).origin;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we would be better of in accepting a URL object rather than a string, or an object with an origin  property. The typical usage is to send this down for every session, and passing an object allows a user to avoid parsing a URL for every request.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's support both. If originOrStream is passed in as an object with an origin property, it will be used. This risks, of course, the value not being strictly ASCII, but we can document that requirement.

if (origin === 'null')
throw new errors.TypeError('ERR_HTTP2_ALTSVC_INVALID_ORIGIN');
} else if (typeof originOrStream === 'number') {
if (originOrStream >>> 0 !== originOrStream || originOrStream === 0)
throw new errors.RangeError('ERR_OUT_OF_RANGE', 'originOrStream');
stream = originOrStream;
} else if (originOrStream !== undefined) {
// Allow origin to be passed a URL or object with origin property
if (originOrStream !== null && typeof originOrStream === 'object')
origin = originOrStream.origin;
// Note: if originOrStream is an object with an origin property other
// than a URL, then it is possible that origin will be malformed.
// We do not verify that here. Users who go that route need to
// ensure they are doing the right thing or the payload data will
// be invalid.
if (typeof origin !== 'string') {
throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'originOrStream',
['string', 'number', 'URL', 'object']);
} else if (origin === 'null' || origin.length === 0) {
throw new errors.TypeError('ERR_HTTP2_ALTSVC_INVALID_ORIGIN');
}
}

if (typeof alt !== 'string')
throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'alt', 'string');
if (!kQuotedString.test(alt))
throw new errors.TypeError('ERR_INVALID_CHAR', 'alt');

// Max length permitted for ALTSVC
if ((alt.length + (origin !== undefined ? origin.length : 0)) > 16382)
throw new errors.TypeError('ERR_HTTP2_ALTSVC_LENGTH');

this[kHandle].altsvc(stream, origin || '', alt);
}
}

// ClientHttp2Session instances have to wait for the socket to connect after
Expand Down
1 change: 1 addition & 0 deletions src/env.h
Expand Up @@ -179,6 +179,7 @@ class ModuleWrap;
V(netmask_string, "netmask") \
V(nsname_string, "nsname") \
V(ocsp_request_string, "OCSPRequest") \
V(onaltsvc_string, "onaltsvc") \
V(onchange_string, "onchange") \
V(onclienthello_string, "onclienthello") \
V(oncomplete_string, "oncomplete") \
Expand Down
76 changes: 76 additions & 0 deletions src/node_http2.cc
Expand Up @@ -103,6 +103,11 @@ Http2Options::Http2Options(Environment* env) {
// are required to buffer.
nghttp2_option_set_no_auto_window_update(options_, 1);

// Enable built in support for ALTSVC frames. Once we add support for
// other non-built in extension frames, this will need to be handled
// a bit differently. For now, let's let nghttp2 take care of it.
nghttp2_option_set_builtin_recv_extension_type(options_, NGHTTP2_ALTSVC);

AliasedBuffer<uint32_t, v8::Uint32Array>& buffer =
env->http2_state()->options_buffer;
uint32_t flags = buffer[IDX_OPTIONS_FLAGS];
Expand Down Expand Up @@ -830,6 +835,10 @@ inline int Http2Session::OnFrameReceive(nghttp2_session* handle,
break;
case NGHTTP2_PING:
session->HandlePingFrame(frame);
break;
case NGHTTP2_ALTSVC:
session->HandleAltSvcFrame(frame);
break;
default:
break;
}
Expand Down Expand Up @@ -1168,6 +1177,34 @@ inline void Http2Session::HandleGoawayFrame(const nghttp2_frame* frame) {
MakeCallback(env()->ongoawaydata_string(), arraysize(argv), argv);
}

// Called by OnFrameReceived when a complete ALTSVC frame has been received.
inline void Http2Session::HandleAltSvcFrame(const nghttp2_frame* frame) {
Isolate* isolate = env()->isolate();
HandleScope scope(isolate);
Local<Context> context = env()->context();
Context::Scope context_scope(context);

int32_t id = GetFrameID(frame);

nghttp2_extension ext = frame->ext;
nghttp2_ext_altsvc* altsvc = static_cast<nghttp2_ext_altsvc*>(ext.payload);
DEBUG_HTTP2SESSION(this, "handling altsvc frame");

Local<Value> argv[3] = {
Integer::New(isolate, id),
String::NewFromOneByte(isolate,
altsvc->origin,
v8::NewStringType::kNormal,
altsvc->origin_len).ToLocalChecked(),
String::NewFromOneByte(isolate,
altsvc->field_value,
v8::NewStringType::kNormal,
altsvc->field_value_len).ToLocalChecked(),
};

MakeCallback(env()->onaltsvc_string(), arraysize(argv), argv);
}

// Called by OnFrameReceived when a complete PING frame has been received.
inline void Http2Session::HandlePingFrame(const nghttp2_frame* frame) {
bool ack = frame->hd.flags & NGHTTP2_FLAG_ACK;
Expand Down Expand Up @@ -2477,6 +2514,44 @@ void Http2Stream::RefreshState(const FunctionCallbackInfo<Value>& args) {
}
}

void Http2Session::AltSvc(int32_t id,
uint8_t* origin,
size_t origin_len,
uint8_t* value,
size_t value_len) {
Http2Scope h2scope(this);
CHECK_EQ(nghttp2_submit_altsvc(session_, NGHTTP2_FLAG_NONE, id,
origin, origin_len, value, value_len), 0);
}

// Submits an AltSvc frame to the sent to the connected peer.
void Http2Session::AltSvc(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
Http2Session* session;
ASSIGN_OR_RETURN_UNWRAP(&session, args.Holder());

int32_t id = args[0]->Int32Value(env->context()).ToChecked();

// origin and value are both required to be ASCII, handle them as such.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this requirement enforced for the value string? If not then we should, to combat the HTTP header encoding debacle that only recently got rectified. In fact IMO we should verify that it only consists of HTTP tokens, similar to the HTTP header checks.

Origin is fine because it’s guaranteed to be ASCII by the URL parser.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The value is not limited to token. It will contain a sequence of uri-host constructs (host:port) within quoted strings. At the C++ level, we write these out as one-byte strings so there is at least some level of enforcement there. And on the receiving side, these are treated as one byte strings so the value will be received correctly. Anyone using extended characters will just see those get corrupted. Because of the performance implications and the fact that this is a non-critical API, I don't think we need to validate every character.... although, it would be good to extend the documentation on this a bit more -- as you said, not everyone likes to read RFCs :-)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The additional documentation is always a plus.

Anyone using extended characters will just see those get corrupted.

Well that’s certainly not the best of outcomes. If this is truly a non-critical API, I think we should ensure correctness above performance. Good call on quotation marks though, and in that case I think simply using a /[\x00-\x7f]/ check should be enough.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There may also be some security implications involved, when '\uff20' is treated identically to `'\u0020' by the V8 API.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, added a check

Local<String> origin_str = args[1]->ToString(env->context()).ToLocalChecked();
Local<String> value_str = args[2]->ToString(env->context()).ToLocalChecked();

size_t origin_len = origin_str->Length();
size_t value_len = value_str->Length();

CHECK_LE(origin_len + value_len, 16382); // Max permitted for ALTSVC
// Verify that origin len != 0 if stream id == 0, or
// that origin len == 0 if stream id != 0
CHECK((origin_len != 0 && id == 0) || (origin_len == 0 && id != 0));

MaybeStackBuffer<uint8_t> origin(origin_len);
MaybeStackBuffer<uint8_t> value(value_len);
origin_str->WriteOneByte(*origin);
value_str->WriteOneByte(*value);

session->AltSvc(id, *origin, origin_len, *value, value_len);
}

// Submits a PING frame to be sent to the connected peer.
void Http2Session::Ping(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
Expand Down Expand Up @@ -2694,6 +2769,7 @@ void Initialize(Local<Object> target,
session->SetClassName(http2SessionClassName);
session->InstanceTemplate()->SetInternalFieldCount(1);
AsyncWrap::AddWrapMethods(env, session);
env->SetProtoMethod(session, "altsvc", Http2Session::AltSvc);
env->SetProtoMethod(session, "ping", Http2Session::Ping);
env->SetProtoMethod(session, "consume", Http2Session::Consume);
env->SetProtoMethod(session, "destroy", Http2Session::Destroy);
Expand Down
7 changes: 7 additions & 0 deletions src/node_http2.h
Expand Up @@ -800,6 +800,11 @@ class Http2Session : public AsyncWrap {
void Consume(Local<External> external);
void Unconsume();
void Goaway(uint32_t code, int32_t lastStreamID, uint8_t* data, size_t len);
void AltSvc(int32_t id,
uint8_t* origin,
size_t origin_len,
uint8_t* value,
size_t value_len);

bool Ping(v8::Local<v8::Function> function);

Expand Down Expand Up @@ -877,6 +882,7 @@ class Http2Session : public AsyncWrap {
static void UpdateChunksSent(const FunctionCallbackInfo<Value>& args);
static void RefreshState(const FunctionCallbackInfo<Value>& args);
static void Ping(const FunctionCallbackInfo<Value>& args);
static void AltSvc(const FunctionCallbackInfo<Value>& args);

template <get_setting fn>
static void RefreshSettings(const FunctionCallbackInfo<Value>& args);
Expand Down Expand Up @@ -921,6 +927,7 @@ class Http2Session : public AsyncWrap {
inline void HandlePriorityFrame(const nghttp2_frame* frame);
inline void HandleSettingsFrame(const nghttp2_frame* frame);
inline void HandlePingFrame(const nghttp2_frame* frame);
inline void HandleAltSvcFrame(const nghttp2_frame* frame);

// nghttp2 callbacks
static inline int OnBeginHeadersCallback(
Expand Down