Internet play with browsers

Network version 26: signalling says each machine's WebRTC.
This commit is contained in:
MrBruh committed 2026-10-11 12:46:28 +13:00
1 parent 887f40bd2c
commit 6dc423fdbf
41 files changed
+3894 -125

No files matched your search

+4 -2
View File
@@ -311,12 +311,14 @@ jobs:
node --test "port/web/tests/*.test.js"
python3 -m pytest -q tools/test_web_build.py
- name: Test in Chromium
- name: Test in Chromium and Firefox
run: |
npm install --no-save --prefix "$RUNNER_TEMP/playwright" playwright@1.58
"$RUNNER_TEMP/playwright/node_modules/.bin/playwright" install --with-deps chromium
"$RUNNER_TEMP/playwright/node_modules/.bin/playwright" install --with-deps chromium firefox
NODE_PATH="$RUNNER_TEMP/playwright/node_modules" node port/web/tests/smoke.mjs dist/halo-web-release
NODE_PATH="$RUNNER_TEMP/playwright/node_modules" node port/web/tests/rooms.mjs dist/halo-web-release
# (the native builds' WebRTC, port/linux/src/p2p_webrtc.c, with both)
NODE_PATH="$RUNNER_TEMP/playwright/node_modules" node port/web/tests/webrtc_native.mjs chromium firefox
- name: Compile cache
run: ccache --show-stats
+6
View File
@@ -90,6 +90,7 @@ python tools/test_light_storage.py
python tools/test_death_timing.py
python -m pytest -q tools/test_web_build.py # the web build's graph, sockets, menus
node --test "port/web/tests/*.test.js" # the web page's importer and service worker
node port/web/tests/webrtc_native.mjs chromium firefox # the native builds' WebRTC with browsers (Playwright)
```
`tools/harness/README.md` explains how to add an asset-free test: the code
@@ -137,6 +138,11 @@ read `xbox/include`. To add a name, follow `port/include/xdk/README.md`.
do not play together.
- Only the host decides; clients predict their own players. Read
`port/linux/NETCODE.md` before changing the netcode.
- Internet play is the same code in every build, the web's too: a browser's
tunnel is a WebRTC data channel (`port/web/src/web_p2p.c`), which the
native builds take on their tunnel's socket (`p2p_webrtc.c`, `posix_dtls.c`).
Check a change with both (`port/web/tests/webrtc_native.mjs`, and with the
maps `port/web/tests/internet.mjs`).
- Anything another machine sends is untrusted: check sizes, indices and
rates, as the existing handlers do. `debug.network_corrupt` tests this.
+7 -5
View File
@@ -76,11 +76,13 @@ functions. These are almost the same on all platforms.
The game can play system link games on a local network and on the internet:
- A system link game can have up to 128 players on up to 128 machines.
- Linux, Windows and Android machines can play in the same game. Browsers
play each other in rooms, over WebRTC, as a local network
([port/web/README.md](port/web/README.md)); they cannot join the other
builds' games (browsers have no UDP).
- An invite link lets a machine join a game on the internet. No server of
- Linux, Windows and Android machines and browsers can play in the same
game. On a local network, the native builds play system link; browsers
play each other in rooms, over WebRTC
([port/web/README.md](port/web/README.md)).
- An invite link, or the server browser, lets a machine join a game on the
internet, browsers too: a native build takes a browser's WebRTC
([port/linux/README.md](port/linux/README.md), "Browsers"). No server of
this project is necessary.
- The netcode is new. Each machine moves its own player at once,
and the host makes the decisions for the game. Refer to
+3 -2
View File
@@ -336,8 +336,9 @@ and supplies the thread pointer and TLS.
- Gives the audio callback of SDL to a thread with a guest stack
(`host/host_sdl.c`).
- Does the calls of the guest: system calls (`host/host_syscall.c`), SDL
(`host/host_sdl.c`), OpenGL ES (`host/host_gl.c`), and the file and socket
functions of `port/linux/src/posix_*.c`.
(`host/host_sdl.c`), OpenGL ES (`host/host_gl.c`), and the file, socket,
UPnP and DTLS functions of `port/linux/src/posix_*.c` (DTLS with Mbed TLS,
for internet play with browsers: `posix_dtls.c`).
The guest calls the host through stubs (`tools/android_imports.py`). The
two ABIs use the same registers for 32-bit integers, floats and pointers.
@@ -13,18 +13,18 @@
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/options_menu" type="column_list" width="640" height="480" flags="pass_unhandled_to_focused_child up_down_tabs_children" description="main_menu/settings_select/player_setup/player_profile_edit/network_setup/help">
<data input="port settings help"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_online" x="54" y="73" platform="desktop android"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_online" x="54" y="73" platform="desktop android web"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_allow_upnp" x="54" y="103" platform="desktop android"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_join_from_clipboard" x="54" y="133" platform="desktop android"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_auto" x="54" y="163" platform="desktop android"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_player_names" x="54" y="193" platform="desktop android"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_player_names" x="54" y="73" platform="web"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_player_names" x="54" y="103" platform="web"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_player_name_scale" x="54" y="223" platform="desktop android"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_player_name_scale" x="54" y="103" platform="web"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_player_name_scale" x="54" y="133" platform="web"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_scoreboard_team_layout" x="54" y="253" platform="desktop android"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_scoreboard_team_layout" x="54" y="133" platform="web"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_scoreboard_team_layout" x="54" y="163" platform="web"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_scoreboard_background" x="54" y="283" platform="desktop android"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_scoreboard_background" x="54" y="163" platform="web"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_scoreboard_background" x="54" y="193" platform="web"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/button_bar" y="414"/>
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/button_bar" type="column_list" width="640" height="28" flags="pass_unhandled_to_focused_child left_right_tabs_items">
@@ -32,7 +32,7 @@
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/button_ok" x="380" y="1"/>
<child widget="common_button_cancel" x="510" y="1"/>
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_online" width="512" height="28" flags="pass_unhandled_to_focused_child" bitmap="bitmaps/option_bkds" color="#FF2896FF" platform="desktop android">
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_online" width="512" height="28" flags="pass_unhandled_to_focused_child" bitmap="bitmaps/option_bkds" color="#FF2896FF" platform="desktop android web">
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/online_label"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/online_spinner" x="320" y="1"/>
</widget>
+3 -1
View File
@@ -3,7 +3,9 @@
# them. The game uses every one at once (up to 4), so any one working is
# enough. One host:port on each line; "#" starts a comment.
# (The game reads this file beside config.toml: network.brokers_file. An
# update replaces it; keep a list of your own under another name.)
# update replaces it; keep a list of your own under another name. The web
# build reaches the last three over their secure WebSockets:
# port/linux/src/p2p_signal.c.)
opence.milenko.org:1883
broker.emqx.io:1883
broker.hivemq.com:1883
+8 -2
View File
@@ -184,7 +184,10 @@ lists a public game with a password with its invite's token sealed with the
password's key (`p2p_lobby.c`), a listing of another layout; version 21
sends each killing blow again reliably and an object come to rest three
times (a client waits for a player's blow before its body dies without one),
and switches co-op's BSP on the host's crossing alone.
and switches co-op's BSP on the host's crossing alone; version 26 has each
machine say its WebRTC in internet play's signalling (a native build's
certificate, a browser's ICE credentials too), so that browsers play
(port/linux/README.md, "Browsers").
A client plays by its host's rules: in another's game (searching for it,
in its lobby, or playing it) the developer console, the telnet console
@@ -614,7 +617,10 @@ swap the bots' wandering does not reach (`debug.network_test_pickup_weapon`
picks the weapon: the first whose tag name has it in it, as "sniper"), and `debug.network_test_score`
shortens the game, to test the next (`host:<map>:<variant>,<variant>...`
plays the variants in turn, the next once a game is over, as the host's
button on the scores does). `debug.network_latency` and
button on the scores does). `debug.network_test_public` lists the host's
game in the server browser, and `browse` joins the first game listed there,
as the server browser does (port/web/tests/internet.mjs plays the native
build and browsers so). `debug.network_latency` and
`debug.network_loss` hold back what a machine receives and drop some of its
datagrams, to test as over the internet. `debug.network_corrupt` damages
that share of the datagrams a machine receives at random (bytes changed,
+36 -2
View File
@@ -406,7 +406,7 @@ the setting for one start of the game. It has priority over the file.
| `debug.gpu_stats`, `debug.gpu_trace_frame`, `debug.gpu_trace_constants`, `debug.gpu_dump_shaders`, `debug.texture_dump_directory`, `debug.texture_log`, `debug.gl_debug`, `debug.texture_no_cache` | off | `HALO_GPU_STATS`, `HALO_GPU_TRACE`, `HALO_GPU_TRACE_CONSTANTS`, `HALO_GPU_DUMP_SHADERS`, `HALO_TEXTURE_DUMP`, `HALO_TEXTURE_LOG`, `HALO_GL_DEBUG`, `HALO_TEXTURE_NO_CACHE` | Tools to find problems in the graphics: counts for each frame, all the GL state of one frame, the GLSL code, the textures. |
| `debug.menu_open` | `""` | `HALO_MENU_OPEN` | Start on this screen of the menus (`main_menu/settings_select/...`, as `port/assets/menus` names it), a player profile being edited, to look at it. |
| `debug.gpu_skip_vertex_shaders`, `debug.gpu_debug_expression`, `debug.gpu_debug_flat`, `debug.gpu_debug_texture0` | off | `HALO_GPU_SKIP_VS`, `HALO_GPU_DEBUG_EXPR`, `HALO_GPU_DEBUG_FLAT`, `HALO_GPU_DEBUG_T0` | Tools to find problems in the graphics: skip the draws of a vertex shader, or replace the output of all pixel shaders with a GLSL expression (for example `t0.rgb`). |
| `debug.network_test`, `debug.network_test_start`, `debug.network_test_kill`, `debug.network_test_score`, `debug.network_test_shoot`, `debug.network_test_vehicle`, `debug.network_test_pickup`, `debug.network_test_pickup_weapon`, `debug.test_input` | off | `HALO_NETWORK_TEST`, `HALO_NETWORK_TEST_START`, `HALO_NETWORK_TEST_KILL`, `HALO_NETWORK_TEST_SCORE`, `HALO_NETWORK_TEST_SHOOT`, `HALO_NETWORK_TEST_VEHICLE`, `HALO_NETWORK_TEST_PICKUP`, `HALO_NETWORK_TEST_PICKUP_WEAPON`, `HALO_TEST_INPUT` | Automatic tests of system link (`game/network_test.c`). Refer to `NETCODE.md`. |
| `debug.network_test`, `debug.network_test_start`, `debug.network_test_kill`, `debug.network_test_score`, `debug.network_test_shoot`, `debug.network_test_vehicle`, `debug.network_test_pickup`, `debug.network_test_pickup_weapon`, `debug.network_test_public`, `debug.test_input` | off | `HALO_NETWORK_TEST`, `HALO_NETWORK_TEST_START`, `HALO_NETWORK_TEST_KILL`, `HALO_NETWORK_TEST_SCORE`, `HALO_NETWORK_TEST_SHOOT`, `HALO_NETWORK_TEST_VEHICLE`, `HALO_NETWORK_TEST_PICKUP`, `HALO_NETWORK_TEST_PICKUP_WEAPON`, `HALO_NETWORK_TEST_PUBLIC`, `HALO_TEST_INPUT` | Automatic tests of system link (`game/network_test.c`). Refer to `NETCODE.md`. |
| `debug.touch_targets` | `false` | `HALO_TOUCH_TARGETS` | Outlines the tap targets of the menus (item green, value blue, list slot yellow, legend button red, the band beside the slots of a list orange, keys of the on-screen keyboard white), marks where the last finger went down and the last tap landed for 3 seconds, and logs each tap with the target that it hit (for a value, also where it splits into previous and next): to judge the accuracy of touch. |
| `debug.network_latency`, `debug.network_loss`, `debug.network_corrupt`, `debug.network_corrupt_stream`, `debug.network_corrupt_after` | `0` | `HALO_NETWORK_LATENCY`, `HALO_NETWORK_LOSS`, `HALO_NETWORK_CORRUPT`, `HALO_NETWORK_CORRUPT_STREAM`, `HALO_NETWORK_CORRUPT_AFTER` | The game holds all the data that it receives for this number of milliseconds, ignores this percentage of the datagrams, and damages this percentage of the datagrams it receives, and this percentage of its reads of streams, at random (bytes changed, cut short, stretched or replaced), from this many seconds after the start. Use the first two to test the netcode as on the internet, and the others to test that nothing another machine sends can crash the game (a damaged stream is closed, so a little goes a long way; a host's messages to its own client are damaged too, so start damaging once the game has started). |
| `debug.voice_test` | `false` | `HALO_VOICE_TEST` | Automatic tests of voice chat: a tone replaces the microphone, and each voice that the game hears is written to the log once each second. |
@@ -615,10 +615,42 @@ To join a game, do one of these steps:
- Enter `halo <link>`.
- Accept a Discord invite. Refer to "Discord".
The game also takes the address of the web build's page with an invite
(`https://halocombatevolved.com/?join=<64 digits>`), as a link.
When the machines connect, the game of the host shows in Multiplayer,
System Link. Join the game as on a local network. System link on a local
network does not need an invite.
### Browsers
The web build ([port/web/README.md](../web/README.md)) plays internet games
with the Linux, Windows and Android builds: it hosts and joins by invite, and
the server browser lists its public games and theirs. A browser has no UDP
socket, only WebRTC, so a native build takes a browser's WebRTC on the port
of its tunnel (`network.tunnel_port`), as little of it as one data channel
needs (`src/p2p_webrtc.c`):
- ICE: the native build is an ICE-lite agent. It answers the checks of the
browser, and the browser chooses the path. The credentials come from the
secret of the session, which both machines have. Until the browser
connects, the native build sends STUN indications to the addresses of the
browser, which open its NAT to the checks of the browser.
- DTLS 1.2 (`src/posix_dtls.c`, Mbed TLS: `port/third_party/mbedtls`): the
native build is the server, with a certificate that it makes when it
starts (ECDSA P-256). Each machine sends the SHA-256 of its certificate in
its signalling messages, which the token seals and the proof of the
session authenticates; the other machine accepts only that certificate.
- SCTP, over DTLS: the browser opens the association, and one data channel,
agreed before (unordered, without retransmissions), carries the packets of
the tunnel. They are sealed as on UDP, and KCP makes the streams reliable
as on UDP.
Two browsers connect to each other with their own WebRTC. There is no relay:
as between native builds, two networks that do not let a direct connection
through (some company and school networks block WebRTC) cannot play
together.
### Voice chat
Network games have voice chat, on the local network and on the internet.
@@ -756,7 +788,9 @@ Only machines with the invite can find the game:
a random number from each. The keys do not go through the brokers. Thus
other machines with the invite cannot read or change the packets.
- Each packet is encrypted and authenticated, with a different key in each
direction. A machine ignores a packet that it already received.
direction. A machine ignores a packet that it already received. To and
from a browser, the packets also go through DTLS, with the certificates
that the signalling messages named.
- A machine can send only to the ports of the game on the other machine.
- The host makes one session from each request of a player. If a person
sends a copy of an old request again, the host ignores it. A player that
+44 -1
View File
@@ -14,7 +14,10 @@ Automated system link sessions for testing the netcode without the menus
the variant "coop" makes the game network co-op on it, at normal
difficulty, as the Map screen does for a campaign level;
- "join" searches for games and joins the first it finds, as picking it in
the system link list does.
the system link list does;
- "browse" joins the first game the server browser lists (internet play's
public games: a host's with debug.network_test_public), as picking it
there does, and then as "join".
Once the game runs, every second each machine logs where every player's
unit is, so the machines' views of the game can be compared.
@@ -56,6 +59,8 @@ Called from the main loop every frame (main.c).
#include "tag_files/tag_files.h"
#include "camera/observer.h"
#include "../src/p2p.h" /* port: port/linux/src/p2p.c, p2p_lobby.c */
#include <math.h>
#include <stdarg.h>
#include <stdio.h>
@@ -63,6 +68,7 @@ Called from the main loop every frame (main.c).
/* the platform layer's (port/linux/src/port_config.c) */
const char *config_string(char const *name);
int config_boolean(char const *name);
double config_real(char const *name);
long config_integer(char const *name);
void platform_log(char const *format, ...);
@@ -80,6 +86,7 @@ enum
_network_test_off,
_network_test_host,
_network_test_join,
_network_test_browse,
};
static struct
@@ -110,6 +117,8 @@ static struct
char pickup_weapon[64];
long score_to_win;
long logged_time;
boolean public_game;
boolean browsing;
} network_test;
/* the variant at the index of the list (copied to name), FALSE past its end */
@@ -161,6 +170,11 @@ static void network_test_read_settings(
{
network_test.mode = _network_test_join;
}
else if (!strcmp(setting, "browse"))
{
network_test.mode = _network_test_browse;
}
network_test.public_game = config_boolean("debug.network_test_public") != 0;
network_test.start_delay = (real)config_real("debug.network_test_start");
network_test.kill_interval = (real)config_real("debug.network_test_kill");
network_test.shoot_interval = (real)config_real("debug.network_test_shoot");
@@ -918,6 +932,32 @@ void network_test_update(
switch (network_test.mode)
{
case _network_test_browse:
{
struct p2p_listing games[8];
int count;
if (!network_test.browsing)
{
network_test.browsing = TRUE;
p2p_lobby_browse(TRUE);
platform_log("network test: browsing the public games");
}
count = p2p_lobby_games(games, NUMBEROF(games));
/* (the first that can be joined: no password, not failed) */
while (count > 0 && (!games[0].invite[0] || games[0].failed))
{
memmove(games, games + 1, sizeof(*games) * (count - 1));
count--;
}
if (count > 0 && p2p_join_invite(games[0].invite))
{
p2p_lobby_browse(FALSE);
platform_log("network test: joining %s's game (%s) from the server browser", games[0].name, games[0].map);
network_test.mode = _network_test_join;
}
break;
}
case _network_test_host:
if (!network_test.set_up)
{
@@ -925,6 +965,9 @@ void network_test_update(
main_set_multiplayer_map_name(network_test.map_name);
player_ui_fast_setup_network_server();
platform_log("network test: hosting %s", network_test.map_name);
/* (in the server browser, as Create Game > Internet's PUBLIC) */
if (network_test.public_game)
p2p_set_hosting_public(TRUE);
}
else if (!network_test.started)
{
+1 -1
View File
@@ -66,7 +66,7 @@ is newer (network_client_manager.c). A host advertises it, with its netcode,
in its game's advertisement's reserved bytes (network_server_message_handler.c),
which hosts built before there was a version send as zeros: version 0.
Raise it with any change to what the machines send each other. */
#define HALO_PORT_NETWORK_VERSION 25
#define HALO_PORT_NETWORK_VERSION 26
/* ... the advertisement's reserved bytes: the version (a little-endian word),
then flags */
#define HALO_PORT_ADVERTISED_VERSION_OFFSET 0
+87 -17
View File
@@ -183,6 +183,9 @@ struct peer
unsigned long virtual_address;
int is_host;
int connected;
/* the WebRTC connection carrying its tunnel (p2p_webrtc.c: a browser's),
or -1 (the UDP tunnel) */
int connection;
struct p2p_candidate candidates[P2P_MAXIMUM_CANDIDATES];
int candidate_count;
struct p2p_candidate endpoint;
@@ -803,7 +806,7 @@ static void peer_send_to(struct peer *peer, const struct p2p_candidate *to, cons
int sealed;
int index;
if (p2p.tunnel_socket < 0 || size > MAXIMUM_INNER_SIZE)
if ((p2p.tunnel_socket < 0 && peer->connection < 0) || size > MAXIMUM_INNER_SIZE)
return;
/* the header, authenticated with the rest */
counter = ++peer->send_counter;
@@ -814,6 +817,12 @@ static void peer_send_to(struct peer *peer, const struct p2p_candidate *to, cons
packet_nonce(packet, nonce);
sealed = p2p_aead_seal(peer->send_key, nonce, packet, TUNNEL_HEADER_SIZE, inner, size,
packet + TUNNEL_HEADER_SIZE);
/* (a browser's: over its WebRTC connection, wherever that goes) */
if (peer->connection >= 0)
{
p2p_webrtc_send(peer->connection, packet, TUNNEL_HEADER_SIZE + sealed);
return;
}
make_address(&address, to->address, to->port);
posix_socket_sendto(p2p.tunnel_socket, packet, TUNNEL_HEADER_SIZE + sealed, 0, &address, sizeof(address));
}
@@ -901,6 +910,8 @@ static void drop_peer(struct peer *peer, const char *reason)
peer_send(peer, &bye, 1);
}
release_peer_links((int)(peer - p2p.peers), 0);
if (peer->connection >= 0)
p2p_webrtc_close(peer->connection);
/* a session that ended does not come back: its packets would pass
again */
memcpy(retired->secret, peer->secret, P2P_SHA256_SIZE);
@@ -985,7 +996,7 @@ int p2p_peer_turned_away(const unsigned char *peer_identifier, int is_host)
}
int p2p_peer_offered(const unsigned char *peer_identifier, const unsigned char *secret,
const struct p2p_candidate *candidates, int count, int is_host)
const struct p2p_candidate *candidates, int count, const struct p2p_webrtc *webrtc, int is_host)
{
struct peer *peer = find_peer(peer_identifier);
@@ -1020,24 +1031,43 @@ int p2p_peer_offered(const unsigned char *peer_identifier, const unsigned char *
peer->receive_window = 1;
peer->virtual_address = virtual_address_for(peer_identifier);
peer->is_host = is_host;
peer->connection = -1;
peer->offered_time = p2p_now();
platform_log("Internet play: reaching %s %s", is_host ? "host" : "player", peer->name);
platform_log("Internet play: reaching %s %s%s", is_host ? "host" : "player", peer->name,
webrtc->kind == _p2p_webrtc_browser ? " (a browser)" : "");
}
add_candidates(peer, candidates, count);
peer->connection = p2p_webrtc_offered(peer->connection, (int)(peer - p2p.peers), peer->secret, webrtc,
candidates, count, peer->is_host);
return 1;
}
int p2p_peer_reoffered(const unsigned char *peer_identifier, const unsigned char *secret,
const struct p2p_candidate *candidates, int count)
const struct p2p_candidate *candidates, int count, const struct p2p_webrtc *webrtc)
{
struct peer *peer = find_peer(peer_identifier);
if (!peer || memcmp(peer->secret, secret, P2P_SHA256_SIZE))
return 0;
add_candidates(peer, candidates, count);
if (count || webrtc->ufrag[0])
{
peer->connection = p2p_webrtc_offered(peer->connection, (int)(peer - p2p.peers), peer->secret, webrtc,
candidates, count, peer->is_host);
}
return 1;
}
void p2p_peer_name(int peer, char *name)
{
memcpy(name, p2p.peers[peer].name, sizeof(p2p.peers[peer].name));
}
unsigned short p2p_tunnel_local_port(void)
{
return p2p.tunnel_port;
}
/* whether a packet of this number from the peer is new: not received yet,
nor from before the window */
static int packet_fresh(const struct peer *peer, unsigned long long counter)
@@ -1136,8 +1166,10 @@ static void update_peers(void)
/* (every address it gave, until a packet sealed by it arrives
from one: then only that one, above. The addresses are anyone's
who has the invite to give, so they are sent to no longer than
this) */
for (candidate = 0; candidate < peer->candidate_count; candidate++)
this. A browser's go over its WebRTC connection, once it is open) */
if (peer->connection >= 0)
peer_ping(peer, &peer->endpoint);
for (candidate = 0; candidate < peer->candidate_count && peer->connection < 0; candidate++)
peer_ping(peer, &peer->candidates[candidate]);
peer->sent_time = p2p_now();
}
@@ -1171,6 +1203,10 @@ static void stun_setup(void)
{
const char *text = config_string("network.stun_servers");
#ifdef HALO_WEB
/* (a page's addresses are its WebRTC connections' own: web_p2p.c) */
text = "";
#endif
while (*text && p2p.stun_count < MAXIMUM_STUN_SERVERS)
{
const char *end = text + strcspn(text, ",");
@@ -2261,6 +2297,24 @@ static void tunnel_received(const unsigned char *packet, int size, const struct
}
}
void p2p_tunnel_packet(const unsigned char *packet, int size, unsigned long address, unsigned short port)
{
struct sockaddr_in from;
make_address(&from, address, port);
tunnel_received(packet, size, &from);
}
void p2p_tunnel_send(unsigned long address, unsigned short port, const void *data, int size)
{
struct sockaddr_in to;
if (p2p.tunnel_socket < 0)
return;
make_address(&to, address, port);
posix_socket_sendto(p2p.tunnel_socket, data, size, 0, &to, sizeof(to));
}
static void tunnel_readable(void)
{
unsigned char packet[2048];
@@ -2274,7 +2328,9 @@ static void tunnel_readable(void)
if (size < 0)
break;
tunnel_received(packet, size, &from);
/* (a browser's ICE checks and DTLS, which carries its tunnel) */
if (!p2p_webrtc_received(packet, size, from.sin_addr.s_addr, from.sin_port))
tunnel_received(packet, size, &from);
}
}
@@ -2293,14 +2349,20 @@ static int parse_invite(const char *text, unsigned char *host_hash, unsigned cha
for (search = text; *search && !start; search++)
{
static const char prefix[] = "halo://join/";
int length;
/* (a link, or the web page's address with one: ?join=) */
static const char *const prefixes[] = { "halo://join/", "?join=", "&join=" };
int prefix;
for (length = 0; prefix[length] && search[length] &&
(search[length] | 0x20) == prefix[length]; length++)
;
if (!prefix[length])
start = search + length;
for (prefix = 0; prefix < (int)(sizeof(prefixes) / sizeof(*prefixes)) && !start; prefix++)
{
int length;
for (length = 0; prefixes[prefix][length] && search[length] &&
(search[length] | 0x20) == prefixes[prefix][length]; length++)
;
if (!prefixes[prefix][length])
start = search + length;
}
}
if (!start)
{
@@ -2387,9 +2449,10 @@ static void update_joining(void)
if (p2p.join_requested)
{
/* the offer carries the public address, if there is one */
/* the offer carries the public address, if there is one, and a
browser's WebRTC */
p2p.stun_started = 1;
if (!stun_settled())
if (!stun_settled() || !p2p_webrtc_ready())
return;
p2p.join_requested = 0;
p2p.joining = 1;
@@ -2650,6 +2713,10 @@ static void update_upnp(void)
if (allowed < 0)
allowed = config_boolean("network.allow_upnp") ? 1 : 0;
#ifdef HALO_WEB
/* (a page cannot reach the router) */
allowed = 0;
#endif
if (!allowed || p2p.upnp_working || p2p.upnp_released)
return;
if (p2p.upnp_forwarded)
@@ -2851,6 +2918,8 @@ static void *p2p_thread(void *unused)
static int read[MAXIMUM_SOCKETS], write[MAXIMUM_SOCKETS];
static int asked_read[MAXIMUM_SOCKETS], asked_write[MAXIMUM_SOCKETS];
static int read_owners[MAXIMUM_SOCKETS], write_owners[MAXIMUM_SOCKETS];
/* WebRTC's connections opening or open (their timers) */
int webrtc_busy = 0;
(void)unused;
#ifdef HALO_PROFILE
@@ -2871,7 +2940,7 @@ static void *p2p_thread(void *unused)
int asked_read_count, asked_write_count;
/* KCP's clock needs a pass every LOOP_INTERVAL while it carries
streams; otherwise the thread can sleep longer */
int wait = LOOP_INTERVAL * 5;
int wait = webrtc_busy ? LOOP_INTERVAL : LOOP_INTERVAL * 5;
int index, asked;
/* what to wait for */
@@ -3030,6 +3099,7 @@ static void *p2p_thread(void *unused)
profile_trace_end(p2p_profile_names.kcp_update);
#endif
update_peers();
webrtc_busy = p2p_webrtc_update();
expire_proxies();
stun_update();
update_hosting();
+12 -1
View File
@@ -13,7 +13,6 @@ brokers carry sealed messages, so only holders of the invite read them; the
tunnel's packets are sealed with keys only its two machines have.
*/
#include "platform.h"
#include "posix.h"
#include "p2p_internal.h"
@@ -704,3 +703,15 @@ int p2p_ed25519_verify(const unsigned char *public_key, const void *message, int
return p2p_ed25519_to_x25519(public_key, x25519_public) &&
crypto_ed25519_check(signature, public_key, message, (size_t)size) == 0;
}
/* a native build's ICE credentials (p2p_webrtc.c) from a session's secret:
8 and 32 hexadecimal digits */
void p2p_webrtc_credentials(const unsigned char *secret, char *ufrag, char *password)
{
unsigned char digest[P2P_SHA256_SIZE];
p2p_hmac_sha256(secret, P2P_SHA256_SIZE, "webrtc ice-ufrag", 16, digest);
p2p_hex(digest, 4, ufrag);
p2p_hmac_sha256(secret, P2P_SHA256_SIZE, "webrtc ice-pwd", 14, digest);
p2p_hex(digest, 16, password);
}
+88 -6
View File
@@ -84,23 +84,25 @@ void p2p_identifier_from_hash(const unsigned char *hash, unsigned char *identifi
0 if the key is unusable (one giving a known secret). The p2p thread's: it
lets go of the p2p lock while it works it out */
int p2p_shared_secret(const unsigned char *public_key, unsigned char *shared);
/* a joiner (on the host) or the host (on a joiner) offered its addresses
through signalling, with the secret of a session (P2P_SHA256_SIZE bytes) its
/* a joiner (on the host) or the host (on a joiner) offered its addresses and
its WebRTC through signalling, with the secret of a session (P2P_SHA256_SIZE bytes) its
tunnel's keys come from; the tunnel starts reaching it. Returns 0 if it was
turned away: another session with that machine lives (it must lapse first),
this one has ended, or there is no room */
struct p2p_webrtc;
int p2p_peer_offered(const unsigned char *identifier, const unsigned char *secret,
const struct p2p_candidate *candidates, int count, int is_host);
const struct p2p_candidate *candidates, int count, const struct p2p_webrtc *webrtc, int is_host);
/* whether p2p_peer_offered would turn a new session with that machine away
now (it is this machine, a session with it lives, there is no room, or, as
a host, too many players are being reached): checked before its secret is
worked out */
int p2p_peer_turned_away(const unsigned char *identifier, int is_host);
/* ... more addresses of a machine whose session (that secret's) lives: 0 if
none does (a session that has ended is never taken up again: its keys'
/* ... more addresses of a machine whose session (that secret's) lives, and
its WebRTC (a browser host's credentials come once the request is proven):
0 if none does (a session that has ended is never taken up again: its keys'
packet numbers would start again) */
int p2p_peer_reoffered(const unsigned char *identifier, const unsigned char *secret,
const struct p2p_candidate *candidates, int count);
const struct p2p_candidate *candidates, int count, const struct p2p_webrtc *webrtc);
/* an invite that arrived on the p2p thread (from Discord, or another copy
of the game) */
void p2p_invite_received(const char *text);
@@ -219,6 +221,86 @@ void p2p_seal_token(const unsigned char *key, const unsigned char *signing_key,
int p2p_unseal_token(const unsigned char *key, const unsigned char *signing_key, const unsigned char *sealed,
unsigned char *token);
/* ---------- WebRTC: tunnels to and from browsers (p2p_webrtc.c in the
native builds, port/web/src/web_p2p.c in the browser) */
enum
{
/* what a machine says of itself in signalling: nothing (no machine of
this version), a native build (which takes browsers' WebRTC on its
tunnel's socket), or a browser (which has WebRTC alone) */
_p2p_webrtc_none = 0,
_p2p_webrtc_native,
_p2p_webrtc_browser,
/* the SHA-256 of a DTLS certificate */
P2P_FINGERPRINT_SIZE = 32,
/* a browser's own addresses, which it hides behind mDNS names (another
browser on its network can look them up), and a name's text with its
end */
P2P_MAXIMUM_WEBRTC_NAMES = 2,
P2P_WEBRTC_NAME_SIZE = 64,
/* ICE's username fragment and password, as text with their end */
P2P_ICE_UFRAG_SIZE = 33,
P2P_ICE_PASSWORD_SIZE = 65,
};
/* a machine's WebRTC, as signalled */
struct p2p_webrtc
{
int kind;
unsigned char fingerprint[P2P_FINGERPRINT_SIZE];
/* a browser's ICE credentials (empty until it has a connection for the
other machine: a host's, once the joiner proved its request). A native
build's come from the session's secret (p2p_webrtc_credentials) */
char ufrag[P2P_ICE_UFRAG_SIZE];
char password[P2P_ICE_PASSWORD_SIZE];
/* a browser's addresses on its own network, as mDNS names
("<uuid>.local") and ports (network byte order) */
int name_count;
char names[P2P_MAXIMUM_WEBRTC_NAMES][P2P_WEBRTC_NAME_SIZE];
unsigned short name_ports[P2P_MAXIMUM_WEBRTC_NAMES];
};
/* whether this machine's WebRTC can be told yet (a browser's: its
certificate and a connection ready; a native build's at once) */
int p2p_webrtc_ready(void);
/* this machine's WebRTC for a message to the machine with this identifier
(the host joined, or a joiner answered: proven, or not yet), and the
addresses it offers it; returns their count */
int p2p_webrtc_describe(const unsigned char *identifier, int proven, struct p2p_webrtc *local,
struct p2p_candidate *candidates, int maximum_count);
/* a new request to join (p2p_signal_join): a browser's connection for the
last one, not taken up by a session, goes */
void p2p_webrtc_new_request(void);
/* a session with the peer (p2p.c's index) whose machine has this WebRTC
was offered, or offered again (more addresses, a browser's credentials):
the WebRTC connection that carries its tunnel (an index, kept by p2p.c), or
-1 if none does (two native builds: their UDP tunnel). connection is the one
returned before (-1 the first time) */
int p2p_webrtc_offered(int connection, int peer, const unsigned char *secret, const struct p2p_webrtc *remote,
const struct p2p_candidate *candidates, int count, int is_host);
void p2p_webrtc_close(int connection);
/* a tunnel packet to the connection's peer (dropped until it is open) */
void p2p_webrtc_send(int connection, const void *packet, int size);
/* a datagram that arrived on the tunnel's socket: 1 if it was WebRTC's (an
ICE check, DTLS), taken */
int p2p_webrtc_received(const unsigned char *packet, int size, unsigned long address, unsigned short port);
/* timers (DTLS, SCTP) and hole punching; called from the p2p thread each
pass: whether a connection is opening or open (the thread passes often) */
int p2p_webrtc_update(void);
/* a native build's ICE credentials from a session's secret (both ends work
them out: they do not travel); p2p_crypto.c's */
void p2p_webrtc_credentials(const unsigned char *secret, char *ufrag, char *password);
/* p2p.c's, which WebRTC calls: a tunnel packet a connection carried, a
datagram it sends on the tunnel's socket, and a peer's name as logged (its
identifier in hex: 2 * P2P_IDENTIFIER_SIZE + 1 characters) */
void p2p_tunnel_packet(const unsigned char *packet, int size, unsigned long address, unsigned short port);
void p2p_tunnel_send(unsigned long address, unsigned short port, const void *data, int size);
void p2p_peer_name(int peer, char *name);
/* the tunnel's socket's port (network byte order) */
unsigned short p2p_tunnel_local_port(void);
/* ---------- p2p_lobby.c: public games' listings */
/* the p2p thread's pass: the token of the game hosted for the internet
+160 -25
View File
@@ -191,8 +191,9 @@ enum
{
_message_join = 'J',
_message_accept = 'A',
/* 3: a JOIN proves its key before the host makes a session */
MESSAGE_VERSION = 3,
/* 3: a JOIN proves its key before the host makes a session; 4: both
messages say the machine's WebRTC (p2p_webrtc.c), so browsers join */
MESSAGE_VERSION = 4,
};
struct broker
@@ -812,10 +813,18 @@ static void broker_connect(struct broker *broker)
/* ---------- addresses */
static int put_candidates(unsigned char *message)
/* this machine's addresses and its WebRTC (p2p_webrtc_describe), for a
message to the machine with that identifier: the count of addresses, each
address and port, then the WebRTC's kind and, unless none, the certificate's
hash, the ICE username fragment and password, each after its length, and
the count of mDNS names, each after its length and before its port; returns
the bytes written */
static int put_addresses(unsigned char *message, const unsigned char *identifier, int proven)
{
struct p2p_candidate candidates[P2P_MAXIMUM_CANDIDATES];
int count = p2p_local_candidates(candidates, P2P_MAXIMUM_CANDIDATES);
struct p2p_webrtc webrtc;
int count = p2p_webrtc_describe(identifier, proven, &webrtc, candidates, P2P_MAXIMUM_CANDIDATES);
int size;
int index;
message[0] = (unsigned char)count;
@@ -824,20 +833,84 @@ static int put_candidates(unsigned char *message)
memcpy(message + 1 + index * 6, &candidates[index].address, 4);
memcpy(message + 1 + index * 6 + 4, &candidates[index].port, 2);
}
return 1 + count * 6;
size = 1 + count * 6;
message[size++] = (unsigned char)webrtc.kind;
if (webrtc.kind == _p2p_webrtc_none)
return size;
memcpy(message + size, webrtc.fingerprint, P2P_FINGERPRINT_SIZE);
size += P2P_FINGERPRINT_SIZE;
message[size] = (unsigned char)strlen(webrtc.ufrag);
memcpy(message + size + 1, webrtc.ufrag, message[size]);
size += 1 + message[size];
message[size] = (unsigned char)strlen(webrtc.password);
memcpy(message + size + 1, webrtc.password, message[size]);
size += 1 + message[size];
message[size++] = (unsigned char)webrtc.name_count;
for (index = 0; index < webrtc.name_count; index++)
{
message[size] = (unsigned char)strlen(webrtc.names[index]);
memcpy(message + size + 1, webrtc.names[index], message[size]);
size += 1 + message[size];
memcpy(message + size, &webrtc.name_ports[index], 2);
size += 2;
}
return size;
}
static int get_candidates(const unsigned char *message, int size, struct p2p_candidate *candidates)
/* whether text is an mDNS name of a browser's address: letters, digits and
"-" before ".local" (a browser puts it in its session description) */
static int webrtc_name_valid(const char *text)
{
int count;
int length = (int)strlen(text);
return length > 6 && !strcmp(text + length - 6, ".local") && (int)strspn(text,
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-") == length - 6;
}
/* ICE's text (RFC 8839's ice-char: letters, digits, "+" and "/"), of a
length from minimum to maximum, or none; a browser puts it in its session
description */
static int get_ice_text(const unsigned char *message, int size, int *offset, int minimum, int maximum,
char *text)
{
int length;
int index;
if (size < 1)
if (*offset >= size)
return 0;
length = message[(*offset)++];
if ((length && (length < minimum || length > maximum)) || *offset + length > size)
return 0;
for (index = 0; index < length; index++)
{
char character = (char)message[*offset + index];
if (!((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
(character >= '0' && character <= '9') || character == '+' || character == '/'))
{
return 0;
}
text[index] = character;
}
text[length] = 0;
*offset += length;
return 1;
}
/* the reverse: the bytes read, or -1 if they are not made so */
static int get_addresses(const unsigned char *message, int size, struct p2p_candidate *candidates, int *count,
struct p2p_webrtc *webrtc)
{
int offset;
int index;
memset(webrtc, 0, sizeof(*webrtc));
if (size < 2)
return -1;
count = message[0];
if (count > P2P_MAXIMUM_CANDIDATES || size < 1 + count * 6)
*count = message[0];
if (*count > P2P_MAXIMUM_CANDIDATES || size < 2 + *count * 6)
return -1;
for (index = 0; index < count; index++)
for (index = 0; index < *count; index++)
{
unsigned int address;
@@ -845,7 +918,43 @@ static int get_candidates(const unsigned char *message, int size, struct p2p_can
candidates[index].address = address;
memcpy(&candidates[index].port, message + 1 + index * 6 + 4, 2);
}
return count;
offset = 1 + *count * 6;
webrtc->kind = message[offset++];
if (webrtc->kind == _p2p_webrtc_none)
return offset;
if ((webrtc->kind != _p2p_webrtc_native && webrtc->kind != _p2p_webrtc_browser) ||
offset + P2P_FINGERPRINT_SIZE > size)
{
return -1;
}
memcpy(webrtc->fingerprint, message + offset, P2P_FINGERPRINT_SIZE);
offset += P2P_FINGERPRINT_SIZE;
if (!get_ice_text(message, size, &offset, 4, P2P_ICE_UFRAG_SIZE - 1, webrtc->ufrag) ||
!get_ice_text(message, size, &offset, 22, P2P_ICE_PASSWORD_SIZE - 1, webrtc->password) ||
!webrtc->ufrag[0] != !webrtc->password[0] || offset >= size)
{
return -1;
}
webrtc->name_count = message[offset++];
if (webrtc->name_count > P2P_MAXIMUM_WEBRTC_NAMES)
return -1;
for (index = 0; index < webrtc->name_count; index++)
{
int length;
if (offset >= size)
return -1;
length = message[offset++];
if (length >= P2P_WEBRTC_NAME_SIZE || offset + length + 2 > size)
return -1;
memcpy(webrtc->names[index], message + offset, (size_t)length);
webrtc->names[index][length] = 0;
memcpy(&webrtc->name_ports[index], message + offset + length, 2);
offset += length + 2;
if (!webrtc_name_valid(webrtc->names[index]) || !webrtc->name_ports[index])
return -1;
}
return offset;
}
/* ---------- the keys: what only a joiner and the host can work out */
@@ -950,7 +1059,7 @@ static void send_join(void)
size += P2P_KEY_SIZE;
memcpy(message + size, signalling.join_nonce, NONCE_SIZE);
size += NONCE_SIZE;
size += put_candidates(message + size);
size += put_addresses(message + size, signalling.join_host, 1);
/* answered: the proof that this machine holds its key, of which the host
makes the session */
if (signalling.join_answered)
@@ -1033,7 +1142,7 @@ static void send_accept(struct broker *broker, const unsigned char *identifier,
size += NONCE_SIZE;
memcpy(answer + size, host_nonce, NONCE_SIZE);
size += NONCE_SIZE;
size += put_candidates(answer + size);
size += put_addresses(answer + size, identifier, proven);
message_tag(base, "accept", answer, size, answer + size);
size += TAG_SIZE;
size = p2p_seal(signalling.host_key, answer, size, sealed);
@@ -1048,6 +1157,8 @@ nonce, and a tag) once the host answered it */
static void join_received(struct broker *broker, const unsigned char *message, int size)
{
struct p2p_candidate candidates[P2P_MAXIMUM_CANDIDATES];
struct p2p_webrtc webrtc;
static const struct p2p_webrtc no_webrtc;
unsigned char identifier[P2P_IDENTIFIER_SIZE];
unsigned char request[P2P_IDENTIFIER_SIZE + NONCE_SIZE];
unsigned char host_nonce[NONCE_SIZE];
@@ -1061,13 +1172,16 @@ static void join_received(struct broker *broker, const unsigned char *message, i
struct used_request *used;
int proven;
int count;
int read;
if (size < fixed + 1)
/* (a browser answers once its WebRTC can be told: the joiner asks
again) */
if (size < fixed + 1 || !p2p_webrtc_ready())
return;
count = get_candidates(message + fixed, size - fixed, candidates);
if (count < 0)
read = get_addresses(message + fixed, size - fixed, candidates, &count, &webrtc);
if (read < 0)
return;
proven = size - fixed - 1 - count * 6;
proven = size - fixed - read;
if (proven != 0 && proven != PROOF_SIZE)
return;
p2p_identifier_for(public_key, identifier);
@@ -1084,7 +1198,8 @@ static void join_received(struct broker *broker, const unsigned char *message, i
return;
}
if (!elapsed(joiner->answered_broker_times[broker_index], ANSWER_INTERVAL) ||
!p2p_peer_reoffered(identifier, joiner->secret, candidates, proven ? count : 0) ||
!p2p_peer_reoffered(identifier, joiner->secret, candidates, proven ? count : 0,
proven ? &webrtc : &no_webrtc) ||
(!proven && !budget_left(&signalling.unproven_answers, MAXIMUM_UNPROVEN_ANSWERS,
UNPROVEN_ANSWER_INTERVAL, 1)))
{
@@ -1171,7 +1286,7 @@ static void join_received(struct broker *broker, const unsigned char *message, i
if (signalling.used_request_count == MAXIMUM_USED_REQUESTS && !elapsed(used->time, USED_REQUEST_TIME))
return;
session_secret(base, nonce, host_nonce, secret);
if (!p2p_peer_offered(identifier, secret, candidates, count, 0))
if (!p2p_peer_offered(identifier, secret, candidates, count, &webrtc, 0))
return;
memcpy(used->request, request, sizeof(request));
used->time = p2p_now();
@@ -1197,6 +1312,7 @@ static void join_received(struct broker *broker, const unsigned char *message, i
static void accept_received(const unsigned char *message, int size)
{
struct p2p_candidate candidates[P2P_MAXIMUM_CANDIDATES];
struct p2p_webrtc webrtc;
unsigned char hash[P2P_KEY_HASH_SIZE];
unsigned char secret[P2P_SHA256_SIZE];
const unsigned char *host_public = message + 2;
@@ -1228,11 +1344,14 @@ static void accept_received(const unsigned char *message, int size)
/* and the answer is its */
if (!tag_right(signalling.join_base, "accept", message, size))
return;
count = get_candidates(message + fixed, size - TAG_SIZE - fixed, candidates);
if (count < 0)
if (get_addresses(message + fixed, size - TAG_SIZE - fixed, candidates, &count, &webrtc) !=
size - TAG_SIZE - fixed)
{
return;
}
session_secret(signalling.join_base, nonce, host_nonce, secret);
if (!p2p_peer_offered(signalling.join_host, secret, candidates, count, 1) || signalling.join_answered)
if (!p2p_peer_offered(signalling.join_host, secret, candidates, count, &webrtc, 1) ||
signalling.join_answered)
return;
memcpy(signalling.join_host_nonce, host_nonce, NONCE_SIZE);
signalling.join_answered = 1;
@@ -1526,6 +1645,19 @@ static void brokers_list(char *text, size_t size)
int comment = 0;
text[0] = 0;
#ifdef HALO_WEB
{
/* a page reaches brokers over WebSockets alone (web_net.c): the
WebSocket URLs of brokers.txt's that have them, or those the page
names (?brokers=, for tests) */
const char *page = getenv("HALO_WEB_BROKERS");
(void)name, (void)path, (void)file, (void)file_size, (void)index, (void)length, (void)comment;
snprintf(text, size, "%s", page && *page ? page :
"wss://broker.emqx.io:8084/mqtt,wss://broker.hivemq.com:8884/mqtt,wss://test.mosquitto.org:8081/mqtt");
return;
}
#endif
if (name[0] == '/' || name[0] == '\\' || (name[0] && name[1] == ':'))
snprintf(path, sizeof(path), "%s", name);
else
@@ -1594,7 +1726,8 @@ void p2p_signal_start(void)
broker->socket = -1;
broker->port = network_short(1883);
colon = strchr(broker->host, ':');
if (colon)
/* (a WebSocket URL is its own address: web_net.c) */
if (colon && strncmp(broker->host, "ws://", 5) && strncmp(broker->host, "wss://", 6))
{
broker->port = network_short((unsigned short)atoi(colon + 1));
*colon = 0;
@@ -1748,7 +1881,9 @@ void p2p_signal_join(const unsigned char *host_hash, const unsigned char *token)
derive(token, "seal", NULL, signalling.join_key);
make_topic(token, "host", signalling.join_host, signalling.join_host_topic);
make_topic(token, "joiner", p2p_identifier(), signalling.join_topic);
/* (new each time: the host makes one session of a request) */
/* (new each time: the host makes one session of a request; a browser's
connection for the last goes, unless a session took it up) */
p2p_webrtc_new_request();
posix_random_bytes(signalling.join_nonce, NONCE_SIZE);
signalling.join_nonce_time = p2p_now();
signalling.join_answered = 0;
File diff suppressed because it is too large. Load diff
+6 -2
View File
@@ -455,8 +455,8 @@ static const struct config_setting config_settings[] =
{ "debug.network_test", _config_string, "\"\"", "HALO_NETWORK_TEST", _environment_value, _platform_all,
"Automated system link sessions for testing (port/linux/game/network_test.c):\n"
"\"host:<map>\" hosts a game on that map, \"join\" joins the first game found;\n"
"empty for none." },
"\"host:<map>\" hosts a game on that map, \"join\" joins the first game found,\n"
"\"browse\" the first the server browser lists; empty for none." },
{ "debug.network_test_start", _config_real, "15.0", "HALO_NETWORK_TEST_START", _environment_value, _platform_all,
"Seconds after hosting that an automated test game starts." },
{ "debug.network_test_kill", _config_real, "0.0", "HALO_NETWORK_TEST_KILL", _environment_value, _platform_all,
@@ -478,6 +478,10 @@ static const struct config_setting config_settings[] =
_platform_all,
"The weapon network_test_pickup stands the player on: the first whose tag\n"
"name has this in it (\"sniper\", say); empty any." },
{ "debug.network_test_public", _config_boolean, "false", "HALO_NETWORK_TEST_PUBLIC", _environment_value,
_platform_all,
"The network test's host lists its game in the server browser, as Create\n"
"Game > Internet's PUBLIC does (debug.network_test \"browse\" joins it)." },
{ "debug.telnet_console", _config_boolean, "false", "HALO_TELNET_CONSOLE", _environment_set_is_true, _platform_all,
"Listen on 127.0.0.1 (port telnet_console_port) for a script console that\n"
"runs what it is sent as the game's console does, with no password; false\n"
+29
View File
@@ -142,6 +142,35 @@ int posix_upnp_forward_udp(unsigned short port, unsigned short preferred_port, p
posix_upnp_forward_udp set up; blocks */
void posix_upnp_stop_forwarding_udp(unsigned short external_port);
/* ---------- DTLS for WebRTC (internet play with browsers, p2p_webrtc.c;
posix_dtls.c, with port/third_party/mbedtls) */
/* the SHA-256 of this run's certificate (made the first time: a
self-signed ECDSA P-256 one, which WebRTC peers know by this hash) into
fingerprint (32 bytes); 0 if it cannot be made */
int posix_dtls_fingerprint(unsigned char *fingerprint);
/* a DTLS 1.2 server for one peer, which asks for the peer's certificate;
a handle, or -1 */
int posix_dtls_open(void);
void posix_dtls_close(int handle);
/* a datagram that arrived from the peer (kept until read: a few at most) */
void posix_dtls_input(int handle, const void *data, int size);
/* the handshake's progress, and then the peer's data: the size of a record
it sent (copied into buffer), 0 if none is waiting, or -1 if the connection
failed or was closed. now is a clock of milliseconds (its retransmissions'
timers) */
int posix_dtls_receive(int handle, posix_ulong now, void *buffer, int size);
/* sends a record of data, once the handshake is done: 1, or 0 if not (not
done yet, or too large) */
int posix_dtls_send(int handle, const void *data, int size);
/* the next datagram for the peer (into buffer): its size, or 0 if none */
int posix_dtls_output(int handle, void *buffer, int size);
/* once the handshake is done: 1, with the SHA-256 of the peer's
certificate in fingerprint (32 bytes); else 0 */
int posix_dtls_peer_fingerprint(int handle, unsigned char *fingerprint);
/* HMAC-SHA-1 (STUN's MESSAGE-INTEGRITY) into digest (20 bytes) */
void posix_hmac_sha1(const void *key, int key_size, const void *data, int size, unsigned char *digest);
/* ---------- the process and the desktop (internet play, p2p.c) */
/* copies the command line argument at index (0 is the program) into buffer;
+356
View File
@@ -0,0 +1,356 @@
/*
POSIX_DTLS.C
DTLS for internet play with browsers (p2p_webrtc.c): the server end of
WebRTC's DTLS 1.2, with Mbed TLS (port/third_party/mbedtls), over datagrams
the caller carries (posix.h). A browser's data channel runs over it.
Each run has one certificate, self-signed (ECDSA P-256), made the first time
it is needed: the peers of a WebRTC connection know each other by their
certificates' SHA-256 (exchanged, sealed, through internet play's
signalling), not by a certificate authority. So the peer's certificate is
asked for and its signature of the handshake checked, but not its chain:
p2p_webrtc.c compares its hash with the one signalled.
There are no cookies (HelloVerifyRequest): datagrams reach an endpoint only
from an address whose ICE check passed, with the session's password.
Built with the host's ABI, as the other posix_*.c.
*/
#include "posix.h"
#include "mbedtls/pk.h"
#include "mbedtls/psa_util.h"
#include "mbedtls/sha256.h"
#include "mbedtls/md.h"
#include "mbedtls/ssl.h"
#include "mbedtls/x509_crt.h"
#include "psa/crypto.h"
#include <stdlib.h>
#include <string.h>
enum
{
MAXIMUM_ENDPOINTS = 128,
/* datagrams kept each way */
QUEUE_LENGTH = 16,
MAXIMUM_DATAGRAM = 1500,
/* the handshake's datagrams are kept under this */
DTLS_MTU = 1200,
CERTIFICATE_SIZE = 1024,
};
struct datagram_queue
{
int count;
int first;
int sizes[QUEUE_LENGTH];
unsigned char data[QUEUE_LENGTH][MAXIMUM_DATAGRAM];
};
struct endpoint
{
int used;
int handshake_done;
int failed;
mbedtls_ssl_context ssl;
/* the clock, as of the last call (the timers'), and the timer: when it
was set, its intermediate and final delays (0: cancelled) */
unsigned long now;
unsigned long timer_start;
unsigned int timer_intermediate;
unsigned int timer_final;
struct datagram_queue input;
struct datagram_queue output;
};
static struct
{
int tried;
int ready;
mbedtls_pk_context key;
mbedtls_x509_crt certificate;
mbedtls_ssl_config config;
unsigned char fingerprint[32];
} dtls;
static struct endpoint *endpoints[MAXIMUM_ENDPOINTS];
static void queue_push(struct datagram_queue *queue, const void *data, int size)
{
int index;
if (size <= 0 || size > MAXIMUM_DATAGRAM)
return;
/* (full: the oldest goes, as a network would lose it) */
if (queue->count == QUEUE_LENGTH)
{
queue->first = (queue->first + 1) % QUEUE_LENGTH;
queue->count--;
}
index = (queue->first + queue->count) % QUEUE_LENGTH;
memcpy(queue->data[index], data, (size_t)size);
queue->sizes[index] = size;
queue->count++;
}
static int queue_pop(struct datagram_queue *queue, void *buffer, int size)
{
int length;
if (!queue->count)
return 0;
length = queue->sizes[queue->first];
if (length > size)
length = size;
memcpy(buffer, queue->data[queue->first], (size_t)length);
queue->first = (queue->first + 1) % QUEUE_LENGTH;
queue->count--;
return length;
}
/* ---------- Mbed TLS's callbacks */
static int bio_send(void *context, const unsigned char *data, size_t size)
{
struct endpoint *endpoint = context;
queue_push(&endpoint->output, data, (int)size);
return (int)size;
}
static int bio_receive(void *context, unsigned char *buffer, size_t size)
{
struct endpoint *endpoint = context;
int length = queue_pop(&endpoint->input, buffer, (int)size);
return length ? length : MBEDTLS_ERR_SSL_WANT_READ;
}
static void timer_set(void *context, uint32_t intermediate, uint32_t final)
{
struct endpoint *endpoint = context;
endpoint->timer_start = endpoint->now;
endpoint->timer_intermediate = intermediate;
endpoint->timer_final = final;
}
static int timer_get(void *context)
{
struct endpoint *endpoint = context;
unsigned int elapsed = (unsigned int)(endpoint->now - endpoint->timer_start);
if (!endpoint->timer_final)
return -1;
if (elapsed >= endpoint->timer_final)
return 2;
return elapsed >= endpoint->timer_intermediate ? 1 : 0;
}
/* the peer's certificate is known by its hash (p2p_webrtc.c), not its
chain */
static int verify_any(void *context, mbedtls_x509_crt *certificate, int depth, uint32_t *flags)
{
(void)context;
(void)certificate;
(void)depth;
*flags = 0;
return 0;
}
/* ---------- the run's certificate */
static int make_certificate(void)
{
static const unsigned char serial[] = { 0x4F, 0x70, 0x65, 0x6E, 0x43, 0x45 };
mbedtls_x509write_cert writer;
unsigned char buffer[CERTIFICATE_SIZE];
int size;
if (psa_crypto_init() != PSA_SUCCESS)
return 0;
mbedtls_pk_init(&dtls.key);
mbedtls_x509_crt_init(&dtls.certificate);
if (mbedtls_pk_setup(&dtls.key, mbedtls_pk_info_from_type(MBEDTLS_PK_ECKEY)) != 0 ||
mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1, mbedtls_pk_ec(dtls.key), mbedtls_psa_get_random,
MBEDTLS_PSA_RANDOM_STATE) != 0)
{
return 0;
}
mbedtls_x509write_crt_init(&writer);
mbedtls_x509write_crt_set_version(&writer, MBEDTLS_X509_CRT_VERSION_3);
mbedtls_x509write_crt_set_md_alg(&writer, MBEDTLS_MD_SHA256);
mbedtls_x509write_crt_set_subject_key(&writer, &dtls.key);
mbedtls_x509write_crt_set_issuer_key(&writer, &dtls.key);
size = -1;
if (mbedtls_x509write_crt_set_subject_name(&writer, "CN=OpenCE") == 0 &&
mbedtls_x509write_crt_set_issuer_name(&writer, "CN=OpenCE") == 0 &&
mbedtls_x509write_crt_set_serial_raw(&writer, (unsigned char *)serial, sizeof(serial)) == 0 &&
mbedtls_x509write_crt_set_validity(&writer, "20240101000000", "20491231235959") == 0)
{
size = mbedtls_x509write_crt_der(&writer, buffer, sizeof(buffer), mbedtls_psa_get_random,
MBEDTLS_PSA_RANDOM_STATE);
}
mbedtls_x509write_crt_free(&writer);
/* (written at the end of the buffer) */
if (size <= 0 || mbedtls_x509_crt_parse_der(&dtls.certificate, buffer + sizeof(buffer) - size,
(size_t)size) != 0)
{
return 0;
}
mbedtls_sha256(buffer + sizeof(buffer) - size, (size_t)size, dtls.fingerprint, 0);
mbedtls_ssl_config_init(&dtls.config);
if (mbedtls_ssl_config_defaults(&dtls.config, MBEDTLS_SSL_IS_SERVER, MBEDTLS_SSL_TRANSPORT_DATAGRAM,
MBEDTLS_SSL_PRESET_DEFAULT) != 0)
{
return 0;
}
/* WebRTC's: DTLS 1.2 (a browser offering 1.3 too takes it) */
mbedtls_ssl_conf_min_tls_version(&dtls.config, MBEDTLS_SSL_VERSION_TLS1_2);
mbedtls_ssl_conf_max_tls_version(&dtls.config, MBEDTLS_SSL_VERSION_TLS1_2);
mbedtls_ssl_conf_rng(&dtls.config, mbedtls_psa_get_random, MBEDTLS_PSA_RANDOM_STATE);
mbedtls_ssl_conf_authmode(&dtls.config, MBEDTLS_SSL_VERIFY_OPTIONAL);
mbedtls_ssl_conf_verify(&dtls.config, verify_any, NULL);
mbedtls_ssl_conf_dtls_cookies(&dtls.config, NULL, NULL, NULL);
return mbedtls_ssl_conf_own_cert(&dtls.config, &dtls.certificate, &dtls.key) == 0;
}
int posix_dtls_fingerprint(unsigned char *fingerprint)
{
if (!dtls.tried)
{
dtls.tried = 1;
dtls.ready = make_certificate();
}
if (dtls.ready)
memcpy(fingerprint, dtls.fingerprint, sizeof(dtls.fingerprint));
return dtls.ready;
}
/* ---------- endpoints */
int posix_dtls_open(void)
{
unsigned char unused[32];
struct endpoint *endpoint;
int handle;
if (!posix_dtls_fingerprint(unused))
return -1;
for (handle = 0; handle < MAXIMUM_ENDPOINTS && endpoints[handle]; handle++)
;
if (handle == MAXIMUM_ENDPOINTS)
return -1;
endpoint = calloc(1, sizeof(*endpoint));
if (!endpoint)
return -1;
mbedtls_ssl_init(&endpoint->ssl);
if (mbedtls_ssl_setup(&endpoint->ssl, &dtls.config) != 0)
{
mbedtls_ssl_free(&endpoint->ssl);
free(endpoint);
return -1;
}
mbedtls_ssl_set_bio(&endpoint->ssl, endpoint, bio_send, bio_receive, NULL);
mbedtls_ssl_set_timer_cb(&endpoint->ssl, endpoint, timer_set, timer_get);
mbedtls_ssl_set_mtu(&endpoint->ssl, DTLS_MTU);
endpoint->used = 1;
endpoints[handle] = endpoint;
return handle;
}
static struct endpoint *endpoint_of(int handle)
{
return handle >= 0 && handle < MAXIMUM_ENDPOINTS ? endpoints[handle] : NULL;
}
void posix_dtls_close(int handle)
{
struct endpoint *endpoint = endpoint_of(handle);
if (!endpoint)
return;
mbedtls_ssl_free(&endpoint->ssl);
free(endpoint);
endpoints[handle] = NULL;
}
void posix_dtls_input(int handle, const void *data, int size)
{
struct endpoint *endpoint = endpoint_of(handle);
if (endpoint)
queue_push(&endpoint->input, data, size);
}
int posix_dtls_receive(int handle, posix_ulong now, void *buffer, int size)
{
struct endpoint *endpoint = endpoint_of(handle);
int result;
if (!endpoint || endpoint->failed)
return -1;
endpoint->now = now;
if (!endpoint->handshake_done)
{
result = mbedtls_ssl_handshake(&endpoint->ssl);
if (result == MBEDTLS_ERR_SSL_WANT_READ || result == MBEDTLS_ERR_SSL_WANT_WRITE)
return 0;
if (result != 0)
{
endpoint->failed = 1;
return -1;
}
endpoint->handshake_done = 1;
}
result = mbedtls_ssl_read(&endpoint->ssl, buffer, (size_t)size);
if (result > 0)
return result;
if (result == MBEDTLS_ERR_SSL_WANT_READ || result == MBEDTLS_ERR_SSL_WANT_WRITE ||
result == MBEDTLS_ERR_SSL_TIMEOUT)
{
return 0;
}
/* (closed, or failed) */
endpoint->failed = 1;
return -1;
}
int posix_dtls_send(int handle, const void *data, int size)
{
struct endpoint *endpoint = endpoint_of(handle);
if (!endpoint || !endpoint->handshake_done || endpoint->failed)
return 0;
return mbedtls_ssl_write(&endpoint->ssl, data, (size_t)size) == size;
}
int posix_dtls_output(int handle, void *buffer, int size)
{
struct endpoint *endpoint = endpoint_of(handle);
return endpoint ? queue_pop(&endpoint->output, buffer, size) : 0;
}
int posix_dtls_peer_fingerprint(int handle, unsigned char *fingerprint)
{
struct endpoint *endpoint = endpoint_of(handle);
const mbedtls_x509_crt *certificate;
if (!endpoint || !endpoint->handshake_done)
return 0;
certificate = mbedtls_ssl_get_peer_cert(&endpoint->ssl);
if (!certificate)
return 0;
mbedtls_sha256(certificate->raw.p, certificate->raw.len, fingerprint, 0);
return 1;
}
void posix_hmac_sha1(const void *key, int key_size, const void *data, int size, unsigned char *digest)
{
mbedtls_md_hmac(mbedtls_md_info_from_type(MBEDTLS_MD_SHA1), key, (size_t)key_size, data, (size_t)size,
digest);
}
+9 -1
View File
@@ -936,8 +936,10 @@ static Uint64 frame_interval_ns(void)
#endif
#ifdef HALO_WEB
/* port/web/src/web_main.c's: the frames shown, which the page reads */
/* port/web/src/web_main.c's: the frames shown, which the page reads; and
web_library.js's: a line for the page (2: an invite link to offer) */
void web_frame_shown(void);
void web_js_post(int kind, const char *text);
#endif
void platform_video_swap(void)
@@ -1171,9 +1173,15 @@ static void platform_invite_clipboard(BOOL look)
if (invite)
{
#ifdef HALO_WEB
/* (a page may write the clipboard only when it is clicked: the page
shows the link, with a button that copies it) */
web_js_post(2, invite);
#else
SDL_SetClipboardText(invite);
snprintf(seen, sizeof(seen), "%s", invite);
platform_log("Internet play: the invite link is on the clipboard");
#endif
#ifdef HALO_ANDROID
SDL_ShowAndroidToast("Hosting: the invite link is on the clipboard", 1, -1, 0, 0);
#endif
+6
View File
@@ -12,6 +12,12 @@ Only `library/`, `include/` and `LICENSE` of the release are kept, without
their build files, and nothing in them is changed. The default configuration
(`include/mbedtls/mbedtls_config.h`) is used, with TLS 1.2 and 1.3.
Every native build's internet play uses it for DTLS with browsers
(`port/linux/src/posix_dtls.c`: WebRTC's DTLS 1.2 server, with a
self-signed certificate made each run): the Linux and Windows builds, and
the Android app's host library. The web build has none of it (a browser has
its own WebRTC).
The Linux build's self-updater (`port/linux/src/posix_update.c`) uses it to
download new builds over HTTPS, with the system's certificate authorities.
The Windows build uses WinHTTP instead, and the Android app Java's HTTPS.
+65 -17
View File
@@ -77,8 +77,27 @@ browser.
### Playing together
Under **Play together**, **Create a room** makes a room and its link
(`https://halocombatevolved.com/?room=<secret>`): send it to your friends.
**Over the internet**, as the Linux, Windows and Android builds play, and
with them: Multiplayer, Create Game > Internet hosts a game (PUBLIC lists it
in everyone's server browser), and Join Game > Server Browser lists the
public games, the native builds' and the browsers' alike. A game hosted here
shows its invite link at the top of the page, with a button that copies it:
the site's address with `?join=<code>`, which opens the site and joins the
game, and which a native build takes too (pasted, or on its command line);
the native builds' own `halo://join/` links work pasted into the address
after `?join=`. Network Setup's INTERNET PLAY turns it off.
The browser reaches each other machine of the game directly, over WebRTC
(its router permitting, as for the native builds: "Internet play" in
[port/linux/README.md](../linux/README.md)); a native build takes the
browser's WebRTC on its tunnel's port (port/linux/src/p2p_webrtc.c). They
find each other through the same public MQTT brokers, here over secure
WebSockets (EMQX's, HiveMQ's and Mosquitto's: the native builds' first broker
has none), with everything sealed as between native builds.
**In a room**: under **Play together**, **Create a room** makes a room and
its link (`https://halocombatevolved.com/?room=<secret>`): send it to your
friends.
Everyone who opens it is in the room, and the browsers of a room are as the
machines of one local network to the game: host a game under Multiplayer and
the others find it there, as system link games are found (up to 16
@@ -98,7 +117,8 @@ For testing, the page's address takes:
| Parameter | Effect |
| --- | --- |
| `?room=<secret>` | Joins that room (32 hex digits); `?brokers=ws://host:port[,...]` uses other MQTT brokers (WebSocket listeners), as the tests do. |
| `?room=<secret>` | Joins that room (32 hex digits); `?brokers=ws://host:port[,...]` uses other MQTT brokers (WebSocket listeners), for rooms and internet play, as the tests do. |
| `?join=<code>` | Joins that internet game once the game starts (an invite's 64 hex digits, as a `halo://join/` link ends). |
| `?set=NAME=value` | Sets a setting's environment variable (the settings table in [port/linux/README.md](../linux/README.md)); repeat it for more. `?set=HALO_GL_DEBUG=1` reports WebGL errors in the log. |
| `?menu=<screen>` | Opens a menu screen (`debug.menu_open`). |
| `?map=<level>` | Starts a level: `a10`, or `levels\test\bloodgulch\bloodgulch`. The page writes `init.txt` for it, and removes it at the next visit without `?map=`. |
@@ -114,10 +134,12 @@ reloads.
## Limits
- Network play only between browsers, in rooms ("Playing together"):
browsers have no UDP and no plain TCP, so they cannot reach the Linux,
Windows and Android builds' games, nor take part in their internet play
(invite links, the server browser). Split screen works.
- No system link with the native builds on a local network (a browser has
no UDP): they play over the internet, as invites and the server browser
find them ("Playing together"). Split screen works.
- Internet play has no relay, as on the native builds: two machines whose
networks let no direct connection through (some company and school
networks block WebRTC) cannot play together.
- No Custom Edition maps (their tag data's address is in the C heap there).
- No Bink videos (as on the other ports).
- The browser's WebGL 2 is OpenGL ES 3.0 without its 3.1 and 3.2 additions:
@@ -164,6 +186,8 @@ node --test "port/web/tests/*.test.js" # the disc image reader, the service wo
python -m pytest -q tools/test_web_build.py # the build's graph, version.json, the sockets, the menus
node port/web/tests/smoke.mjs dist/halo-web-release # the site in a headless Chromium (Playwright)
node port/web/tests/rooms.mjs dist/halo-web-release # two browsers in a room (and mosquitto)
node port/web/tests/webrtc_native.mjs chromium firefox # the native builds' WebRTC with browsers
node port/web/tests/internet.mjs build/web/site <maps folder> # internet play with the Linux build
```
The smoke test serves the site as GitHub Pages does, checks that the page
@@ -176,6 +200,17 @@ the multiplayer maps imported, one opened with
`?room=<secret>&set=HALO_NETWORK_TEST=join` (`debug.network_test`,
port/linux/NETCODE.md), play one and log every player's state each second.
The WebRTC test builds `port/linux/src/p2p_webrtc.c` (with `posix_dtls.c`
and Mbed TLS) for this computer around one connection, which a browser's
data channel opens as signalling would have it; the browser sends it
messages of every size, which come back, then again with a tenth of the
datagrams lost each way. The internet play test needs the maps (`ui.map`
and the multiplayer maps, which each browser imports) and the Linux build
(`build/linux/halo`), and plays three games through a local broker: two
browsers by invite, the native build hosting with a browser joining from the
server browser, and the reverse (`debug.network_test` `browse`,
`debug.network_test_public`).
Playwright's screenshots of the page in headless Firefox show the game's
canvas black (it is drawn by a worker); the game draws. Check Firefox headed,
on a virtual display.
@@ -238,6 +273,21 @@ multiply-adds). `tools/web_build.py` writes the graph.
reach the sockets bound to their port, and streams connect to the socket
listening on theirs. Split screen (a network game over loopback) and the
game's own start-up use them.
- **Internet play.** The native builds' (port/linux/src/p2p*.c), all of it:
the invites, the signalling and its sealing, the server browser, the
tunnel's sealed packets and KCP streams, the stand-ins the game's sockets
reach peers through. Two things differ. A broker is a WebSocket URL, which
`posix_resolve_ipv4` gives a stand-in address (198.19.0.x) and a stream
socket connected to it is the page's WebSocket (`web_library.js`), the
same MQTT bytes. And the tunnel to each peer is a WebRTC data channel
(`port/web/src/web_p2p.c`, `site/p2p.js`): the page keeps a connection
ready, whose ICE credentials, addresses (IPv4, and its own as mDNS names)
and certificate's hash a request or an answer carries, and makes up the
other end's description from what signalling told: a native build's is an
ICE-lite agent and a DTLS server, whose credentials come from the
session's secret; of two browsers, the host is the DTLS server. What the
channel receives goes to the tunnel's socket from the connection's stand-in
address (198.18.x.y).
- **Rooms.** The other browsers of a room have addresses of their own in
`10.0.0.0/8`, which `net.js` chooses (and the game is started with:
`HALO_WEB_ADDRESS`). What the game sends to one, or broadcasts, is a frame
@@ -245,14 +295,11 @@ multiply-adds). `tools/web_build.py` writes the graph.
(unordered, not resent), a stream's opening, bytes and end on one that
does not. What arrives, `web_net_receive` checks and hands to the sockets.
System link and the netcode run unchanged on top, so the network version
needs no change for it. (The plan was to carry internet play's own
invites and tunnel to browsers; that tunnel's peers exchange UDP
addresses, where WebRTC needs its own descriptions exchanged, which would
change the protocol of every build. Browser to native play is the next
step: port/web/README.md "Limits".)
needs no change for it.
- **Left out.** The self-updater (the site updates itself), the disc image
reader (the page's `xiso-worker.js` is), UPnP and Discord (no internet
play), and the page-fault memory watch.
reader (the page's `xiso-worker.js` is), STUN, UPnP and Discord (a page
reaches none: its WebRTC finds its own addresses), and the page-fault
memory watch.
- **The page and the game.** The memory is shared, so the page reads and
writes a few words of it itself (`port/web/src/web_shared.h`): the frames
shown, whose count standing still while a map loads makes the page say
@@ -315,13 +362,14 @@ a first visit downloads about 10 MB, compressed).
| `tools/web_build.py` | The `ninja web` graph |
| `port/web/halo_sdl3.py` | SDL 3.4.16 as an Emscripten port |
| `port/web/src/web_main.c` | The entry point: the page's arguments, the storage, the heap's limit |
| `port/web/src/web_net.c` | The sockets inside the page, and the room's other browsers |
| `port/web/src/web_net.c` | The sockets inside the page, the room's other browsers, and the brokers' WebSockets |
| `port/web/src/web_p2p.c` | Internet play's WebRTC connections |
| `port/web/src/web_memory_watch.c` | The memory watch by hashing |
| `port/web/src/web_gl_host.c` | The renderer's services for WebGL 2 |
| `port/web/src/web_stubs.c` | UPnP and an MSVC intrinsic |
| `port/web/src/web_shared.h`, `web_touch.c` | The words of memory the page reads and writes: frames shown, the touch controls' state |
| `port/web/src/web_library.js`, `web_pre.js` | The JavaScript halves: messages to the page, the workers' errors |
| `port/web/site/` | The page (`index.html`, `app.js`, `style.css`), the touch controls (`touch.js`), rooms (`net.js`), its service worker (`sw.js`), the importer (`xiso-worker.js`), the web app's manifest and icons |
| `port/web/src/web_library.js`, `web_pre.js` | The JavaScript halves: messages to the page, the brokers' WebSockets, the workers' errors |
| `port/web/site/` | The page (`index.html`, `app.js`, `style.css`), the touch controls (`touch.js`), rooms (`net.js`), internet play's WebRTC (`p2p.js`), its service worker (`sw.js`), the importer (`xiso-worker.js`), the web app's manifest and icons |
| `port/web/stamp_version.py`, `licenses.py` | `version.json` and `licenses.txt` |
| `port/web/tests/` | The tests above |
| `tools/web_serve.py` | A local server for the site |
+43 -6
View File
@@ -16,10 +16,13 @@ then fills the page.
- Query parameters, for testing: ?set=NAME=value (any number) passes a
setting's environment variable (port/linux/README.md, the settings
table), ?room=<secret> joins a room (net.js; ?brokers= names other
message brokers), ?menu=<screen> opens a menu screen (debug.menu_open), ?map=<level>
starts a level (a10, or levels\a10\a10: the init.txt the game reads, which
the page removes again the next time), ?play starts the game at once when
it can.
message brokers, for internet play's signalling too), ?menu=<screen> opens
a menu screen (debug.menu_open), ?map=<level> starts a level (a10, or
levels\a10\a10: the init.txt the game reads, which the page removes again
the next time), ?play starts the game at once when it can.
- ?join=<invite> (a halo://join/ link's code, which native builds and the
game here make: port/linux/src/p2p.c) joins that internet game once the
game starts.
*/
'use strict';
@@ -518,6 +521,12 @@ function gameArguments() {
if (/^[A-Z0-9_]+=/.test(value)) args.push('--' + value);
}
if (params.has('menu')) args.push('--HALO_MENU_OPEN=' + params.get('menu'));
// internet play's brokers (port/linux/src/p2p_signal.c), if not its own
const brokers = (params.get('brokers') || '').split(',').filter((url) => /^wss?:\/\/[^,\s]+$/.test(url));
if (brokers.length) args.push('--HALO_WEB_BROKERS=' + brokers.join(','));
// an invite to join (its code, or a whole link)
const invite = /([0-9a-f]{64})\s*$/i.exec(params.get('join') || '');
if (invite) args.push('halo://join/' + invite[1].toLowerCase());
return args;
}
@@ -529,6 +538,27 @@ function showFatal(text) {
if (document.exitPointerLock) document.exitPointerLock();
}
// a game hosted for the internet: its invite as this page's address with
// ?join= (which native builds take too), and a button that copies it (a
// page may write the clipboard only when it is clicked)
function showInvite(text) {
const code = /([0-9a-f]{64})/.exec(text);
if (!code) return;
$('invite-link').value = `${location.origin}${location.pathname}?join=${code[1]}`;
$('invite-copy').textContent = 'Copy';
$('invite').hidden = false;
}
$('invite-copy').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText($('invite-link').value);
$('invite-copy').textContent = 'Copied';
} catch {
$('invite-link').select();
}
});
$('invite-close').addEventListener('click', () => { $('invite').hidden = true; });
function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
@@ -556,7 +586,7 @@ async function play() {
try {
await prepareInit();
await loadScript('halo.js');
game = await window.createHalo({
const options = {
canvas,
arguments: gameArguments(),
print: (text) => { logLine(text); console.log(text); },
@@ -566,8 +596,11 @@ async function play() {
if (!$('starting').hidden && /data root:/.test(text)) $('starting-text').textContent = 'Loading the menus…';
},
haloNetSend: (address, reliable, bytes) => window.HaloNet.send(address, reliable, bytes),
// internet play's WebRTC connections (p2p.js)
haloP2P: (command, connection, bytes) => window.HaloP2P.command(command, connection, bytes),
haloMessage: (kind, text) => {
logLine(text);
if (kind === 2) showInvite(text);
if (kind === 3) showFatal(text);
},
onAbort: (what) => showFatal('The game stopped: ' + what),
@@ -575,7 +608,11 @@ async function play() {
if (status) showFatal(`The game stopped (status ${status}). The log below says why.`);
else location.reload();
},
});
};
// (the module these options become, which p2p.js calls into as soon as
// the game asks it for a connection)
window.HaloP2P.attach(options);
game = await window.createHalo(options);
// the on-screen touch controls (touch.js), and the room's frames (net.js)
window.HaloTouch.start(game, $('touch'));
window.HaloNet.attach(game);
+7
View File
@@ -102,6 +102,12 @@
<div class="spinner" aria-hidden="true"></div>
<p id="starting-text">Starting…</p>
</div>
<div id="invite" class="invite" role="status" hidden>
<span>Hosting for the internet: invite players with this link</span>
<input id="invite-link" readonly aria-label="The invite link">
<button id="invite-copy">Copy</button>
<button id="invite-close" aria-label="Hide">Hide</button>
</div>
<div class="toolbar" id="toolbar">
<button id="fullscreen" title="Fullscreen">Fullscreen</button>
<button id="leave" title="Back to the launcher (the game stops)">Quit</button>
@@ -127,6 +133,7 @@
<script src="touch.js"></script>
<script src="net.js"></script>
<script src="p2p.js"></script>
<script src="app.js"></script>
</body>
</html>
+201
View File
@@ -0,0 +1,201 @@
/*
P2P.JS
Internet play's WebRTC connections (port/web/src/web_p2p.c): the game's
tunnel to each machine of an internet game, a native build or a browser, is
a data channel of an RTCPeerConnection made here. The game asks (the
commands below) and is answered through its exports: each connection's ICE
credentials, IPv4 addresses and mDNS names (web_p2p_described), this run's
certificate's hash (web_p2p_fingerprint), and the messages that arrive
(web_p2p_receive).
Every connection makes an offer, with one data channel negotiated
beforehand (id 0, unordered, no retransmissions: the tunnel's datagrams);
the other end's answer is made up from what the game's signalling told of
it: a native build's is an ICE-lite agent and a DTLS server
(port/linux/src/p2p_webrtc.c), and of two browsers, both offering, the host
is the DTLS server (and ICE settles which end controls). Every connection
has the run's certificate, whose hash the game signals.
*/
'use strict';
window.HaloP2P = (() => {
// (port/linux/src/port_config.c's network.stun_servers, as net.js)
const ICE_SERVERS = [{ urls: ['stun:stun.l.google.com:19302', 'stun:stun.cloudflare.com:3478'] }];
const COMMAND_CREATE = 1;
const COMMAND_CONNECT = 2;
const COMMAND_CANDIDATES = 3;
const COMMAND_SEND = 4;
const COMMAND_CLOSE = 5;
// the addresses told (web_p2p.c keeps 4: P2P_MAXIMUM_CANDIDATES), and the
// mDNS names of a browser's own (2: P2P_MAXIMUM_WEBRTC_NAMES)
const MAXIMUM_ADDRESSES = 4;
const MAXIMUM_NAMES = 2;
// (a channel that holds this much unsent drops what is sent to it, as a
// full network would)
const BUFFERED_LIMIT = 1 << 20;
const encoder = new TextEncoder();
const decoder = new TextDecoder();
let game = null;
let certificate = null;
let fingerprintTold = false;
// index → { pc, dc, chain (the commands, in order), closed }
const connections = new Map();
function attach(module) {
game = module;
}
// a copy of bytes in the game's memory, for a call that does not keep it
function withBytes(bytes, call) {
const pointer = game._malloc(bytes.length || 1);
if (!pointer) return;
try {
game.HEAPU8.set(bytes, pointer);
call(pointer);
} finally {
game._free(pointer);
}
}
function withText(text, call) {
withBytes(encoder.encode(text + '\0'), call);
}
function certificateMade() {
certificate = certificate || RTCPeerConnection.generateCertificate({ name: 'ECDSA', namedCurve: 'P-256' });
return certificate;
}
// this end's credentials and addresses, from its offer as gathered so far:
// IPv4 over UDP, the public ones first; and its own addresses, which the
// browser hides behind mDNS names (another browser on the network looks
// them up; a native build cannot, and needs none: the browser reaches it)
function describe(index, entry) {
const description = entry.pc.localDescription;
if (!description || entry.closed || !game) return;
const sdp = description.sdp;
const ufrag = /^a=ice-ufrag:(\S+)/m.exec(sdp);
const password = /^a=ice-pwd:(\S+)/m.exec(sdp);
if (!ufrag || !password) return;
const addresses = [];
for (const match of sdp.matchAll(/^a=candidate:\S+ \d+ udp \d+ (\d+\.\d+\.\d+\.\d+) (\d+) typ (host|srflx)/gim)) {
addresses.push({ text: `${match[1]}:${match[2]}`, public: match[3] === 'srflx' });
}
addresses.sort((a, b) => b.public - a.public);
const unique = [...new Set(addresses.map((address) => address.text))].slice(0, MAXIMUM_ADDRESSES);
const names = [];
for (const match of sdp.matchAll(/^a=candidate:\S+ \d+ udp \d+ ([0-9a-zA-Z-]+\.local) (\d+) typ host/gim)) {
names.push(`${match[1]}:${match[2]}`);
}
if (!fingerprintTold) {
const fingerprint = /^a=fingerprint:sha-256 ([0-9A-F:]+)/im.exec(sdp);
if (fingerprint) {
const bytes = new Uint8Array(fingerprint[1].split(':').map((pair) => parseInt(pair, 16)));
if (bytes.length === 32) {
withBytes(bytes, (pointer) => game._web_p2p_fingerprint(pointer));
fingerprintTold = true;
}
}
}
withText([ufrag[1], password[1], ...unique, ...[...new Set(names)].slice(0, MAXIMUM_NAMES)].join(' '),
(pointer) => game._web_p2p_described(index, pointer));
}
async function create(index, entry) {
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS, certificates: [await certificateMade()] });
const dc = pc.createDataChannel('opence', { negotiated: true, id: 0, ordered: false, maxRetransmits: 0 });
entry.pc = pc;
entry.dc = dc;
if (entry.closed) {
pc.close();
return;
}
dc.binaryType = 'arraybuffer';
dc.onmessage = (event) => {
if (!game || entry.closed || !(event.data instanceof ArrayBuffer) || !event.data.byteLength) return;
withBytes(new Uint8Array(event.data), (pointer) => game._web_p2p_receive(index, pointer, event.data.byteLength));
};
pc.onicecandidate = () => describe(index, entry);
await pc.setLocalDescription(await pc.createOffer());
entry.mid = /^a=mid:(\S+)/m.exec(pc.localDescription.sdp)?.[1] || '0';
describe(index, entry);
}
function candidateLine(address, number) {
const [ip, port] = address.split(':');
// (each its own foundation and priority)
return `candidate:${number} 1 udp ${2130706431 - number} ${ip} ${port} typ host`;
}
// the other end, as the game was told: "lite|full passive|active ufrag
// password fingerprint address:port ... name.local:port ..."
async function connect(entry, text) {
const [kind, setup, ufrag, password, fingerprint, ...addresses] = text.split(' ');
if (!/^[0-9a-f]{64}$/.test(fingerprint || '')) return;
entry.addressCount = 0;
const lines = [
'v=0', 'o=- 1 2 IN IP4 127.0.0.1', 's=-', 't=0 0', `a=group:BUNDLE ${entry.mid}`,
...(kind === 'lite' ? ['a=ice-lite'] : []),
'm=application 9 UDP/DTLS/SCTP webrtc-datachannel', 'c=IN IP4 0.0.0.0', `a=mid:${entry.mid}`,
`a=ice-ufrag:${ufrag}`, `a=ice-pwd:${password}`,
`a=fingerprint:sha-256 ${fingerprint.toUpperCase().match(/../g).join(':')}`,
`a=setup:${setup === 'active' ? 'active' : 'passive'}`, 'a=sctp-port:5000', 'a=max-message-size:262144',
...addresses.map((address) => `a=${candidateLine(address, ++entry.addressCount)}`),
'',
];
await entry.pc.setRemoteDescription({ type: 'answer', sdp: lines.join('\r\n') });
}
async function addCandidates(entry, text) {
for (const address of text.split(' ').filter(Boolean)) {
await entry.pc.addIceCandidate({ candidate: candidateLine(address, ++entry.addressCount), sdpMid: entry.mid,
sdpMLineIndex: 0 }).catch(() => {});
}
}
function close(entry) {
entry.closed = true;
if (entry.pc) entry.pc.close();
}
// a command of the game's (web_library.js's web_js_p2p)
function command(kind, index, bytes) {
if (kind === COMMAND_SEND) {
const entry = connections.get(index);
if (entry && entry.dc && entry.dc.readyState === 'open' && entry.dc.bufferedAmount < BUFFERED_LIMIT) {
try {
entry.dc.send(bytes);
} catch {
// (closing meanwhile: lost, as a datagram may be)
}
}
return;
}
if (kind === COMMAND_CREATE) {
const old = connections.get(index);
if (old) close(old);
const entry = { closed: false };
entry.chain = create(index, entry).catch((error) => console.warn('internet play: WebRTC:', error));
connections.set(index, entry);
return;
}
const entry = connections.get(index);
if (!entry) return;
const text = bytes ? decoder.decode(bytes) : '';
if (kind === COMMAND_CLOSE) {
connections.delete(index);
entry.chain = entry.chain.then(() => close(entry));
close(entry);
} else if (kind === COMMAND_CONNECT) {
entry.chain = entry.chain.then(() => !entry.closed && connect(entry, text))
.catch((error) => console.warn('internet play: WebRTC:', error));
} else if (kind === COMMAND_CANDIDATES) {
entry.chain = entry.chain.then(() => !entry.closed && addCandidates(entry, text));
}
}
return { attach, command };
})();
+27
View File
@@ -178,6 +178,33 @@ button.danger:hover:not(:disabled) { border-color: var(--bad); color: var(--bad)
}
.toolbar:hover, .toolbar:focus-within { opacity: 1; }
.toolbar button { min-height: 32px; padding: 6px 10px; font-size: 12px; background: rgba(10, 14, 20, 0.8); }
/* a game hosted for the internet: its invite link (app.js) */
.invite {
position: absolute;
top: max(8px, env(safe-area-inset-top));
left: max(8px, env(safe-area-inset-left));
max-width: calc(100% - 200px);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-radius: 8px;
background: rgba(10, 14, 20, 0.85);
color: var(--text);
font-size: 12px;
}
.invite input {
flex: 1 1 260px;
min-width: 0;
padding: 6px 8px;
border: 1px solid var(--panel-edge);
border-radius: 6px;
background: #0a0e14;
color: var(--text);
font: 12px ui-monospace, monospace;
}
.invite button { min-height: 30px; padding: 6px 10px; font-size: 12px; }
.sheet {
position: fixed;
+66 -2
View File
@@ -4,11 +4,13 @@ WEB_LIBRARY.JS
The JavaScript half of the web runtime (tools/web_build.py --js-library).
The game runs on a Web Worker (PROXY_TO_PTHREAD); these functions run on
the page's thread, where Emscripten proxies them, and hand what they are
given to the page (port/web/site/app.js, Module.haloMessage).
given to the page (port/web/site/app.js, Module.haloMessage), or keep the
WebSockets of internet play's brokers.
*/
addToLibrary({
// kind: 0 a status, 1 a notice, 3 a fatal error (the game stops)
// kind: 0 a status, 1 a notice, 2 an invite link of a game hosted for the
// internet (to offer), 3 a fatal error (the game stops)
web_js_post__proxy: 'sync',
web_js_post: (kind, text) => {
var message = UTF8ToString(text);
@@ -27,4 +29,66 @@ addToLibrary({
_free(frame);
if (Module.haloNetSend) Module.haloNetSend(address >>> 0, reliable, bytes);
},
// internet play's brokers (port/linux/src/p2p_signal.c), MQTT over a
// WebSocket for each socket web_net.c connects to one's URL; their events
// go back by the socket's index and its connection's number
$webSockets: {},
web_js_websocket_open__proxy: 'async',
web_js_websocket_open__deps: ['$webSockets', 'malloc', 'free'],
web_js_websocket_open: (index, connection, url) => {
var socket;
var closed = () => {
if (webSockets[index] && webSockets[index].socket === socket) delete webSockets[index];
_web_net_websocket_closed(index, connection);
};
try {
socket = new WebSocket(UTF8ToString(url), ['mqtt']);
} catch (error) {
_web_net_websocket_closed(index, connection);
return;
}
socket.binaryType = 'arraybuffer';
webSockets[index] = { socket, connection };
socket.onopen = () => _web_net_websocket_opened(index, connection);
socket.onmessage = (event) => {
if (!(event.data instanceof ArrayBuffer) || !event.data.byteLength) return;
var bytes = new Uint8Array(event.data);
var copy = _malloc(bytes.length);
if (!copy) return;
HEAPU8.set(bytes, copy);
_web_net_websocket_data(index, connection, copy, bytes.length);
_free(copy);
};
socket.onclose = closed;
socket.onerror = closed;
},
web_js_websocket_send__proxy: 'async',
web_js_websocket_send__deps: ['$webSockets', 'free'],
web_js_websocket_send: (index, connection, bytes, size) => {
var copy = HEAPU8.slice(bytes, bytes + size);
var entry = webSockets[index];
_free(bytes);
if (entry && entry.connection === connection && entry.socket.readyState === 1) entry.socket.send(copy);
},
web_js_websocket_close__proxy: 'async',
web_js_websocket_close__deps: ['$webSockets'],
web_js_websocket_close: (index, connection) => {
var entry = webSockets[index];
if (!entry || entry.connection !== connection) return;
delete webSockets[index];
entry.socket.onclose = entry.socket.onerror = entry.socket.onmessage = null;
entry.socket.close();
},
// internet play's WebRTC connections (port/web/src/web_p2p.c), which the
// page makes (port/web/site/p2p.js, Module.haloP2P): a command, the
// connection, and a copy of its text or packet, freed here
web_js_p2p__proxy: 'async',
web_js_p2p__deps: ['free'],
web_js_p2p: (command, connection, data, size) => {
var bytes = data ? HEAPU8.slice(data, data + size) : null;
if (data) _free(data);
if (Module.haloP2P) Module.haloP2P(command, connection, bytes);
},
});
+7 -5
View File
@@ -20,7 +20,10 @@ game (shell_xbox.c's main, renamed halo_game_main by tools/web_build.py).
#include "web_shared.h"
int halo_game_main(void);
/* web_library.js: a line for the page (kind 0 a status, 3 a fatal error) */
/* web_net.c: the arguments, for posix_command_line_argument */
void web_net_arguments(int count, char **values);
/* web_library.js: a line for the page (kind 0 a status, 2 an invite link, 3 a
fatal error) */
void web_js_post(int kind, const char *text);
/* ---------- memory
@@ -68,7 +71,7 @@ int main(int argc, char **argv)
/* --NAME=value arguments from the page (port/web/site/app.js) become
environment variables, which come before config.toml
(port/linux/src/port_config.c) */
(port/linux/src/port_config.c); the others are the command line's */
for (index = 1; index < argc; index++)
{
const char *equals = strchr(argv[index], '=');
@@ -94,15 +97,14 @@ int main(int argc, char **argv)
set_default("HALO_DATA_ROOT", "/data");
set_default("HALO_SAVE_ROOT", "/data/save");
/* no UDP and no TCP in a browser: system link and internet play wait
for the browsers' own transport (port/web/README.md, "Limits") */
set_default("HALO_NET_ONLINE", "false");
/* the canvas fills the page, which the page's own button takes
fullscreen (a page may only do so from a click) */
set_default("HALO_DISPLAY_MODE", "windowed");
/* (Custom Edition maps' window would be the C heap's here:
port/linux/src/xbox_memory.c) */
set_default("HALO_CUSTOM_EDITION", "false");
/* (an invite link among them is joined: port/linux/src/p2p.c) */
web_net_arguments(argc, argv);
web_js_post(0, "starting");
return halo_game_main();
}
+201 -7
View File
@@ -28,8 +28,16 @@ As with Winsock, each call returns -1 on failure with the error code in
posix_socket_last_error(); blocking calls wait on one condition, which every
change signals.
Internet play (port/linux/src/p2p_signal.c) reaches its MQTT brokers over
WebSockets here: a broker's address is its wss:// URL, which
posix_resolve_ipv4 gives a stand-in address in 198.19.0.0/24, and a stream
socket connected to that address is the page's WebSocket to the URL. Its
tunnel goes over WebRTC (web_p2p.c): what arrives is handed to its socket
here (web_net_inject).
The rest of posix.h's network half, which a browser has no use for, answers
"none" here: the command line, the desktop's link handler, Discord.
"none" here: the command line's (but for what the page started the game
with), the desktop's link handler, Discord.
*/
#include <errno.h>
@@ -131,6 +139,17 @@ frame for the browser at address (REMOTE_EVERYONE: every one), on the
channel that does not lose it or the one that may; the frame is a copy
the page frees */
void web_js_net_send(unsigned int address, int reliable, unsigned char *frame, int size);
/* a WebSocket for a socket (its index, and its connection's number, which
the page's calls back name it by) to a URL; bytes for it (a copy the page
frees); and its closing */
void web_js_websocket_open(int index, unsigned int connection, const char *url);
void web_js_websocket_send(int index, unsigned int connection, unsigned char *bytes, int size);
void web_js_websocket_close(int index, unsigned int connection);
/* the brokers' URLs, by their stand-in addresses: 198.19.0.1 and on */
#define MAXIMUM_WEBSOCKET_URLS 8
#define WEBSOCKET_URL_SIZE 256
#define IS_WEBSOCKET_ADDRESS(ip) (((ip) & 0xffffffu) == (198u | 19u << 8))
/* sockaddr_in, as Winsock and BSD share it */
struct address
@@ -180,6 +199,10 @@ struct web_socket
uint32_t connection;
int opened_here;
int remote_closed;
/* a WebSocket (to remote.ip's URL), numbered by connection too, and
whether it has opened */
int is_websocket;
int websocket_open;
/* listening: the connections waiting for accept, as indices */
int pending[MAXIMUM_BACKLOG];
int pending_count, backlog;
@@ -192,6 +215,8 @@ static __thread int last_error;
static unsigned short next_ephemeral_port = FIRST_EPHEMERAL_PORT;
static uint32_t next_connection = 1;
static unsigned int local_address_value;
static char websocket_urls[MAXIMUM_WEBSOCKET_URLS][WEBSOCKET_URL_SIZE];
static int websocket_url_count;
static unsigned short swap16(unsigned short value)
{
@@ -370,6 +395,8 @@ static void socket_release(int index)
int pending;
datagrams_free(socket);
if (socket->is_websocket)
web_js_websocket_close(index, socket->connection);
if (socket->state == _socket_connected && socket->is_remote && !socket->remote_closed)
remote_stream_frame(socket, _frame_close, NULL, 0);
else if (socket->state == _socket_connected && socket->peer >= 0)
@@ -557,6 +584,28 @@ static int connect_remote(struct web_socket *socket, const struct address *to)
return succeed(0);
}
static int wait_changed(const struct timespec *deadline);
/* (under net_lock) a stream to a broker's URL: connecting until the page's
WebSocket opens, which makes it writeable (or fails, which ends it) */
static int connect_websocket(struct web_socket *socket, const struct address *to)
{
unsigned int number = to->ip >> 24;
if (number < 1 || number > (unsigned int)websocket_url_count)
return fail(WSAENETUNREACH);
if (!(socket->bytes = malloc(STREAM_CAPACITY)))
return fail(WSAENOBUFS);
socket->state = _socket_connected;
socket->is_websocket = 1;
socket->connection = next_connection++;
socket->remote = *to;
socket->remote.family = FAMILY_INET;
socket->local.ip = local_address();
web_js_websocket_open(socket_index(socket), socket->connection, websocket_urls[number - 1]);
return fail(WSAEWOULDBLOCK);
}
int posix_socket_connect(int descriptor, const void *address, int address_length)
{
struct web_socket *socket;
@@ -591,6 +640,20 @@ int posix_socket_connect(int descriptor, const void *address, int address_length
pthread_mutex_unlock(&net_lock);
return result;
}
if (IS_WEBSOCKET_ADDRESS(to.ip))
{
result = connect_websocket(socket, &to);
/* (a blocking socket waits for it to open, or fail) */
while (!socket->nonblocking && socket->state == _socket_connected && !socket->websocket_open &&
!socket->remote_closed)
{
wait_changed(NULL);
}
if (!socket->nonblocking)
result = socket->websocket_open ? succeed(0) : fail(WSAECONNREFUSED);
pthread_mutex_unlock(&net_lock);
return result;
}
listener = is_local(to.ip) ? find_listener(to.port) : -1;
if (listener < 0)
{
@@ -753,6 +816,22 @@ static int stream_send(struct web_socket *socket, const unsigned char *buffer, i
struct web_socket *peer;
unsigned int sent = 0;
if (socket->is_websocket)
{
unsigned char *copy;
if (socket->send_shut)
return fail(WSAESHUTDOWN);
if (socket->remote_closed)
return fail(WSAECONNRESET);
if (!socket->websocket_open)
return fail(WSAEWOULDBLOCK);
if (!(copy = malloc((size_t)length + 1)))
return fail(WSAENOBUFS);
memcpy(copy, buffer, (size_t)length);
web_js_websocket_send(socket_index(socket), socket->connection, copy, length);
return succeed(length);
}
if (socket->is_remote)
{
int offset;
@@ -868,7 +947,8 @@ static int readable(const struct web_socket *socket)
/* (data, or the end: the other end closed or shut down its sending) */
if (socket->byte_count > 0 || socket->receive_shut)
return 1;
return socket->is_remote ? socket->remote_closed : socket->peer < 0 || sockets[socket->peer].send_shut;
return socket->is_remote || socket->is_websocket ? socket->remote_closed :
socket->peer < 0 || sockets[socket->peer].send_shut;
}
static int receive(int descriptor, void *buffer, int length, int flags, void *address, int *address_length)
@@ -1121,6 +1201,9 @@ static int writeable(const struct web_socket *socket)
return 1;
if (socket->state != _socket_connected)
return 0;
/* (a WebSocket once it opened, or failed: as a connection's end) */
if (socket->is_websocket)
return socket->websocket_open || socket->remote_closed;
if (socket->is_remote)
return 1;
return socket->peer < 0 || sockets[socket->peer].byte_count < STREAM_CAPACITY;
@@ -1343,6 +1426,90 @@ EMSCRIPTEN_KEEPALIVE void web_net_peer_lost(unsigned int address)
pthread_mutex_unlock(&net_lock);
}
/* ---------- WebSockets, from the page (web_library.js) */
/* (under net_lock) a WebSocket socket by the page's names for it, or NULL
(closed since: the socket may be another's now) */
static struct web_socket *websocket_of(int index, unsigned int connection)
{
if (index < 0 || index >= MAXIMUM_SOCKETS || !sockets[index].is_websocket ||
sockets[index].connection != connection)
{
return NULL;
}
return &sockets[index];
}
EMSCRIPTEN_KEEPALIVE void web_net_websocket_opened(int index, unsigned int connection)
{
struct web_socket *socket;
pthread_mutex_lock(&net_lock);
socket = websocket_of(index, connection);
if (socket)
socket->websocket_open = 1;
pthread_cond_broadcast(&net_changed);
pthread_mutex_unlock(&net_lock);
}
/* bytes that arrived (a copy the page frees); more than the queue holds
ends the stream, as for another browser's */
EMSCRIPTEN_KEEPALIVE void web_net_websocket_data(int index, unsigned int connection, const unsigned char *data,
int size)
{
struct web_socket *socket;
pthread_mutex_lock(&net_lock);
socket = websocket_of(index, connection);
if (socket && size > 0 && !socket->remote_closed && !socket->receive_shut)
{
if ((unsigned int)size > STREAM_CAPACITY - socket->byte_count)
socket->remote_closed = 1;
else
{
int offset;
for (offset = 0; offset < size; offset++)
socket->bytes[(socket->read_position + socket->byte_count + offset) % STREAM_CAPACITY] = data[offset];
socket->byte_count += (unsigned int)size;
}
}
pthread_cond_broadcast(&net_changed);
pthread_mutex_unlock(&net_lock);
}
EMSCRIPTEN_KEEPALIVE void web_net_websocket_closed(int index, unsigned int connection)
{
struct web_socket *socket;
pthread_mutex_lock(&net_lock);
socket = websocket_of(index, connection);
if (socket)
socket->remote_closed = 1;
pthread_cond_broadcast(&net_changed);
pthread_mutex_unlock(&net_lock);
}
/* ---------- internet play's tunnel (web_p2p.c) */
void web_net_inject(unsigned int from_ip, unsigned short from_port, unsigned short to_port, const void *data, int size)
{
struct address source, destination;
if (size <= 0 || size > MAXIMUM_DATAGRAM || !to_port)
return;
memset(&source, 0, sizeof(source));
memset(&destination, 0, sizeof(destination));
source.family = destination.family = FAMILY_INET;
source.ip = from_ip;
source.port = from_port;
destination.ip = local_address();
destination.port = to_port;
pthread_mutex_lock(&net_lock);
deliver_local_datagram(&source, &destination, data, size);
pthread_mutex_unlock(&net_lock);
}
/* ---------- addresses */
posix_ulong posix_local_ipv4_address(void)
@@ -1352,7 +1519,20 @@ posix_ulong posix_local_ipv4_address(void)
posix_ulong posix_resolve_ipv4(const char *host)
{
/* dotted quads only: a page cannot look names up */
int index;
/* a broker's WebSocket URL: its stand-in address (connect_websocket) */
if (host && (!strncmp(host, "ws://", 5) || !strncmp(host, "wss://", 6)) && strlen(host) < WEBSOCKET_URL_SIZE)
{
pthread_mutex_lock(&net_lock);
for (index = 0; index < websocket_url_count && strcmp(websocket_urls[index], host); index++)
;
if (index == websocket_url_count && websocket_url_count < MAXIMUM_WEBSOCKET_URLS)
strcpy(websocket_urls[websocket_url_count++], host);
pthread_mutex_unlock(&net_lock);
return index < websocket_url_count ? 198u | 19u << 8 | (unsigned int)(index + 1) << 24 : 0;
}
/* otherwise dotted quads only: a page cannot look names up */
return parse_ipv4(host);
}
@@ -1374,12 +1554,26 @@ void posix_random_bytes(void *buffer, posix_ulong size)
/* ---------- the process and the desktop: none in a page */
static int argument_count;
static char **arguments;
/* the arguments the page started the game with (web_main.c): an invite
link among them is joined (p2p.c) */
void web_net_arguments(int count, char **values)
{
argument_count = count;
arguments = values;
}
int posix_command_line_argument(int index, char *buffer, posix_ulong size)
{
(void)index;
if (buffer && size)
buffer[0] = 0;
return 0;
if (!buffer || !size)
return 0;
buffer[0] = 0;
if (index < 0 || index >= argument_count || !arguments[index])
return 0;
snprintf(buffer, size, "%s", arguments[index]);
return 1;
}
posix_ulong posix_process_id(void)
+515
View File
@@ -0,0 +1,515 @@
/*
WEB_P2P.C
Internet play's WebRTC in the browser (port/linux/src/p2p_internal.h's
p2p_webrtc_*, which the native builds have in p2p_webrtc.c). A browser has
no UDP socket: its tunnel to another machine of internet play, a native
build or a browser, is a data channel of an RTCPeerConnection the page
makes (port/web/site/p2p.js). p2p.c seals and opens the tunnel's packets as
it does on UDP; here they go to the page, and what the page receives is
handed to the tunnel's socket (web_net.c's web_net_inject) as if it came
from 198.18.x.y, the connection's stand-in address.
The page keeps a connection ready (a spare: its offer made, its addresses
gathered), so that a message to another machine can carry this end's ICE
credentials and addresses at once; a request to join takes it, as does a
joiner a host made a session for, and the page makes another. Each
connection's offer is the page's; the other end's description the page
makes up from what signalling told: its credentials (a native build's come
from the session's secret, p2p_webrtc_credentials), its certificate's hash
and its addresses. A native build is an ICE-lite agent and a DTLS server; of
two browsers, the host is the DTLS server.
The page's calls come on its own thread; the p2p thread's run under
p2p_lock, and what both touch (a connection's description) under
description_lock.
*/
#include "platform.h"
#include "posix.h"
#include "p2p_internal.h"
#include <emscripten.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
enum
{
/* a peer's each, a request's and spares */
MAXIMUM_CONNECTIONS = P2P_MAXIMUM_PEERS + 4,
/* the text of a description or an answer's details */
TEXT_SIZE = 512,
/* the stand-in port of a connection's address */
STAND_IN_PORT = 5000,
};
enum
{
_connection_unused,
/* made, for the next that needs one */
_connection_spare,
/* the current request to join's */
_connection_request,
_connection_peer,
};
/* what the page is asked (web_js_p2p) */
enum
{
_command_create = 1,
_command_connect,
_command_candidates,
_command_send,
_command_close,
};
struct connection
{
int role;
int peer;
char peer_name[2 * P2P_IDENTIFIER_SIZE + 1];
int is_host;
/* this end's, as the page described it (under description_lock) */
int described;
char ufrag[P2P_ICE_UFRAG_SIZE];
char password[P2P_ICE_PASSWORD_SIZE];
struct p2p_candidate candidates[P2P_MAXIMUM_CANDIDATES];
int candidate_count;
/* (its own addresses, as mDNS names) */
int name_count;
char names[P2P_MAXIMUM_WEBRTC_NAMES][P2P_WEBRTC_NAME_SIZE];
unsigned short name_ports[P2P_MAXIMUM_WEBRTC_NAMES];
/* the other end's, and what the page was told of it */
struct p2p_webrtc remote;
struct p2p_candidate remote_candidates[P2P_MAXIMUM_CANDIDATES];
int remote_candidate_count;
int connect_sent;
int candidates_sent;
};
/* web_library.js's: what the page is asked to do with a connection, and a
copy of the text or the packet it is given (freed by the page) */
void web_js_p2p(int command, int connection, unsigned char *data, int size);
/* web_net.c's */
void web_net_inject(unsigned int from_ip, unsigned short from_port, unsigned short to_port, const void *data,
int size);
static struct connection connections[MAXIMUM_CONNECTIONS];
static pthread_mutex_t description_lock = PTHREAD_MUTEX_INITIALIZER;
static unsigned char fingerprint[P2P_FINGERPRINT_SIZE];
static volatile int has_fingerprint;
static int started;
static unsigned short network_short(unsigned short value)
{
return (unsigned short)(value << 8 | value >> 8);
}
static void post(int command, int connection, const void *data, int size)
{
unsigned char *copy = NULL;
if (size > 0)
{
copy = malloc((size_t)size + 1);
if (!copy)
return;
memcpy(copy, data, (size_t)size);
copy[size] = 0;
}
web_js_p2p(command, connection, copy, size > 0 ? size : 0);
}
static void address_text(const struct p2p_candidate *candidate, char *text, int size)
{
unsigned char bytes[4];
memcpy(bytes, &candidate->address, 4);
snprintf(text, (size_t)size, " %u.%u.%u.%u:%u", bytes[0], bytes[1], bytes[2], bytes[3],
network_short(candidate->port));
}
/* ---------- connections */
static int connection_new(int role)
{
int index;
for (index = 0; index < MAXIMUM_CONNECTIONS; index++)
{
if (connections[index].role == _connection_unused)
{
pthread_mutex_lock(&description_lock);
memset(&connections[index], 0, sizeof(connections[index]));
connections[index].role = role;
connections[index].peer = -1;
pthread_mutex_unlock(&description_lock);
post(_command_create, index, NULL, 0);
return index;
}
}
return -1;
}
static void connection_free(int index)
{
post(_command_close, index, NULL, 0);
pthread_mutex_lock(&description_lock);
memset(&connections[index], 0, sizeof(connections[index]));
pthread_mutex_unlock(&description_lock);
}
/* a spare is kept made */
static void keep_spare(void)
{
int index;
started = 1;
for (index = 0; index < MAXIMUM_CONNECTIONS; index++)
{
if (connections[index].role == _connection_spare)
return;
}
connection_new(_connection_spare);
}
/* the spare (described, if one is), taken for role; or a new one */
static int take_spare(int role)
{
int found = -1;
int index;
for (index = 0; index < MAXIMUM_CONNECTIONS; index++)
{
if (connections[index].role == _connection_spare && (found < 0 || connections[index].described))
found = index;
}
if (found >= 0)
connections[found].role = role;
else
found = connection_new(role);
keep_spare();
return found;
}
static int find_role(int role)
{
int index;
for (index = 0; index < MAXIMUM_CONNECTIONS; index++)
{
if (connections[index].role == role)
return index;
}
return -1;
}
/* (under description_lock) this end's credentials and addresses */
static int copy_description(const struct connection *connection, struct p2p_webrtc *local,
struct p2p_candidate *candidates, int maximum_count)
{
int count = connection->candidate_count < maximum_count ? connection->candidate_count : maximum_count;
if (!connection->described)
return 0;
strcpy(local->ufrag, connection->ufrag);
strcpy(local->password, connection->password);
local->name_count = connection->name_count;
memcpy(local->names, connection->names, sizeof(local->names));
memcpy(local->name_ports, connection->name_ports, sizeof(local->name_ports));
memcpy(candidates, connection->candidates, sizeof(*candidates) * (size_t)count);
return count;
}
/* the page is told the other end, once it has its credentials; and its
addresses that came since */
static void tell_page(int index)
{
struct connection *connection = &connections[index];
char text[TEXT_SIZE];
int length;
int candidate;
if (!connection->remote.ufrag[0])
return;
if (!connection->connect_sent)
{
char hex[2 * P2P_FINGERPRINT_SIZE + 1];
p2p_hex(connection->remote.fingerprint, P2P_FINGERPRINT_SIZE, hex);
/* a native build: ICE-lite, the DTLS server; of two browsers, the
host is the DTLS server */
length = snprintf(text, sizeof(text), "%s %s %s %s %s",
connection->remote.kind == _p2p_webrtc_native ? "lite" : "full",
connection->remote.kind == _p2p_webrtc_native || connection->is_host ? "passive" : "active",
connection->remote.ufrag, connection->remote.password, hex);
for (candidate = 0; candidate < connection->remote_candidate_count; candidate++)
{
address_text(&connection->remote_candidates[candidate], text + length, (int)sizeof(text) - length);
length += (int)strlen(text + length);
}
/* (a browser's own, by name: one on the same network finds it) */
for (candidate = 0; candidate < connection->remote.name_count; candidate++)
{
snprintf(text + length, sizeof(text) - (size_t)length, " %s:%u", connection->remote.names[candidate],
network_short(connection->remote.name_ports[candidate]));
length += (int)strlen(text + length);
}
post(_command_connect, index, text, length);
connection->connect_sent = 1;
connection->candidates_sent = connection->remote_candidate_count;
return;
}
if (connection->candidates_sent == connection->remote_candidate_count)
return;
length = 0;
text[0] = 0;
for (candidate = connection->candidates_sent; candidate < connection->remote_candidate_count; candidate++)
{
address_text(&connection->remote_candidates[candidate], text + length, (int)sizeof(text) - length);
length += (int)strlen(text + length);
}
post(_command_candidates, index, text, length);
connection->candidates_sent = connection->remote_candidate_count;
}
/* ---------- p2p.c's side (p2p_internal.h) */
int p2p_webrtc_ready(void)
{
int index;
if (!started)
keep_spare();
if (!has_fingerprint)
return 0;
for (index = 0; index < MAXIMUM_CONNECTIONS; index++)
{
if (connections[index].role == _connection_spare && connections[index].described)
return 1;
}
return 0;
}
int p2p_webrtc_describe(const unsigned char *identifier, int proven, struct p2p_webrtc *local,
struct p2p_candidate *candidates, int maximum_count)
{
char name[2 * P2P_IDENTIFIER_SIZE + 1];
int found = -1;
int count = 0;
int index;
memset(local, 0, sizeof(*local));
if (!has_fingerprint)
return 0;
local->kind = _p2p_webrtc_browser;
memcpy(local->fingerprint, fingerprint, P2P_FINGERPRINT_SIZE);
p2p_hex(identifier, P2P_IDENTIFIER_SIZE, name);
for (index = 0; index < MAXIMUM_CONNECTIONS && found < 0; index++)
{
if (connections[index].role == _connection_peer && !strcmp(connections[index].peer_name, name))
found = index;
}
/* (no session yet: a request's own; a host answers a request not
proven yet with none, as it makes no connection for one) */
if (found < 0 && proven)
{
found = find_role(_connection_request);
if (found < 0)
found = take_spare(_connection_request);
}
if (found < 0)
return 0;
pthread_mutex_lock(&description_lock);
count = copy_description(&connections[found], local, candidates, maximum_count);
pthread_mutex_unlock(&description_lock);
return count;
}
void p2p_webrtc_new_request(void)
{
int index = find_role(_connection_request);
if (index >= 0)
connection_free(index);
}
int p2p_webrtc_offered(int index, int peer, const unsigned char *secret, const struct p2p_webrtc *remote,
const struct p2p_candidate *candidates, int count, int is_host)
{
struct connection *connection;
int candidate;
if (index < 0)
{
/* (a machine that has no WebRTC cannot be reached from a page) */
if (remote->kind == _p2p_webrtc_none)
return -1;
/* the host's: the request's connection, which its credentials went
with; a joiner's: the spare, whose went with the answer */
index = is_host ? find_role(_connection_request) : -1;
if (index >= 0)
connections[index].role = _connection_peer;
else
index = take_spare(_connection_peer);
if (index < 0)
return -1;
connection = &connections[index];
connection->peer = peer;
connection->is_host = is_host;
p2p_peer_name(peer, connection->peer_name);
connection->remote.kind = remote->kind;
memcpy(connection->remote.fingerprint, remote->fingerprint, P2P_FINGERPRINT_SIZE);
if (remote->kind == _p2p_webrtc_native)
p2p_webrtc_credentials(secret, connection->remote.ufrag, connection->remote.password);
}
connection = &connections[index];
/* (a browser host's credentials come once the joiner proved its
request) */
if (!connection->remote.ufrag[0] && remote->ufrag[0] && remote->kind == _p2p_webrtc_browser)
{
strcpy(connection->remote.ufrag, remote->ufrag);
strcpy(connection->remote.password, remote->password);
connection->remote.name_count = remote->name_count;
memcpy(connection->remote.names, remote->names, sizeof(remote->names));
memcpy(connection->remote.name_ports, remote->name_ports, sizeof(remote->name_ports));
}
for (candidate = 0; candidate < count; candidate++)
{
int known;
for (known = 0; known < connection->remote_candidate_count; known++)
{
if (connection->remote_candidates[known].address == candidates[candidate].address &&
connection->remote_candidates[known].port == candidates[candidate].port)
break;
}
if (known == connection->remote_candidate_count && connection->remote_candidate_count < P2P_MAXIMUM_CANDIDATES)
connection->remote_candidates[connection->remote_candidate_count++] = candidates[candidate];
}
tell_page(index);
return index;
}
void p2p_webrtc_close(int index)
{
if (index >= 0 && index < MAXIMUM_CONNECTIONS && connections[index].role != _connection_unused)
connection_free(index);
}
void p2p_webrtc_send(int index, const void *packet, int size)
{
if (index >= 0 && index < MAXIMUM_CONNECTIONS && connections[index].role == _connection_peer)
post(_command_send, index, packet, size);
}
int p2p_webrtc_received(const unsigned char *packet, int size, unsigned long address, unsigned short port)
{
(void)packet;
(void)size;
(void)address;
(void)port;
return 0;
}
int p2p_webrtc_update(void)
{
return 0;
}
/* ---------- the page's (port/web/site/p2p.js), on its thread */
/* this run's certificate's SHA-256 (32 bytes), which every connection has */
EMSCRIPTEN_KEEPALIVE void web_p2p_fingerprint(const unsigned char *bytes)
{
memcpy(fingerprint, bytes, P2P_FINGERPRINT_SIZE);
has_fingerprint = 1;
}
/* whether text is ICE's (letters, digits, "+" and "/"), of a length from
minimum to maximum */
static int ice_text(const char *text, int minimum, int maximum)
{
int length = (int)strlen(text);
return length >= minimum && length <= maximum && (int)strspn(text,
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/") == length;
}
/* a connection's credentials and IPv4 addresses, as its offer and gathering
gave them, and its own addresses' mDNS names: "ufrag password address:port
... name.local:port ..." */
EMSCRIPTEN_KEEPALIVE void web_p2p_described(int index, const char *text)
{
char ufrag[P2P_ICE_UFRAG_SIZE], password[P2P_ICE_PASSWORD_SIZE];
struct p2p_candidate candidates[P2P_MAXIMUM_CANDIDATES];
char names[P2P_MAXIMUM_WEBRTC_NAMES][P2P_WEBRTC_NAME_SIZE];
unsigned short name_ports[P2P_MAXIMUM_WEBRTC_NAMES];
int count = 0;
int name_count = 0;
int offset = 0;
if (index < 0 || index >= MAXIMUM_CONNECTIONS || !text ||
sscanf(text, "%32s %64s%n", ufrag, password, &offset) != 2 || !ice_text(ufrag, 4, P2P_ICE_UFRAG_SIZE - 1) ||
!ice_text(password, 22, P2P_ICE_PASSWORD_SIZE - 1))
{
return;
}
for (;;)
{
unsigned int a, b, c, d, port;
char name[P2P_WEBRTC_NAME_SIZE];
int used = 0;
if (sscanf(text + offset, " %u.%u.%u.%u:%u%n", &a, &b, &c, &d, &port, &used) == 5)
{
offset += used;
if (a > 255 || b > 255 || c > 255 || d > 255 || !port || port > 65535 || count == P2P_MAXIMUM_CANDIDATES)
continue;
candidates[count].address = a | b << 8 | c << 16 | d << 24;
candidates[count].port = network_short((unsigned short)port);
count++;
}
else if (sscanf(text + offset, " %63[a-zA-Z0-9.-]:%u%n", name, &port, &used) == 2)
{
int length = (int)strlen(name);
offset += used;
if (length <= 6 || strcmp(name + length - 6, ".local") || strchr(name, '.') != name + length - 6 ||
!port || port > 65535 || name_count == P2P_MAXIMUM_WEBRTC_NAMES)
{
continue;
}
strcpy(names[name_count], name);
name_ports[name_count++] = network_short((unsigned short)port);
}
else
break;
}
pthread_mutex_lock(&description_lock);
if (connections[index].role != _connection_unused)
{
strcpy(connections[index].ufrag, ufrag);
strcpy(connections[index].password, password);
memcpy(connections[index].candidates, candidates, sizeof(candidates));
connections[index].candidate_count = count;
memcpy(connections[index].names, names, sizeof(names));
memcpy(connections[index].name_ports, name_ports, sizeof(name_ports));
connections[index].name_count = name_count;
connections[index].described = 1;
}
pthread_mutex_unlock(&description_lock);
}
/* a message the connection's data channel received (a copy the page frees):
to the tunnel's socket, from the connection's stand-in address */
EMSCRIPTEN_KEEPALIVE void web_p2p_receive(int index, const unsigned char *data, int size)
{
unsigned int stand_in = 198u | 18u << 8 | (unsigned int)(index >> 8 & 255) << 16 | (unsigned int)(index & 255) << 24;
if (index < 0 || index >= MAXIMUM_CONNECTIONS || !data || size <= 0)
return;
web_net_inject(stand_in, network_short(STAND_IN_PORT), p2p_tunnel_local_port(), data, size);
}
+173
View File
@@ -0,0 +1,173 @@
// Internet play between browsers and the native builds, each game played
// on its own (debug.network_test):
//
// node port/web/tests/internet.mjs <site> <maps folder> [<halo>]
//
// (with Playwright where Node finds it, mosquitto and xvfb-run on the PATH;
// the maps folder holds ui.map and the multiplayer maps, which each
// browser's profile imports, about 300 MB each). It starts a local broker
// with a WebSocket listener for the browsers and a plain one for the native
// build (<halo>, build/linux/halo by default), serves the site, and plays:
//
// - two browsers, one hosting and the other joining by its invite (?join=);
// - the native build hosting a public game, which a browser finds in the
// server browser and joins (debug.network_test "browse");
// - a browser hosting a public game, which the native build joins the same
// way.
//
// Each must reach the other over WebRTC (the native build's ICE-lite end and
// DTLS server, port/linux/src/p2p_webrtc.c) and play: both log the two
// players. Run it with no copy of the game of your own open: the native
// build's system link would find it.
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
const require = createRequire(import.meta.url);
const { chromium } = require('playwright');
const here = path.dirname(new URL(import.meta.url).pathname);
const root = path.resolve(here, '../../..');
const site = path.resolve(process.argv[2] || 'build/web/site');
const maps = path.resolve(process.argv[3] || 'assets/maps');
const halo = path.resolve(process.argv[4] || 'build/linux/halo');
const PLAY_SECONDS = 45;
const sitePort = 8000 + Math.floor(Math.random() * 1000);
const tcpPort = 18000 + Math.floor(Math.random() * 1000);
const wsPort = tcpPort + 1000;
const work = mkdtempSync(path.join(tmpdir(), 'opence-internet-'));
writeFileSync(path.join(work, 'mosquitto.conf'),
`listener ${tcpPort} 127.0.0.1\nprotocol mqtt\nlistener ${wsPort} 127.0.0.1\nprotocol websockets\nallow_anonymous true\n`);
writeFileSync(path.join(work, 'brokers.txt'), `127.0.0.1:${tcpPort}\n`);
const broker = spawn('mosquitto', ['-c', path.join(work, 'mosquitto.conf')], { stdio: 'ignore' });
const server = spawn('python3', [path.join(root, 'tools/web_serve.py'), site, '--port', String(sitePort)],
{ stdio: 'ignore', env: { ...process.env, WEB_SERVE_QUIET: '1' } });
const pageUrl = `http://localhost:${sitePort}/`;
const settings = `set=HALO_NET_JOIN_FROM_CLIPBOARD=0&brokers=ws://127.0.0.1:${wsPort}`;
function fail(message) {
console.error('FAILED: ' + message);
process.exitCode = 1;
}
// a side of a game: its log's lines, and the invite it hosts with
function side(name) {
const result = { name, lines: [], invite: null };
result.add = (text) => {
for (const line of String(text).split('\n')) {
if (!line) continue;
result.lines.push(line);
const invite = /halo:\/\/join\/([0-9a-f]{64})/.exec(line);
if (invite && !result.invite) result.invite = invite[1];
if (/Internet play|network test: (joining|browsing|hosting)/.test(line)) console.log(` ${name} | ${line.slice(0, 160)}`);
}
};
// both players, once the game runs
result.playing = () => result.lines.some((line) => /network test: tick .*player 0: .*player 1: /.test(line));
return result;
}
async function browser(name) {
const context = await chromium.launchPersistentContext(path.join(work, 'profile-' + name), {
headless: true, viewport: { width: 800, height: 450 },
});
const page = context.pages()[0] || await context.newPage();
await page.goto(pageUrl);
await page.waitForFunction(() => document.querySelectorAll('#checks li').length >= 5, null, { timeout: 60000 });
await page.setInputFiles('#folder-input', maps);
await page.waitForFunction(() => /Copied|cannot/.test(document.querySelector('#import-text').textContent), null,
{ timeout: 600000 });
const log = side(name);
page.on('console', (message) => log.add(message.text()));
return { context, page, log };
}
async function play(game, query) {
await game.page.goto(`${pageUrl}?${query}&${settings}`);
await game.page.waitForFunction(() => !document.querySelector('#play').disabled, null, { timeout: 60000 });
await game.page.click('#play');
}
function native(name, test, extra = {}) {
const log = side(name);
const env = { ...process.env, HALO_DATA_ROOT: path.dirname(maps), HALO_NETWORK_TEST: test, HALO_NULL_RENDERER: '1',
HALO_HIDDEN_WINDOW: '1', SDL_VIDEO_DRIVER: 'x11', HALO_NET_BROKERS_FILE: path.join(work, 'brokers.txt'),
HALO_NET_JOIN_FROM_CLIPBOARD: '0', HALO_NET_ALLOW_UPNP: 'false', HALO_DISCORD_APPLICATION: '',
HALO_EXIT_AFTER: '600', ...extra };
delete env.WAYLAND_DISPLAY;
const child = spawn('xvfb-run', ['-a', '-s', '-screen 0 640x480x24', halo], { cwd: path.dirname(halo), env, detached: true });
child.stdout.on('data', (data) => log.add(data));
child.stderr.on('data', (data) => log.add(data));
log.stop = () => {
try {
process.kill(-child.pid, 'SIGKILL');
} catch {
// (ended already)
}
};
return log;
}
async function until(test, seconds) {
for (let waited = 0; waited < seconds && !test(); waited++) await sleep(1000);
return test();
}
const opened = [];
try {
await sleep(1500);
const first = await browser('first');
const second = await browser('second');
opened.push(first, second);
console.log('two browsers, by invite');
await play(first, 'set=HALO_NETWORK_TEST=host:bloodgulch');
if (!await until(() => first.log.invite, 120)) fail('the browser hosting made no invite');
else {
await play(second, `join=${first.log.invite}&set=HALO_NETWORK_TEST=join`);
if (!await until(() => first.log.playing() && second.log.playing(), 120 + PLAY_SECONDS)) {
fail('the two browsers did not play together');
}
}
console.log('the native build hosting, a browser joining from the server browser');
first.log.lines.length = 0;
await first.page.goto('about:blank');
await second.page.goto('about:blank');
const host = native('native', 'host:bloodgulch', { HALO_NETWORK_TEST_PUBLIC: 'true' });
second.log.lines.length = 0;
if (!await until(() => host.invite, 120)) fail('the native build hosting made no invite');
else {
await play(second, 'set=HALO_NETWORK_TEST=browse');
if (!await until(() => host.playing() && second.log.playing(), 120 + PLAY_SECONDS)) {
fail('the native build and the browser did not play together');
}
if (!host.lines.some((line) => /WebRTC connected/.test(line))) fail('the native build had no WebRTC connection');
}
host.stop();
await second.page.goto('about:blank');
console.log('a browser hosting, the native build joining from the server browser');
first.log.lines.length = 0;
await play(first, 'set=HALO_NETWORK_TEST=host:bloodgulch&set=HALO_NETWORK_TEST_PUBLIC=true');
if (!await until(() => first.log.invite, 120)) fail('the browser hosting made no invite');
else {
const joiner = native('native', 'browse');
if (!await until(() => joiner.playing() && first.log.playing(), 120 + PLAY_SECONDS)) {
fail('the browser and the native build did not play together');
}
joiner.stop();
}
if (!process.exitCode) console.log('internet play: ok');
} catch (error) {
fail(String(error && error.stack ? error.stack : error));
} finally {
for (const game of opened) await game.context.close().catch(() => {});
server.kill();
broker.kill();
rmSync(work, { recursive: true, force: true });
}
+113 -1
View File
@@ -8,7 +8,9 @@ by broadcast; a stream connected, accepted, written and read, and its end;
select; and the errors Winsock gives. Then the other browsers of a room:
the frames the page would carry to them (caught here in place of the page's
web_js_net_send), and theirs handed in as the page hands them, malformed ones
among them.
among them. Then internet play's: a broker's WebSocket (its URL's stand-in
address, the page's WebSocket opening, bytes each way, its end), a tunnel
packet the page's WebRTC hands in, and the command line's arguments.
*/
#include <assert.h>
@@ -39,6 +41,11 @@ struct address
void web_net_receive(unsigned int from, int reliable, const unsigned char *frame, int size);
void web_net_peer_lost(unsigned int address);
void web_net_websocket_opened(int index, unsigned int connection);
void web_net_websocket_data(int index, unsigned int connection, const unsigned char *data, int size);
void web_net_websocket_closed(int index, unsigned int connection);
void web_net_inject(unsigned int from_ip, unsigned short from_port, unsigned short to_port, const void *data, int size);
void web_net_arguments(int count, char **values);
#define MAXIMUM_SENT 64
@@ -61,6 +68,40 @@ void web_js_net_send(unsigned int address, int reliable, unsigned char *frame, i
sent_count++;
}
/* the page's WebSocket, as web_net.c asked for it */
static struct
{
int index;
unsigned int connection;
char url[256];
int opened;
int closed;
unsigned char bytes[64];
int size;
} websocket;
void web_js_websocket_open(int index, unsigned int connection, const char *url)
{
websocket.index = index;
websocket.connection = connection;
snprintf(websocket.url, sizeof(websocket.url), "%s", url);
websocket.opened++;
}
void web_js_websocket_send(int index, unsigned int connection, unsigned char *bytes, int size)
{
assert(index == websocket.index && connection == websocket.connection && size <= (int)sizeof(websocket.bytes));
memcpy(websocket.bytes, bytes, (size_t)size);
websocket.size = size;
free(bytes);
}
void web_js_websocket_close(int index, unsigned int connection)
{
assert(index == websocket.index && connection == websocket.connection);
websocket.closed++;
}
static void sent_clear(void)
{
while (sent_count)
@@ -321,6 +362,76 @@ static void test_room_streams(void)
sent_clear();
}
static void test_internet_play(void)
{
unsigned int first = posix_resolve_ipv4("wss://broker.example:8084/mqtt");
unsigned int second = posix_resolve_ipv4("ws://127.0.0.1:18831");
struct address to, from, bound;
unsigned char buffer[16];
char argument[64];
char *arguments[] = { "halo", "--HALO_X=1", "halo://join/00" };
int read[1], write[1], read_count, write_count, error_count = 0;
int length = sizeof(from);
int socket, datagram;
/* a broker's URL has a stand-in address of its own, the same each time */
assert((first & 0xffffff) == (198u | 19u << 8) && first >> 24 == 1);
assert(second >> 24 == 2 && posix_resolve_ipv4("wss://broker.example:8084/mqtt") == first);
assert(posix_resolve_ipv4("10.0.0.7") == (10u | 7u << 24) && posix_resolve_ipv4("example.com") == 0);
/* connecting opens the page's WebSocket; writeable once it opened */
socket = posix_socket(AF_INET_VALUE, SOCK_STREAM_VALUE, 0);
posix_socket_set_nonblocking(socket, 1);
to = address_of(198, 19, 0, 1, 8084);
assert(posix_socket_connect(socket, &to, sizeof(to)) == -1 && posix_socket_last_error() == WSAEWOULDBLOCK);
assert(websocket.opened == 1 && !strcmp(websocket.url, "wss://broker.example:8084/mqtt"));
write[0] = socket;
write_count = 1;
read_count = 0;
assert(posix_socket_select(read, &read_count, write, &write_count, NULL, &error_count, 0, 0, 0) == 0);
assert(posix_socket_send(socket, "x", 1, 0) == -1 && posix_socket_last_error() == WSAEWOULDBLOCK);
web_net_websocket_opened(websocket.index, websocket.connection);
write_count = 1;
assert(posix_socket_select(read, &read_count, write, &write_count, NULL, &error_count, 0, 0, 0) == 1);
assert(posix_socket_send(socket, "\x10\x02", 2, 0) == 2 && websocket.size == 2 && websocket.bytes[0] == 0x10);
/* what arrives is read as a stream's bytes; another connection's, never */
web_net_websocket_data(websocket.index, websocket.connection + 1, (const unsigned char *)"no", 2);
web_net_websocket_data(websocket.index, websocket.connection, (const unsigned char *)"\x20\x02\x00\x00", 4);
assert(posix_socket_recv(socket, buffer, sizeof(buffer), 0) == 4 && buffer[0] == 0x20);
assert(posix_socket_recv(socket, buffer, sizeof(buffer), 0) == -1 && posix_socket_last_error() == WSAEWOULDBLOCK);
/* its end: read as the stream's */
web_net_websocket_closed(websocket.index, websocket.connection);
assert(posix_socket_recv(socket, buffer, sizeof(buffer), 0) == 0);
posix_socket_close(socket);
assert(websocket.closed == 1);
/* a URL never resolved is no broker's */
socket = posix_socket(AF_INET_VALUE, SOCK_STREAM_VALUE, 0);
posix_socket_set_nonblocking(socket, 1);
to = address_of(198, 19, 0, 9, 1);
assert(posix_socket_connect(socket, &to, sizeof(to)) == -1 && posix_socket_last_error() != WSAEWOULDBLOCK);
posix_socket_close(socket);
/* a tunnel packet from a WebRTC connection: to the socket of its port,
from the connection's stand-in address */
datagram = posix_socket(AF_INET_VALUE, SOCK_DGRAM_VALUE, 0);
bound = address_of(0, 0, 0, 0, 0);
assert(posix_socket_bind(datagram, &bound, sizeof(bound)) == 0);
assert(posix_socket_getsockname(datagram, &bound, &length) == 0);
web_net_inject(198u | 18u << 8 | 3u << 24, 0x8813, bound.port, "tunnel", 6);
length = sizeof(from);
assert(posix_socket_recvfrom(datagram, buffer, sizeof(buffer), 0, &from, &length) == 6);
assert(!memcmp(buffer, "tunnel", 6) && from.ip == (198u | 18u << 8 | 3u << 24) && from.port == 0x8813);
web_net_inject(1, 1, bound.port, "x", 0);
posix_socket_set_nonblocking(datagram, 1);
assert(posix_socket_recvfrom(datagram, buffer, sizeof(buffer), 0, &from, &length) == -1);
posix_socket_close(datagram);
/* the page's arguments (an invite link among them) */
web_net_arguments(3, arguments);
assert(posix_command_line_argument(2, argument, sizeof(argument)) && !strcmp(argument, "halo://join/00"));
assert(!posix_command_line_argument(3, argument, sizeof(argument)) && !argument[0]);
}
int main(void)
{
/* (the address the page gives the machine in a room, read once) */
@@ -330,6 +441,7 @@ int main(void)
sent_clear();
test_room_datagrams();
test_room_streams();
test_internet_play();
printf("web_net: ok\n");
return 0;
}
+238
View File
@@ -0,0 +1,238 @@
/*
WEBRTC_NATIVE.C
A native build's WebRTC (port/linux/src/p2p_webrtc.c, with posix_dtls.c and
Mbed TLS), built for this computer around one connection to a browser:
port/web/tests/webrtc_native.mjs runs it with a browser's data channel, as
internet play's signalling would tell each about the other, and the browser
sends it messages of every size, which it sends back.
It prints, on its standard output, what the browser is told: its address,
the tunnel's port, its ICE credentials (from the session's secret, the first
argument in hexadecimal) and its certificate's hash; and every second the
messages received. On its standard input it takes the browser's
certificate's hash ("browser <hex>"), which opens the connection, and
"quit".
HARNESS_LOSS=<percent> loses that share of the datagrams each way.
*/
#include "posix.h"
#include "p2p_internal.h"
#include <arpa/inet.h>
#include <netinet/in.h>
#include <poll.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/random.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>
static int udp = -1;
static unsigned short tunnel_port;
static in_addr_t local_address;
static int connection = -1;
static long received_count, received_bytes;
static int loss;
/* ---------- what p2p_webrtc.c calls (p2p.c's, the platform layer's) */
void posix_random_bytes(void *buffer, posix_ulong size)
{
if (getrandom(buffer, size, 0) != (ssize_t)size)
abort();
}
unsigned long p2p_now(void)
{
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return (unsigned long)(now.tv_sec * 1000 + now.tv_nsec / 1000000);
}
void p2p_hex(const unsigned char *bytes, int size, char *text)
{
static const char digits[] = "0123456789abcdef";
int index;
for (index = 0; index < size; index++)
{
text[index * 2] = digits[bytes[index] >> 4];
text[index * 2 + 1] = digits[bytes[index] & 15];
}
text[size * 2] = 0;
}
int p2p_local_candidates(struct p2p_candidate *candidates, int maximum_count)
{
(void)maximum_count;
candidates[0].address = local_address;
candidates[0].port = tunnel_port;
return 1;
}
void p2p_peer_name(int peer, char *name)
{
(void)peer;
strcpy(name, "browser");
}
void platform_log(const char *format, ...)
{
va_list arguments;
va_start(arguments, format);
vfprintf(stderr, format, arguments);
va_end(arguments);
fputc('\n', stderr);
}
static int lost(void)
{
unsigned int value;
posix_random_bytes(&value, sizeof(value));
return (int)(value % 100) < loss;
}
void p2p_tunnel_send(unsigned long address, unsigned short port, const void *data, int size)
{
struct sockaddr_in to;
if (lost())
return;
memset(&to, 0, sizeof(to));
to.sin_family = AF_INET;
to.sin_addr.s_addr = (in_addr_t)address;
to.sin_port = port;
sendto(udp, data, (size_t)size, 0, (struct sockaddr *)&to, sizeof(to));
}
/* a message from the browser: back to it */
void p2p_tunnel_packet(const unsigned char *packet, int size, unsigned long address, unsigned short port)
{
(void)address;
(void)port;
received_count++;
received_bytes += size;
p2p_webrtc_send(connection, packet, size);
}
/* ---------- the harness */
static void unhex(const char *text, unsigned char *bytes, int size)
{
int index;
for (index = 0; index < size; index++)
{
unsigned int value = 0;
sscanf(text + index * 2, "%2x", &value);
bytes[index] = (unsigned char)value;
}
}
int main(int argc, char **argv)
{
unsigned char secret[P2P_SHA256_SIZE];
unsigned char fingerprint[P2P_FINGERPRINT_SIZE];
char ufrag[P2P_ICE_UFRAG_SIZE], password[P2P_ICE_PASSWORD_SIZE], text[2 * P2P_FINGERPRINT_SIZE + 1];
struct sockaddr_in address;
socklen_t length = sizeof(address);
unsigned long reported = 0;
if (argc < 2 || strlen(argv[1]) != 2 * P2P_SHA256_SIZE)
{
fprintf(stderr, "usage: webrtc_native <secret in hex>\n");
return 2;
}
loss = getenv("HARNESS_LOSS") ? atoi(getenv("HARNESS_LOSS")) : 0;
unhex(argv[1], secret, sizeof(secret));
/* the address this computer sends from (a browser may not take a
loopback one): a UDP socket connected anywhere names it, sending nothing */
local_address = htonl(INADDR_LOOPBACK);
udp = socket(AF_INET, SOCK_DGRAM, 0);
memset(&address, 0, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = inet_addr("192.0.2.1");
address.sin_port = htons(9);
if (udp >= 0 && connect(udp, (struct sockaddr *)&address, sizeof(address)) == 0 &&
getsockname(udp, (struct sockaddr *)&address, &length) == 0)
{
local_address = address.sin_addr.s_addr;
}
if (udp >= 0)
close(udp);
length = sizeof(address);
udp = socket(AF_INET, SOCK_DGRAM, 0);
memset(&address, 0, sizeof(address));
address.sin_family = AF_INET;
if (udp < 0 || bind(udp, (struct sockaddr *)&address, sizeof(address)) != 0 ||
getsockname(udp, (struct sockaddr *)&address, &length) != 0)
{
perror("socket");
return 1;
}
tunnel_port = address.sin_port;
p2p_webrtc_credentials(secret, ufrag, password);
if (!posix_dtls_fingerprint(fingerprint))
{
fprintf(stderr, "no certificate\n");
return 1;
}
p2p_hex(fingerprint, sizeof(fingerprint), text);
printf("address %s\nport %u\nufrag %s\npassword %s\nfingerprint %s\n",
inet_ntoa(*(struct in_addr *)&local_address), ntohs(tunnel_port), ufrag, password, text);
fflush(stdout);
for (;;)
{
struct pollfd waits[2] = { { udp, POLLIN, 0 }, { 0, POLLIN, 0 } };
unsigned char datagram[2048];
poll(waits, 2, 5);
if (waits[1].revents & (POLLIN | POLLHUP))
{
char line[256];
int size = 0;
while (size < (int)sizeof(line) - 1 && read(0, line + size, 1) == 1 && line[size] != '\n')
size++;
line[size] = 0;
if (!strncmp(line, "browser ", 8) && strlen(line) >= 8 + 2 * P2P_FINGERPRINT_SIZE)
{
struct p2p_webrtc remote;
memset(&remote, 0, sizeof(remote));
remote.kind = _p2p_webrtc_browser;
unhex(line + 8, remote.fingerprint, P2P_FINGERPRINT_SIZE);
connection = p2p_webrtc_offered(-1, 0, secret, &remote, NULL, 0, 0);
printf("connection %d\n", connection);
fflush(stdout);
}
else if (!strcmp(line, "quit") || !size)
break;
}
if (waits[0].revents & POLLIN)
{
struct sockaddr_in from;
socklen_t from_length = sizeof(from);
ssize_t size = recvfrom(udp, datagram, sizeof(datagram), 0, (struct sockaddr *)&from, &from_length);
if (size > 0 && !lost())
p2p_webrtc_received(datagram, (int)size, from.sin_addr.s_addr, from.sin_port);
}
p2p_webrtc_update();
if (p2p_now() - reported >= 1000)
{
reported = p2p_now();
printf("received %ld %ld\n", received_count, received_bytes);
fflush(stdout);
}
}
return 0;
}
+179
View File
@@ -0,0 +1,179 @@
// A native build's WebRTC (port/linux/src/p2p_webrtc.c) with browsers:
//
// node port/web/tests/webrtc_native.mjs [chromium] [firefox]
//
// (with Playwright where Node finds it, and a C compiler). It builds
// webrtc_native.c for this computer (with posix_dtls.c, Mbed TLS and
// Monocypher), then has each browser open a data channel to it as internet
// play's signalling would have it (port/web/site/p2p.js makes up the native
// end's description the same way): ICE to its ICE-lite end, DTLS with the
// certificates both were told, and SCTP's association. The browser sends it
// messages of every size, split across SCTP's packets and not, which it
// sends back, and the browser checks them; then again with a tenth of the
// datagrams each way lost, when the channel must stay open (what was lost is
// given up, FORWARD-TSN) and carry the rest. No game data is needed.
import { spawn, spawnSync } from 'node:child_process';
import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { cpus, tmpdir } from 'node:os';
import path from 'node:path';
const require = createRequire(import.meta.url);
const playwright = require('playwright');
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..');
const browsers = process.argv.slice(2).length ? process.argv.slice(2) : ['chromium'];
const work = mkdtempSync(path.join(tmpdir(), 'opence-webrtc-'));
function fail(message) {
console.error('FAILED: ' + message);
process.exitCode = 1;
}
// ---------- the harness, built for this computer
async function build() {
const compiler = process.env.CC || 'cc';
const mbedtls = path.join(root, 'port/third_party/mbedtls');
const monocypher = path.join(root, 'port/third_party/monocypher');
const sources = [
...readdirSync(path.join(mbedtls, 'library')).filter((name) => name.endsWith('.c'))
.map((name) => [path.join(mbedtls, 'library', name), ['-w']]),
[path.join(root, 'port/linux/src/posix_dtls.c'), []],
[path.join(root, 'port/linux/src/p2p_webrtc.c'), []],
[path.join(root, 'port/linux/src/p2p_crypto.c'), []],
[path.join(monocypher, 'monocypher.c'), ['-w']],
[path.join(monocypher, 'monocypher-ed25519.c'), ['-w']],
[path.join(root, 'port/web/tests/webrtc_native.c'), []],
];
const flags = ['-std=gnu11', '-O1', '-g', '-D_GNU_SOURCE', `-I${path.join(mbedtls, 'include')}`,
`-I${path.join(mbedtls, 'library')}`, `-I${monocypher}`, `-I${path.join(root, 'port/linux/src')}`];
const objects = [];
const queue = sources.map(([source, extra], index) => () => new Promise((resolve, reject) => {
const object = path.join(work, `${index}.o`);
objects.push(object);
const child = spawn(compiler, [...flags, ...extra, '-c', source, '-o', object], { stdio: 'inherit' });
child.on('exit', (code) => (code ? reject(new Error(`cannot compile ${source}`)) : resolve()));
}));
await Promise.all(Array.from({ length: Math.max(1, cpus().length) }, async () => {
while (queue.length) await queue.shift()();
}));
const program = path.join(work, 'webrtc_native');
const link = spawnSync(compiler, ['-o', program, ...objects, '-lpthread'], { stdio: 'inherit' });
if (link.status) throw new Error('cannot link the harness');
return program;
}
// ---------- one browser's run
function start(program, loss) {
const secret = [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, '0')).join('');
const harness = spawn(program, [secret], { env: { ...process.env, HARNESS_LOSS: String(loss) } });
const told = {};
harness.stderr.on('data', (data) => process.stdout.write(' native: ' + data));
const ready = new Promise((resolve, reject) => {
harness.stdout.on('data', (data) => {
for (const line of data.toString().split('\n')) {
const [key, value] = line.split(' ');
if (key) told[key] = value;
}
if (told.fingerprint) resolve();
});
harness.on('exit', () => reject(new Error('the harness ended')));
});
return { harness, told, ready };
}
async function run(program, kind, loss, rounds) {
const { harness, told, ready } = start(program, loss);
const browser = await playwright[kind].launch();
try {
await ready;
const page = await browser.newPage();
await page.setContent('<!doctype html><title>WebRTC</title>');
const fingerprint = await page.evaluate(async () => {
const pc = new RTCPeerConnection();
const dc = pc.createDataChannel('opence', { negotiated: true, id: 0, ordered: false, maxRetransmits: 0 });
dc.binaryType = 'arraybuffer';
window.pc = pc;
window.dc = dc;
await pc.setLocalDescription(await pc.createOffer());
window.mid = /^a=mid:(\S+)/m.exec(pc.localDescription.sdp)[1];
return /^a=fingerprint:sha-256 ([0-9A-F:]+)/im.exec(pc.localDescription.sdp)[1].replace(/:/g, '').toLowerCase();
});
harness.stdin.write(`browser ${fingerprint}\n`);
// (the answer p2p.js makes up for a native build)
const answer = (mid) => [
'v=0', 'o=- 1 2 IN IP4 127.0.0.1', 's=-', 't=0 0', `a=group:BUNDLE ${mid}`, 'a=ice-lite',
'm=application 9 UDP/DTLS/SCTP webrtc-datachannel', 'c=IN IP4 0.0.0.0', `a=mid:${mid}`,
`a=ice-ufrag:${told.ufrag}`, `a=ice-pwd:${told.password}`,
`a=fingerprint:sha-256 ${told.fingerprint.toUpperCase().match(/../g).join(':')}`,
'a=setup:passive', 'a=sctp-port:5000', 'a=max-message-size:262144',
`a=candidate:1 1 udp 2130706431 ${told.address} ${told.port} typ host`, '',
].join('\r\n');
const result = await page.evaluate(async ({ sdp, rounds }) => {
await pc.setRemoteDescription({ type: 'answer', sdp });
await new Promise((resolve, reject) => {
if (dc.readyState === 'open') resolve();
dc.onopen = resolve;
setTimeout(() => reject(new Error(`not open: ICE ${pc.iceConnectionState}, ${pc.connectionState}`)), 20000);
});
// (as large as a tunnel packet: p2p.c's MAXIMUM_PACKET_SIZE)
const sizes = [1, 10, 100, 1000, 1100, 1101, 1200, 1431];
const sent = new Map();
let echoed = 0;
let wrong = 0;
let lastEchoed = -1;
dc.onmessage = (event) => {
const bytes = new Uint8Array(event.data);
const number = bytes.length >= 2 ? bytes[0] | bytes[1] << 8 : -1;
const expected = sent.get(number);
if (!expected || expected.length !== bytes.length || expected.some((b, i) => b !== bytes[i])) wrong++;
else {
echoed++;
lastEchoed = Math.max(lastEchoed, number);
}
};
let number = 0;
for (let round = 0; round < rounds; round++) {
for (const size of sizes) {
const bytes = new Uint8Array(Math.max(size, 2));
for (let i = 2; i < bytes.length; i++) bytes[i] = (i * 7 + number) & 255;
bytes[0] = number & 255;
bytes[1] = number >> 8;
sent.set(number++, bytes);
dc.send(bytes);
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
await new Promise((resolve) => setTimeout(resolve, 2000));
return { sent: number, echoed, wrong, lastEchoed, open: dc.readyState === 'open' };
}, { sdp: answer(await page.evaluate(() => window.mid)), rounds });
return result;
} finally {
await browser.close();
harness.stdin.write('quit\n');
harness.kill();
}
}
try {
const program = await build();
for (const kind of browsers) {
const clean = await run(program, kind, 0, 40);
console.log(`${kind}: ${clean.echoed} of ${clean.sent} messages back`);
if (clean.wrong || clean.echoed !== clean.sent) fail(`${kind}: ${JSON.stringify(clean)}`);
const lossy = await run(program, kind, 10, 120);
console.log(`${kind}, a tenth of the datagrams lost: ${lossy.echoed} of ${lossy.sent} messages back`);
// (a message comes back if its every datagram passed each way: about
// two thirds for the larger, most for the smaller; and to the end)
if (lossy.wrong || !lossy.open || lossy.echoed < lossy.sent / 2 || lossy.lastEchoed < lossy.sent - 64) {
fail(`${kind} with losses: ${JSON.stringify(lossy)}`);
}
}
} catch (error) {
fail(String(error && error.stack ? error.stack : error));
} finally {
rmSync(work, { recursive: true, force: true });
}
+11
View File
@@ -41,6 +41,8 @@ TOML_DIR = Path("port/third_party/tomlc17")
EXPAT_DIR = Path("port/third_party/expat")
EXPAT_SOURCES = ("xmlparse.c", "xmlrole.c", "xmltok.c")
KCP_DIR = Path("port/third_party/kcp")
# internet play's DTLS with browsers (port/linux/src/posix_dtls.c, in the host)
MBEDTLS_DIR = Path("port/third_party/mbedtls")
MONOCYPHER_DIR = Path("port/third_party/monocypher")
# the port's zlib (port/third_party/zlib/zlib_prefixed.h): what inflates the
# maps, the menus' and the HUD's PNGs and the updates, data from anywhere,
@@ -587,6 +589,15 @@ def generate_android_build(n: Writer, sln: Any) -> None:
n.build(outputs=obj, rule="android_host_cc", inputs=source,
variables={"cflags": miniupnpc_cflags + (" -w" if source.name != "posix_upnp.c" else "")})
host_objects.append(obj)
# internet play's DTLS with browsers (posix_dtls.c, with
# port/third_party/mbedtls), as the other posix_*.c in the host
mbedtls_cflags = " ".join([host_cflags, f"-I{MBEDTLS_DIR / 'include'}", f"-I{MBEDTLS_DIR / 'library'}"])
for source in [LINUX_DIR / "src" / "posix_dtls.c", *sorted((MBEDTLS_DIR / "library").glob("*.c"))]:
obj = host_obj_dir / ("mbedtls_" + source.name + ".o" if source.parent.parent == MBEDTLS_DIR
else source.name + ".o")
n.build(outputs=obj, rule="android_host_cc", inputs=source,
variables={"cflags": mbedtls_cflags + (" -w" if source.name != "posix_dtls.c" else "")})
host_objects.append(obj)
table_obj = host_obj_dir / "host_import_table.c.o"
n.build(outputs=table_obj, rule="android_host_cc", inputs=host_table_c, variables={"cflags": host_cflags})
host_objects.append(table_obj)
+3 -3
View File
@@ -121,9 +121,9 @@ def main() -> int:
# XisoExtractor.java) follow extract-xiso, whose license asks binaries
# to carry its notice
shutil.copy2(ROOT / "port/third_party/extract-xiso/LICENSE.TXT", dist / "extract-xiso-LICENSE.txt")
if args.platform == "linux":
# the self-updater's TLS (port/third_party/mbedtls), whose Apache
# license asks the same
if args.platform != "web":
# internet play's DTLS with browsers, and the Linux self-updater's TLS
# (port/third_party/mbedtls), whose Apache license asks the same
shutil.copy2(ROOT / "port/third_party/mbedtls/LICENSE", dist / "mbedtls-LICENSE.txt")
# internet play's UPnP (port/third_party/miniupnpc), in every build,
# whose BSD license asks binaries to carry its notice
+4 -3
View File
@@ -577,7 +577,7 @@ def generate_linux_build(n: Writer, sln: Any) -> None:
posix_cflags = " ".join(POSIX_FLAGS + target + [f"-I{platform_dir}"])
mbedtls_include = f"-I{MBEDTLS_DIR / 'include'}"
for source in sorted(platform_dir.glob("*.c")):
if source.name == "posix_update.c":
if source.name in ("posix_update.c", "posix_dtls.c"):
add_object(source, f"{posix_cflags} {mbedtls_include}", posix=True)
elif source.name == "posix_upnp.c":
add_object(source, f"{posix_cflags} -I{MINIUPNPC_DIR / 'include'} -DMINIUPNP_STATICLIB", posix=True)
@@ -589,8 +589,9 @@ def generate_linux_build(n: Writer, sln: Any) -> None:
add_object(source, platform_cflags)
for source in embedded_assets:
add_object(source, platform_cflags)
# the self-updater's TLS (port/third_party/mbedtls), with the host's
# ABI as the posix_*.c that use it (and no loop turned into glibc's
# the self-updater's TLS and internet play's DTLS with browsers
# (port/third_party/mbedtls), with the host's ABI as the posix_*.c
# that use it (and no loop turned into glibc's
# wcslen, which linux_link_check.py rejects: the game's wchar_t is
# 16-bit)
for source in sorted((MBEDTLS_DIR / "library").glob("*.c")):
+3 -3
View File
@@ -140,13 +140,13 @@ SCREENS = {
"screen": "network_settings_screen",
"header": ("header_profile_network_settings", f"{PE}/network_setup/header_profile_network_settings"),
"spacing": 30,
# (internet play and the updates, which the web build has not: the
# other rows from the top there)
# (the router, the clipboard and the updates, which the web build
# has not: the other rows from the top there)
"platform_places": True,
"rows": [
("INTERNET PLAY:", "network.online", ON_OFF,
"Host and join games over the internet by invite\nlinks; off keeps to the local network.",
"desktop android"),
"desktop android web"),
("UPNP PORT FORWARDING:", "network.allow_upnp", ON_OFF,
"Let internet play ask the router to forward its\nport, for networks that stop connections.",
"desktop android"),
+3 -1
View File
@@ -232,7 +232,9 @@ def test_menus_are_well_formed():
where = f"{path.name}: <{element.tag} {element.get('name', '')}>"
assert element.tag in MENU_ATTRIBUTES, where
assert set(element.attrib) <= MENU_ATTRIBUTES[element.tag], where
assert element.get("platform") in (None, "desktop", "android"), where
# (the platforms that show it: menu_files.c's MENU_PLATFORM)
assert element.get("platform") is None or \
set(element.get("platform").split()) <= {"desktop", "android", "web"}, where
# (menu_files.c's whole numbers, which the tags keep in shorts,
# its true/false attributes, and no text but whitespace outside
# <string>s)
+4 -2
View File
@@ -184,8 +184,10 @@ def test_the_menus_name_known_platforms_and_match_their_generator():
assert value.split() and set(value.split()) <= {"desktop", "android", "web"}, (path.name, value)
for name, lines in [*port_settings.settings_files().items(), *port_settings.multiplayer_files().items()]:
assert (ROOT / "port/assets/menus/ce" / name).read_text() == "\n".join(lines), name
# the rows a browser has no use for are not on its screens
# the rows a browser has no use for are not on its screens; internet
# play's is (port/web/src/web_p2p.c)
network = (ROOT / "port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit."
"network_setup.xml").read_text()
for row in ("op_online", "op_allow_upnp", "op_join_from_clipboard", "op_auto"):
for row in ("op_allow_upnp", "op_join_from_clipboard", "op_auto"):
assert re.search(rf'<child widget="[^"]*/{row}"[^>]*platform="desktop android"', network), row
assert re.search(r'<child widget="[^"]*/op_online"[^>]*platform="desktop android web"', network)
+2
View File
@@ -126,6 +126,8 @@ WEB_EXCLUDE = {
"posix_update.c", # the self-updater's downloads
"posix_upnp.c", # internet play's router (port/web/src/web_stubs.c)
"posix_trace_marker.c", # a Linux GPU driver's trace markers
"posix_dtls.c", # internet play's WebRTC: the browser's own
"p2p_webrtc.c", # (port/web/src/web_p2p.c)
}
# the web runtime's units that, like posix_*.c, are the C library's side of
+12 -1
View File
@@ -54,6 +54,8 @@ EXPAT_DIR = Path("port/third_party/expat")
EXPAT_SOURCES = ("xmlparse.c", "xmlrole.c", "xmltok.c", "random_rand_s.c")
KCP_DIR = Path("port/third_party/kcp")
MONOCYPHER_DIR = Path("port/third_party/monocypher")
# internet play's DTLS with browsers (port/linux/src/posix_dtls.c)
MBEDTLS_DIR = Path("port/third_party/mbedtls")
# the port's zlib (port/third_party/zlib/zlib_prefixed.h), which inflates
# the maps, the menus' and the HUD's PNGs and the updates
ZLIB_DIR = Path("port/third_party/zlib")
@@ -423,10 +425,14 @@ def generate_windows_build(n: Writer, sln: Any) -> None:
f"-I{_quote(sdl_include)}",
])
replaced = set(config.get("replaced_platform_sources", []))
mbedtls_include = f"-I{MBEDTLS_DIR / 'include'}"
for source in sorted(linux_platform.glob("*.c")):
if source.name in replaced:
continue
if source.name == "updater.c":
if source.name == "posix_dtls.c":
# (Mbed TLS sees the Windows SDK, as the win32_*.c do)
add_object(source, f"{win32_cflags} {mbedtls_include}")
elif source.name == "updater.c":
add_object(source, f"{platform_cflags} {updater_defines(getattr(sln, 'port_release', False))}")
else:
add_object(source, platform_cflags)
@@ -455,6 +461,11 @@ def generate_windows_build(n: Writer, sln: Any) -> None:
add_object(EXPAT_DIR / name, " ".join([abi, "-std=gnu11", f"-I{EXPAT_DIR}", "-w"]))
# internet play's reliable streams (port/third_party/kcp; p2p.c)
add_object(KCP_DIR / "ikcp.c", " ".join([abi, "-std=gnu11", "-w"]))
# internet play's DTLS with browsers (port/third_party/mbedtls;
# posix_dtls.c), on the Windows SDK (its entropy is BCryptGenRandom)
for source in sorted((MBEDTLS_DIR / "library").glob("*.c")):
add_object(source, " ".join([abi, *WIN32_FLAGS, mbedtls_include, f"-I{MBEDTLS_DIR / 'library'}",
"-D_CRT_SECURE_NO_WARNINGS", "-w"]))
# voice chat's codec (port/third_party/opus)
for source in opus_sources():
add_object(source, opus_cflags(abi))