orbisRPC v1.0.0: signed-only updates, heap fix, plugin name fallback, dead code purge, production release key, secret guard, docs accuracy

This commit is contained in:
Humza committed 2026-09-25 18:29:54 +02:00
1 parent 3e0f4b492c
commit 4989941642
17 files changed
+79 -171

No files matched your search

+2
View File
@@ -11,6 +11,8 @@ jobs:
run: make -C tests asan
- name: e2e contracts
run: python3 tests/e2e_consumer.py
env:
ORBISRPC_STRICT_SECRETS: "1"
sdk-payload:
runs-on: ubuntu-latest
steps:
+1
View File
@@ -28,3 +28,4 @@ installer/pkg.gp4
installer/assets/daemon.elf
installer/assets/evict.elf
installer/sce_sys/param.sfo
tests/test_utils_asan
+12 -8
View File
@@ -2,6 +2,8 @@
<img src="config/icons/logo.png" width="420" alt="orbisRPC">
</p>
# orbisRPC — Discord Rich Presence for PS4 (GoldHEN RPC)
<p align="center">
<a href="https://github.com/SirHumza/orbisRPC/releases/tag/v1.0.0"><img src="https://img.shields.io/badge/version-1.0.0-ffd800?style=flat-square" alt="version"></a>
<img src="https://img.shields.io/badge/PS4-GoldHEN-003791?style=flat-square" alt="PS4 GoldHEN">
@@ -20,22 +22,24 @@ playing to Discord: name, cover art, timer. No PC at runtime.</p>
1. Grab `OrbisRPC-Setup-1.0.0.pkg` from the
[Releases page](https://github.com/SirHumza/orbisRPC/releases/tag/v1.0.0)
and install it with Package Installer.
2. Open **orbisRPC Setup** → say Yes. It drops the daemon into GoldHEN's
`bin/elf` and saves your Discord token.
3. Start **orbisrpc** from GoldHEN's payload menu, launch a game, watch Discord.
2. Open **orbisRPC Setup**. It stages `orbisrpc.bin` + `evict.elf` in
`/data/payloads`, writes `/data/orbisRPC/config.json`, evicts any old
daemon, then asks for your Discord token.
3. Launch **orbisrpc** from Payload Guest (GoldHEN's payload menu),
launch a game, watch Discord.
After a reboot: re-jailbreak, then enable AutoRun for `orbisrpc` in GoldHEN's
payload menu once — it starts itself on every jailbreak after that.
After a reboot: re-jailbreak, then enable AutoRun for `orbisrpc` in
Payload Guest once — it starts itself on every jailbreak after that.
## What you get
| | |
|---|---|
| 🎮 **Any game, no lists** | Names resolve from your console's own database — CUSA, PPSA, indies, all covered with zero per-game setup. |
| 🎮 **Any game, no lists** | Names resolve from your console's metadata (SFO, app.xml) plus Sony's TMDB — CUSA, PPSA, indies, zero per-game setup. |
| 🖼️ **Real cover art** | Game art served per title, PlayStation logo when idle. |
| ⏱️ **True timers** | Survive reconnects and restarts, resume across quick game switches. |
| 🧠 **Self-learning** | First-seen titles are remembered, so later boots resolve instantly. |
| 🔄 **Self-updating** | Signed daemon updates with boot rollback. No reinstall treadmill. |
| 🔄 **Self-updating** | Daemon updates land from GitHub releases with automatic rollback. No reinstall treadmill. |
| 📦 **One-tap installer** | Setup PKG: install → token → payload in place. |
## How it works
@@ -45,7 +49,7 @@ PS4 (GoldHEN) Discord
┌─────────────────────────┐ ┌──────────────────┐
│ orbisRPC daemon │ TLS │ your profile │
│ sandbox scan → game ID │ ◄──► │ Playing Game │
│ app.db → display name │ │ [cover] [timer] │
│ metadata/TMDB → name │ │ [cover] [timer] │
└─────────────────────────┘ └──────────────────┘
```
+4 -4
View File
@@ -28,12 +28,12 @@ predates the fix — reinstall.
## Payload won't start
The Setup PKG only writes `/data/GoldHEN/bin/elf/orbisrpc.bin` — it never
launches anything. Start it from GoldHEN's payload menu, or enable AutoRun
for `orbisrpc` once so it boots with every jailbreak.
The Setup PKG only writes `/data/payloads/orbisrpc.bin` — it never
launches anything. Start it from Payload Guest (GoldHEN's payload menu),
or enable AutoRun for `orbisrpc` once so it boots with every jailbreak.
Manual injection (see `injecting.md`) still exists and needs the loaders
open: ports 9090/9021/9020 are closed whenever GoldHEN's BinLoader toggle
open: ports 9021/9020 are closed whenever GoldHEN's BinLoader toggle
is off. Flip it in GoldHEN's menu. Reboot wipes jailbreak + daemon
(RAM-only), so re-jailbreak first.
+2 -8
View File
@@ -65,13 +65,7 @@ GoldHEN's BinLoader arms **once**. Any empty port check (`connect_ex`,
arm** — the real payload then arrives at a dead listener. Sequence is
always: tap → say go → fire immediately → verify. Never probe first.
## Method 3 — in-app injector
The OrbisRPC setup app injects over loopback itself
(`127.0.0.1:9020`, fallback `9090`) from its menu. Needs a listening
loader exactly like Method 2.
## Method 4 — sender page (no PC tools)
## Method 3 — sender page (no PC tools)
`https://SirHumza.github.io/orbisrpc-host/` in the PS4 browser
auto-sends the bundled backend to the console's own loader and prints
@@ -91,7 +85,7 @@ the result on screen.
| Symptom | Meaning | Fix |
|---|---|---|
| `Connection refused` on 9020/9090 | No listener armed | Tap BinLoader / open payloader page, retry instantly |
| `Connection refused` on 9021/9020 | No listener armed | Tap BinLoader / open payloader page, retry instantly |
| `payload launched successfully` then silence, no log | Loader segfault (see above) | Update GoldHEN ≥ v2.4b18.5, use BinLoader server |
| `Error handling payload` | Loader rejected the bytes | Re-check file integrity (`shasum`), resend |
| Log exists but `FATAL: token rejected (4004)` | Token rotated/dead | Fresh token into `/data/orbisRPC/config.json`, relaunch |
+2 -2
View File
@@ -68,8 +68,8 @@ $(INTDIR)/jsonlite.o: orbisrpc/jsonlite.c
$(CC) $(CFLAGS) -o $@ $<
# Stage the daemon + evict payloads + config into the app image.
# config.json is pre-populated with the Discord token so the
# installer skips token entry on fresh installs.
# config.json carries the SET_ME placeholder; a pre-seeded valid token
# (kept out of the repo) makes the installer skip token entry.
installer/assets/daemon.elf: build-sdk/orbisrpc_sdk.elf
cp $< $@
installer/assets/evict.elf: build-sdk/evict.elf
+2 -2
View File
@@ -242,8 +242,8 @@ static int step_files(void){
FILE *src = fopen("/app0/assets/config.json", "rb");
if(src){
fclose(src);
copy_file("/app0/assets/config.json", ICFG_PATH);
ilog("cfg-copy", 0, 0, 0);
int rc = copy_file("/app0/assets/config.json", ICFG_PATH);
ilog("cfg-copy", rc == 0 ? 0 : (errno ? errno : -1), 0, 0);
} else {
ilog("cfg-noasset", errno, 0, 0);
/* Fallback: write template with pre-set token. */
-33
View File
@@ -113,39 +113,6 @@ int ui_ok(const char *msg){
return 0;
}
/* Yes/No dialog. Returns 1 (Yes) or 0 (No/closed).
* The dialog uses YESNO_FOCUS_NO which focuses the "No" button.
* On this console, Circle=confirm and X=back. The confirm
* button selects the focused button, so X acts as confirm
* and selects No, while O acts as back and selects Yes.
* Since the dialog inverts the result, this returns 1 when
* X is pressed (Yes) and 0 when O is pressed (No) — matching
* the system's confirm button behavior. Kept for future use. */
int ui_confirm(const char *msg){
OrbisMsgDialogParam param;
OrbisMsgDialogUserMessageParam um;
OrbisMsgDialogResult res;
memset(&res, 0, sizeof res);
sceMsgDialogTerminate();
if(sceMsgDialogInitialize() < 0) return -1;
base_init(&param);
memset(&um, 0, sizeof um);
um.msg = msg;
/* YESNO_FOCUS_NO focuses the "No" button. On a Circle-accept
* console the confirm button selects the focused button, so
* X (confirm) selects No and O (back) selects Yes. The
* result is inverted: X→buttonId=NO→returns 1 (Yes). */
um.buttonType = ORBIS_MSG_DIALOG_BUTTON_TYPE_YESNO_FOCUS_NO;
param.userMsgParam = &um;
if(sceMsgDialogOpen(&param) < 0){ sceMsgDialogTerminate(); return -1; }
while(sceMsgDialogUpdateStatus() != ORBIS_COMMON_DIALOG_STATUS_FINISHED)
sceKernelUsleep(20000);
sceMsgDialogClose();
sceMsgDialogGetResult(&res);
sceMsgDialogTerminate();
return res.buttonId == ORBIS_MSG_DIALOG_BUTTON_ID_YES;
}
static int progress_open = 0;
int ui_progress_open(const char *msg){
-4
View File
@@ -10,10 +10,6 @@ int ui_init(void);
/* Info dialog with OK. 0 shown, -1 failed to open. */
int ui_ok(const char *msg);
/* Yes/No dialog: X = enter (Yes), O = back (No). 1 yes, 0 no/closed,
* -1 error. */
int ui_confirm(const char *msg);
/* Progress dialog. Open once, update, close. 0 ok, -1 error. */
int ui_progress_open(const char *msg);
void ui_progress_msg(const char *msg);
+2
View File
@@ -83,6 +83,8 @@ static void sess_save(const char *tid, const char *name, int64_t started){ jl
FILE *f = fopen("/data/orbisRPC/session.json.new", "wb");
if(f){
int ok = (fputs(s, f) >= 0) && (fflush(f) == 0);
/* force bytes to disk before rename (same as cfg_save) */
if(ok){ int fd = fileno(f); if(fd < 0 || fsync(fd) != 0) ok = 0; }
if(fclose(f) != 0) ok = 0;
if(ok) rename("/data/orbisRPC/session.json.new",
"/data/orbisRPC/session.json");
+1 -1
View File
@@ -573,7 +573,7 @@ int detect_name_for_title(const char *titleId, char *out_name, size_t cap){
/* Game-process-safe only: small reads plus one bounded network
* lookup; no multi-megabyte scans anywhere in this codebase. */
if(cfg_title(&g_cfg, titleId, out_name, cap)==0){ log_msg("name: %s via config", out_name); resolve_remember(titleId, out_name, "", 1); return 0; }
/* appdb_title(titleId, out_name, cap); */ resolve_remember(titleId, out_name, "", 1); return 0;
/* appdb_title removed: no baked table (README "tables-none"). */
if(pronunc_title(titleId, out_name, cap)==0){ log_msg("name: %s via appmeta", out_name); resolve_remember(titleId, out_name, "", 1); return 0; }
else log_msg("name: appmeta miss for %s", titleId);
if(sfo_file_title(titleId, out_name, cap)==0){ log_msg("name: %s via sfo", out_name); resolve_remember(titleId, out_name, "", 1); return 0; }
+10 -10
View File
@@ -1,22 +1,22 @@
/* release_pubkey.h - embedded release-channel public key (P-256, raw X||Y).
*
* DEV KEY: generated for v1.0.1 development. Before publishing a real
* release, generate a production keypair OFFLINE, replace these bytes,
* and keep the private key out of the repo:
* PRODUCTION KEY: generated 2026-09-25 with
* openssl ecparam -name prime256v1 -genkey -noout -out release_priv.pem
* openssl ec -in release_priv.pem -pubout -out release_pub.pem
* Sign manifest.json with scripts/make_manifest.py (or openssl dgst).
* The private key lives OFFLINE outside the repo (macOS host:
* ~/.config/orbisrpc/release_priv.pem) and must be backed up offline:
* losing it bricks the on-console updater; leaking it fakes releases.
* Sign manifest.json with scripts/make_manifest.py --priv release_priv.pem.
*/
#ifndef ORBISRPC_RELEASE_PUBKEY_H
#define ORBISRPC_RELEASE_PUBKEY_H
/* Uncompressed P-256 X || Y (64 bytes). */
static const unsigned char ORBISRPC_RELEASE_PUBKEY[64] = {
0x47,0xb8,0x9d,0xb8,0x85,0x3d,0x6c,0xcf,0x7e,0x1a,0xbe,0x6e,0xd1,0x5c,0x2e,0x69,
0x66,0x2f,0xa8,0x09,0xbf,0x6d,0xf8,0x9c,0x50,0xe4,0x10,0x0b,0x79,0x4b,0x66,0x1a,
0xd7,0x79,0xae,0x2e,0x41,0xc9,0x35,0x5c,0x6f,0xe3,0xba,0xed,0xc9,0x93,0x9a,0xca,
0xe8,0x18,0xd5,0x4b,0xeb,0x84,0x20,0x8d,0xb3,0xd2,0x4a,0xfc,0x65,0x81,0x6c,0x6f
0xe7,0x31,0xa1,0x93,0x9f,0xe3,0x85,0x36,0x03,0xbf,0x3e,0xb1,0xf0,0xc0,0x55,0x80,
0x4e,0xa9,0x19,0xc5,0xca,0xe5,0xe8,0x5c,0x36,0xdc,0x0d,0x6f,0x7e,0x46,0x03,0xdb,
0x23,0x2b,0xb9,0x2e,0xb1,0x72,0xa8,0xc4,0x75,0x15,0x99,0x18,0x58,0x59,0xfe,0x8c,
0x4d,0x4f,0x31,0xee,0xa3,0xdd,0xfb,0x15,0xae,0x09,0xc3,0x34,0xee,0xf9,0x16,0x54
};
#define ORBISRPC_RELEASE_KEY_ID "dev-2026-09-20"
#define ORBISRPC_RELEASE_KEY_ID "prod-2026-09-25"
#endif
+8 -9
View File
@@ -281,20 +281,19 @@ int tmdb_resolve(const char *titleId, char *name, size_t name_cap,
return name[0] ? 0 : -1;
}
}
/* Live Sony CDN first (TMDB over TLS; plain HTTP is blocked
* on-console and handled inside http_get/https fallback below).
* No baked tables: CUSA code + Sony CDN is the source of truth. */
/* Live Sony CDN over TLS first (plain HTTP is blocked on jailbroken
* consoles — http_get below is last-resort only). No baked tables:
* CUSA code + Sony CDN is the source of truth. */
char path[128];
if(tmdb_path(titleId, path, sizeof path) != 0) return -1;
static char body[TMDB_BODY_MAX];
int status = 0;
int n = http_get(TMDB_HOST, path, body, sizeof body, &status);
int n = https_get_tmdb(path, body, sizeof body, &status);
if(n > 0) log_msg("tmdb: live via https for %s", titleId);
if(n <= 0){
/* Port 80 is blocked from jailbroken consoles; Sony also answers
* the same paths over TLS (443). Try HTTPS before giving up so
* new installs resolve live like everything else. */
n = https_get_tmdb(path, body, sizeof body, &status);
if(n > 0) log_msg("tmdb: live via https for %s", titleId);
/* Plain HTTP (port 80): blocked from jailbroken consoles, so this
* burns the connect timeout before giving up — keep it last. */
n = http_get(TMDB_HOST, path, body, sizeof body, &status);
}
if(n <= 0){ log_msg("tmdb: fetch fail status=%d", status); return -1; }
char iname[128] = "", iicon[256] = "";
+10 -83
View File
@@ -172,7 +172,7 @@ static char *https_get_once(const char *host, const char *path,
if(tls_write(t, req, (size_t)rl) < 0){ tls_free(t); return NULL; }
/* Read raw response (headers + body) up to header cap + body cap. */
size_t rawcap = UPD_HDR_MAX + cap;
char *raw = (char*)malloc(rawcap);
char *raw = (char*)malloc(rawcap + 1); /* +1: NUL pad when a read fills cap exactly */
if(!raw){ tls_free(t); return NULL; }
size_t rl2 = 0;
int64_t dl = orbis_mono_s() + UPD_DEADLINE_S + 20;
@@ -280,57 +280,6 @@ static int stage_file(const char *target, const unsigned char *data, size_t n){
return 0;
}
/* SHA256 of a buffer, hex-encoded (64 chars + NUL). Returns 0 on success. */
static int sha256_hex(const unsigned char *data, size_t n, char out[65]){
unsigned char dig[32];
mbedtls_sha256_context sc;
mbedtls_sha256_init(&sc);
int ok = mbedtls_sha256_starts(&sc, 0) == 0 &&
mbedtls_sha256_update(&sc, data ? data : (const unsigned char *)"", n) == 0 &&
mbedtls_sha256_finish(&sc, dig) == 0;
mbedtls_sha256_free(&sc);
if(!ok) return -1;
for(int i=0;i<32;i++) snprintf(out+2*i, 3, "%02x", dig[i]);
out[64]=0;
return 0;
}
/* Look up "<filename>" in a SHA256SUMS body ("<hash> <name>\n" lines).
* Returns 0 and fills want_hex when found. */
static int sums_lookup(const char *sums, const char *filename, char want_hex[65]){
size_t fnlen = strlen(filename);
const char *p = sums;
while(*p){
while(*p==' '||*p=='\t'||*p=='\r'||*p=='\n') p++;
if(!*p) break;
if(strlen(p) < 64) break;
char hex[65];
memcpy(hex, p, 64); hex[64]=0;
int ishex=1;
for(int i=0;i<64;i++){
char c=hex[i];
if(!((c>='0'&&c<='9')||(c>='a'&&c<='f')||(c>='A'&&c<='F'))){ ishex=0; break; }
}
const char *e = strchr(p, '\n');
size_t linelen = e ? (size_t)(e-p) : strlen(p);
if(ishex && linelen > 65){
const char *nl = p+64;
while(nl<p+linelen && (*nl==' '||*nl=='\t'||*nl=='*')) nl++;
if((size_t)(p+linelen-(nl)) >= fnlen && !memcmp(nl, filename, fnlen) &&
(nl[fnlen]=='\n'||nl[fnlen]=='\r'||nl[fnlen]==' '||nl[fnlen]=='\t'||nl[fnlen]==0||nl[fnlen]=='*')){
for(int i=0;i<64;i++){
char c=hex[i];
want_hex[i]=(c>='A'&&c<='F')?(char)(c-'A'+'a'):c;
}
want_hex[64]=0;
return 0;
}
}
p = e ? e+1 : p+linelen;
}
return -1;
}
/* Download a release asset by exact name. Returns heap body or NULL. */
static char *fetch_asset(const jl_val_t *assets, const char *want_name,
size_t cap, int *status, size_t *out_len){
@@ -428,12 +377,14 @@ int updater_check_and_stage(void){
free(sbody);
if(!have_manifest){ free(mbody); mbody = NULL; }
}
/* Compat fallback: SHA256SUMS (unsigned but hash-pinned). */
char *sums = NULL;
/* Refuse unsigned updates outright. SHA256SUMS comes from the
* same release as the binaries, so it pins nothing against
* release-asset compromise — exactly what the signed manifest
* defends against (ORX-UPDATE-002). */
if(!have_manifest){
sums = fetch_asset(assets, "SHA256SUMS", 65536, &status, NULL);
if(!sums)
log_msg("updater: WARN ORX-UPDATE-002: no manifest and no SHA256SUMS; refusing unsigned update");
log_msg("updater: no valid signed manifest; refusing update (ORX-UPDATE-002)");
jl_free(r);
return 0;
}
/* Two-phase commit: download + verify EVERY asset first, then
* activate all at once. A failure anywhere stages nothing, so
@@ -468,31 +419,8 @@ int updater_check_and_stage(void){
pend_fail = 1;
break;
}
if(have_manifest){
if(manifest_check(&mf, nm->str, (unsigned char*)bin, al) != 0){
log_msg("updater: asset %s not in manifest or hash mismatch; refusing (ORX-UPDATE-002)", nm->str);
free(bin);
pend_fail = 1;
break;
}
} else if(sums){
char want[65];
if(sums_lookup(sums, nm->str, want) != 0){
log_msg("updater: asset %s not in SHA256SUMS; refusing", nm->str);
free(bin);
pend_fail = 1;
break;
}
char got[65];
if(sha256_hex((unsigned char*)bin, al, got) != 0 || strcmp(got, want) != 0){
log_msg("updater: asset %s hash mismatch; refusing", nm->str);
free(bin);
pend_fail = 1;
break;
}
} else {
/* No manifest and no SHA256SUMS: refuse unsigned bytes. */
log_msg("updater: asset %s refused: no trust anchor (ORX-UPDATE-002)", nm->str);
if(manifest_check(&mf, nm->str, (unsigned char*)bin, al) != 0){
log_msg("updater: asset %s not in manifest or hash mismatch; refusing (ORX-UPDATE-002)", nm->str);
free(bin);
pend_fail = 1;
break;
@@ -519,7 +447,6 @@ int updater_check_and_stage(void){
log_msg("updater: incomplete set; staged nothing (versions stay matched)");
}
for(int pi = 0; pi < pend_n; pi++) free(pend[pi].bin);
free(sums);
free(mbody);
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ MBEDTLS_DIR := ../third_party/mbedtls/library
MBEDTLS_SRCS := $(filter-out $(MBEDTLS_DIR)/net_sockets.c $(MBEDTLS_DIR)/timing.c $(MBEDTLS_DIR)/entropy_poll.c,$(wildcard $(MBEDTLS_DIR)/*.c))
ORBIS_SRCS := ../orbisrpc/jsonlite.c ../orbisrpc/b64.c ../orbisrpc/sfo.c ../orbisrpc/tmdb_crypto.c ../orbisrpc/updater_util.c ../orbisrpc/art.c ../orbisrpc/log.c ../orbisrpc/health.c ../orbisrpc/manifest.c ../orbisrpc/compat.c ../orbisrpc/cfg.c ../orbisrpc/appdb.c ../orbisrpc/discord.c
INST_SRCS := ../installer/icfg.c ../installer/nettest.c ../installer/send.c
INST_SRCS := ../installer/icfg.c
# SQLite amalgamation: -O0 for host iteration speed (payload uses -O2).
SQLITE_DIR := ../third_party/sqlite/sqlite-amalgamation-3510100
SQLITE_FLAGS := -O0 -DSQLITE_THREADSAFE=0 -DSQLITE_OMIT_LOAD_EXTENSION -DSQLITE_OMIT_DEPRECATED -I$(SQLITE_DIR)
+22
View File
@@ -34,6 +34,28 @@ check("host/unit-tests", r.returncode == 0 and "utility tests passed" in r.stdou
cfg = src("config/config.json")
check("install/template-has-no-token", "SET_ME" in cfg)
check("install/art-pack-default", "orbisrpc-host" in cfg)
# 2b. no Discord-session-shaped token anywhere the PKG or repo ships.
# Fatal when ORBISRPC_STRICT_SECRETS=1 (CI); a local working copy may hold
# a dev token and gets a warning instead.
tok_re = re.compile(rb"MT[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{20,}")
leaks = []
for rel in ("installer/installer.c", "installer/config.json",
"installer/assets/config.json", "config/config.json"):
try:
with open(os.path.join(ROOT, rel), "rb") as fh:
if tok_re.search(fh.read()):
leaks.append(rel)
except OSError:
pass
if leaks:
_msg = "token-shaped string in " + ", ".join(leaks)
if os.environ.get("ORBISRPC_STRICT_SECRETS") == "1":
check("secrets/no-token-in-installer", False, _msg)
else:
print("WARN secrets/no-token-in-installer (set ORBISRPC_STRICT_SECRETS=1 "
"to fail) - " + _msg)
else:
check("secrets/no-token-in-installer", True)
# 3. single-instance lock with recycled-PID guard
lock = src("orbisrpc/lock.c")
check("runtime/single-lock", "lock_acquire" in src("orbisrpc/daemon.c"))
-6
View File
@@ -11,7 +11,6 @@
#include "../orbisrpc/discord.h"
#include "../orbisrpc/detect.h"
#include "../installer/icfg.h"
#include "../installer/nettest.h"
#include "sqlite3.h"
#include <string.h>
@@ -611,11 +610,6 @@ static void test_installer_cfg(void) {
assert(icfg_get_int(path, "poll_interval_s", &n) == 0 && n == 12);
/* missing file: loads fail soft, save still works */
assert(icfg_get_str("/nonexistent/x.json", "k", st, sizeof st) != 0);
/* net probes fail soft on garbage */
assert(net_probe(NULL, 443, 2) == 0);
assert(net_probe("", 443, 2) == 0);
assert(net_probe("127.0.0.1", 1, 1) == 0);
assert(net_probe("nonexistent.invalid", 443, 1) == 0);
}
int main(void) {