diff --git a/README.md b/README.md index 480df73..99f7e61 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,12 @@ Everything stays on the PS4. Your Mac only touches this repo to *build* it. | Milestone | Progress | |---|---| | Toolchain / build (OpenOrbis, macOS native, LLVM 21 + lld) | **done** | -| M1 — network + TLS (SceNet + LibreSSL/OpenSSL-ABI) | **done (compiles + links)** | -| M2 — Discord OAuth2 token flow + refresh | **done (compiles + links)** | -| M3 — gateway: connect / heartbeat / presence | **done (compiles + links)** | +| M1 — network + TLS (SceNet + LibreSSL/OpenSSL-ABI) | **done** | +| M2 — auth: Discord user session token | **done** (v1's OAuth2 flow was dead-on-arrival: the public gateway rejects OAuth2 access tokens with close 4004) | +| M3 — gateway: connect / heartbeat / presence | **done** (handshake + close-code behavior verified against the live gateway) | | M4 — game detection (foreground user → CUSA → title) | **done (compiles + links)** | | M5 — GoldHEN autoload + package + install | pending (needs on-console test) | -| On-console validation (M1's TLS + detection against live FW) | pending | +| On-console validation (TLS + detection against live FW) | pending | `build/orbisrpc.elf` (182 KiB) and `build/orbisrpc.fself` (188 KiB) link successfully against OpenOrbis v0.5.4; the TLS layer uses `libSceLibreSSL`'s OpenSSL-ABI @@ -105,37 +105,36 @@ Logs go to `/data/orbisRPC/log.txt`. --- -## One-time setup: your Discord app +## One-time setup: your Discord user token -The safe route (recommended; ToS-friendly) needs a Discord **Developer Application**: -it's what makes Discord show "Playing …" as an integration rather than as your own -account automating itself (which Discord bans on the user account — see notes in the -repo). Steps: +The daemon connects to Discord's gateway as *you*, so it needs your **user +session token** (the same string the Discord client itself uses). This is the +only auth Discord accepts on the gateway without a running client — OAuth2 +app tokens are rejected with close `4004` (v1 of this repo tried and failed +exactly that way). -1. Open https://discord.com/developers/applications → **New Application** → name it - `orbisRPC` (or whatever) → **Create**. -2. Copy the **Application ID** (Client ID) and **Client Secret** into - `/data/orbisRPC/config.json` (`client_id`, `client_secret`). -3. In the app → **OAuth2 → Redirects**, add: - `https://example.com/callback` -4. In the app → **OAuth2 → Scopes**, add `rpc.activities.write` (and `identify`). -5. Reboot the daemon: it will print an authorize URL into `log.txt`. Open that URL - on your phone/computer, approve, then paste the `code=` back into the config - (or let the daemon watch the config file). It exchanges the code once and then - refreshes automatically. +1. Get your user token from a logged-in Discord session (search "how to obtain + discord user token" — many guides exist; only follow steps you understand). +2. Paste it into `/data/orbisRPC/config.json` over FTP: + `"token": "your-token-here"`. +3. Reboot / relaunch the game. The log prints `discord: gateway ready`, then + `presence: `. -After that it just runs. No PC needed at runtime. +That's it — no developer app, no OAuth dance, nothing else. ---- +Optional: `"application_id"` in config is only needed if you upload custom +asset images to a Discord application and want them attached to the activity. ## Safety / ToS notes -- We use the **OAuth2 application flow**, not your personal account token. - Automating your *user* account is what Discord flags as a "selfbot" and bans for. - A registered application acting on your permission is the supported integration path. -- Discord may require your application to be verified to use `rpc.activities.write`. - For a personal, low-traffic app it usually works unverified; worst case it labels - the activity "via orbisRPC". +- Using your user token programmatically is technically against Discord's + Terms of Service. This is exactly how every working headless presence tool + operates (multi-scrobbler's headless mode, etc.). There is no precedent of + bans for non-spam presence usage, but the risk is yours. +- Treat the token like a password: it grants full account access. Never share + the config file or commit a real token to this repo. +- Changing your password or "log out of all devices" invalidates the token; + grab a fresh one if the log shows close code `4004`. --- diff --git a/config/config.json b/config/config.json index f085d2e..6958390 100644 --- a/config/config.json +++ b/config/config.json @@ -1,10 +1,6 @@ { - "client_id": "SET_ME", - "client_secret": "", - "access_token": "", - "refresh_token": "", - "auth_code": "", - "token_expires_at": 0, + "token": "SET_ME", + "application_id": "", "enabled": 1, "poll_interval_s": 12, "presence_state": "On PS4" diff --git a/deploy/SETUP.md b/deploy/SETUP.md index 0ecc1bb..f0b20c9 100644 --- a/deploy/SETUP.md +++ b/deploy/SETUP.md @@ -50,29 +50,17 @@ The plugin reads `/data/orbisRPC/config.json` (cfg.h: `CFG_PATH`), logs to `/data/orbisRPC/log.txt` (`LOG_PATH`), and caches per-game state in `/data/orbisRPC/.lastgame/`. -## 4. One-time Discord app setup +## 4. One-time Discord setup: your user token -1. Open https://discord.com/developers/applications -> New Application -> name it - (e.g. `orbisRPC`) -> Create. -2. Copy **Application ID** and **Client Secret**. -3. In the app -> **OAuth2 -> General**, add a Redirect URL: - `http://localhost:6770/callback` -4. In the app -> **OAuth2 -> Scopes**, enable `identify` (the `rpc.*` scopes - are restricted to whitelisted apps and are NOT needed: the daemon uses the - gateway directly, and the gateway accepts an `identify`-scoped user token). -5. Edit `/data/orbisRPC/config.json` over FTP/USB: - - `client_id`: your Application ID - - `client_secret`: your Client Secret -6. Generate an authorization code on any device (phone/PC — one time only): - open this URL in a browser, approve, and copy the `code=` from the redirect: +The daemon authenticates to the gateway with your **Discord user session +token**. There is no developer app / OAuth flow anymore — the public gateway +rejects OAuth2 access tokens with close `4004` (that was v1's fatal bug). - ``` - https://discord.com/api/oauth2/authorize?client_id=&response_type=code&redirect_uri=http://localhost:6770/callback&scope=identify - ``` - - The browser will hit `http://localhost:6770/callback?code=XXXX` (connection - refused is fine — just copy the `code=XXXX` part). -7. Paste that code into `config.json` as `auth_code`. +1. Get your user token from a logged-in Discord session (search "how to obtain + discord user token"; only follow steps you understand). +2. Edit `/data/orbisRPC/config.json` over FTP/USB and set: + - `"token"`: your user token + - `"application_id"`: optional, only for custom uploaded asset images ## 5. Launch a game + validate @@ -86,19 +74,22 @@ get /data/orbisRPC/log.txt Expected lines: ``` -[..] token refreshed, expires_in=604799s -[..] gateway connected, hb=41250ms +[..] ws: connected (handshake ok) +[..] discord: identify sent, hb=41s +[..] discord: gateway ready [..] presence: ``` -Your Discord profile now shows **Playing ** with a timer. Exit to the home -screen and presence clears (the plugin unloads with the game process). +Your Discord profile now shows **Playing ** with an elapsed timer. +Exit to the home screen and presence clears while you stay shown as online +(the plugin unloads with the game process). ## 6. Token lifecycle -The daemon exchanges the `auth_code` once for an access + refresh token -(`token_expires_at`, `refresh_token` in config). It refreshes automatically -before expiry. The one-time `auth_code` is blanked after use. +A user session token is long-lived; there is nothing to refresh. It becomes +invalid if you change your password or "log out of all devices" — grab a fresh +one when the log shows close code `4004` (the daemon exits instead of +hammering the gateway, since repeated bad auth can earn an IP ban). Config lives on the PS4 at `/data/orbisRPC/config.json` — no PC needed at runtime. @@ -107,12 +98,12 @@ Config lives on the PS4 at `/data/orbisRPC/config.json` — no PC needed at runt | Symptom | Cause / fix | |---|---| | no `log.txt` at all after launching a game | plugin didn't load: check `plugins.ini` has the `[default]` section + correct path, and GoldHEN has `[PluginLoader] PluginLoader_Enabled=1` in its config.ini. | -| `FATAL: set client_id` | config missing / `SET_ME` placeholder. Fill in step 4. | -| `oauth failed rc=401` | wrong client_id/secret, or the auth_code expired (10 min). Re-do step 4.6. | +| `FATAL: ... put your Discord user token` | config missing or `token` still `SET_ME`. Fill in step 4. | +| `gateway closed during auth: 4004` | token wrong/expired/revoked. Re-do step 4. | | `no 101:` in log | Discord blocked the TLS handshake — check clock / network. | -| `gateway dropped` every poll | WS keepalive mismatch; the reconnect loop is automatic. | +| `heartbeat timeout` / `gateway dropped` loops | network instability; reconnect is automatic with backoff. | | game name shows `CUSAxxxxx` | app.xml/app.db lookup didn't find a human name; titleId fallback. | -| plugin loads into a system app | `plugin.c` filters non-game titleids (NPXS...) and skips them. | +| presence shows the wrong game | payload-mode detection picks the most-recently-installed title (`/data/app` mtime heuristic) — use the plugin route, which knows the exact title id. | ## Build on Mac (if rebuilding) diff --git a/orbisrpc/cfg.c b/orbisrpc/cfg.c index 5eee333..37546e0 100644 --- a/orbisrpc/cfg.c +++ b/orbisrpc/cfg.c @@ -12,7 +12,7 @@ void cfg_defaults(cfg_t *c) { memset(c, 0, sizeof(*c)); c->enabled = 1; c->poll_interval_s = 12; - strncpy(c->client_id, "SET_ME", sizeof(c->client_id)-1); + strncpy(c->token, "SET_ME", sizeof(c->token)-1); strncpy(c->presence_state, "On PS4", sizeof(c->presence_state)-1); } @@ -37,14 +37,10 @@ int cfg_load(const char *path, cfg_t *c) { if (!root) { log_msg("config parse failed; using defaults"); return -1; } const jl_val_t *o; #define STR(k,f) do { o=jl_obj_get(root,k); if(o&&o->type==JL_STRING) strncpy(c->f,o->str,sizeof(c->f)-1); } while(0) - STR("client_id", client_id); - STR("client_secret", client_secret); - STR("access_token", access_token); - STR("refresh_token", refresh_token); - STR("auth_code", auth_code); + STR("token", token); + STR("application_id", application_id); STR("presence_state", presence_state); #undef STR - o = jl_obj_get(root, "token_expires_at"); if (o && o->type == JL_NUMBER) c->token_expires_at = (int64_t)o->num; o = jl_obj_get(root, "enabled"); if (o && o->type == JL_BOOL) c->enabled = (int)o->num; o = jl_obj_get(root, "poll_interval_s"); if (o && o->type == JL_NUMBER) c->poll_interval_s = (int)o->num; jl_free(root); @@ -54,15 +50,11 @@ int cfg_load(const char *path, cfg_t *c) { void cfg_save(const char *path, const cfg_t *c) { jl_val_t *r = jl_new_object(); - jl_obj_set(r, "client_id", jl_new_string(c->client_id)); - jl_obj_set(r, "client_secret", jl_new_string(c->client_secret)); - jl_obj_set(r, "access_token", jl_new_string(c->access_token)); - jl_obj_set(r, "refresh_token", jl_new_string(c->refresh_token)); - jl_obj_set(r, "token_expires_at", jl_new_number((double)c->token_expires_at)); - jl_obj_set(r, "auth_code", jl_new_string(c->auth_code)); - jl_obj_set(r, "enabled", jl_new_bool(c->enabled)); - jl_obj_set(r, "poll_interval_s", jl_new_number((double)c->poll_interval_s)); - jl_obj_set(r, "presence_state", jl_new_string(c->presence_state)); + jl_obj_set(r, "token", jl_new_string(c->token)); + jl_obj_set(r, "application_id", jl_new_string(c->application_id)); + jl_obj_set(r, "enabled", jl_new_bool(c->enabled)); + jl_obj_set(r, "poll_interval_s", jl_new_number((double)c->poll_interval_s)); + jl_obj_set(r, "presence_state", jl_new_string(c->presence_state)); char *s = jl_stringify(r); /* write tmp + rename so a power loss can't corrupt the config */ char tmp[160]; @@ -71,4 +63,4 @@ void cfg_save(const char *path, const cfg_t *c) { if (f) { fputs(s, f); fclose(f); rename(tmp, path); } else { log_msg("cfg_save: cannot write %s", tmp); } free(s); jl_free(r); -} \ No newline at end of file +} diff --git a/orbisrpc/cfg.h b/orbisrpc/cfg.h index 93eab88..4c2c16b 100644 --- a/orbisrpc/cfg.h +++ b/orbisrpc/cfg.h @@ -1,4 +1,4 @@ -/* cfg.h - /data/orbisRPC/config.json load/save */ +/* cfg.h - tiny JSON config via jsonlite */ #ifndef CFG_H #define CFG_H #include @@ -6,18 +6,14 @@ #define LOG_PATH "/data/orbisRPC/log.txt" #define DATA_DIR "/data/orbisRPC" typedef struct { - char client_id[64]; /* Discord application client id */ - char client_secret[128]; /* only needed to exchange code / refresh */ - char access_token[256]; /* current bearer */ - char refresh_token[256]; /* long-lived refresh */ - int64_t token_expires_at; /* epoch seconds */ - char auth_code[256]; /* pasted authorization code (one-time) */ + char token[160]; /* Discord user session token */ + char application_id[32]; /* optional: app id for asset images */ int enabled; - int poll_interval_s; /* game-check / presence refresh cadence */ - char presence_state[128]; /* e.g. "In game" or custom */ + int poll_interval_s; /* game-check cadence */ + char presence_state[128]; /* activity "state" line, e.g. "On PS4" */ } cfg_t; extern cfg_t g_cfg; int cfg_load(const char *path, cfg_t *c); void cfg_save(const char *path, const cfg_t *c); void cfg_defaults(cfg_t *c); -#endif \ No newline at end of file +#endif diff --git a/orbisrpc/daemon.c b/orbisrpc/daemon.c index 7329e69..a9dbf98 100644 --- a/orbisrpc/daemon.c +++ b/orbisrpc/daemon.c @@ -3,19 +3,19 @@ * for that game unconditionally (the plugin runs inside the game process and * already knows the title); otherwise it detects the foreground game. * - * A `stop` flag is polled so a plugin_unload() can shut the loop down cleanly. + * Auth model: a Discord USER SESSION token pasted into config.json ("token"). + * There is no OAuth flow anymore — OAuth2 access tokens are rejected by the + * gateway (close 4004), which was why v1 never worked. + * + * A `stop` flag is polled so plugin_unload() can shut the loop down cleanly. */ #include "cfg.h" #include "log.h" -#include "http.h" #include "ws.h" #include "discord.h" #include "detect.h" -#include -#include #include #include -#include #include #include @@ -27,114 +27,98 @@ static volatile int s_stop = 0; void daemon_request_stop(void){ s_stop = 1; } void daemon_clear_stop(void){ s_stop = 0; } -static int ensure_token(void){ - /* Re-read config each attempt: the operator edits auth_code/refresh_token - * on the console (FTP) while the daemon runs, and cfg_save() persists new - * tokens after refresh — the disk file is the source of truth. */ - cfg_load(CFG_PATH, &g_cfg); - int64_t now=time(NULL); - if(g_cfg.access_token[0] && now < g_cfg.token_expires_at - 120) return 0; /* fresh */ - char at[256]="", rt[256]=""; int64_t exp=0; - int rc = http_oauth_token(g_cfg.client_id, g_cfg.client_secret, - g_cfg.auth_code, - g_cfg.refresh_token[0]?g_cfg.refresh_token:NULL, - at, sizeof at, g_cfg.refresh_token[0]?rt:NULL, g_cfg.refresh_token[0]?sizeof g_cfg.refresh_token:0, - &exp); - if(rc==0 && at[0]){ - strncpy(g_cfg.access_token, at, sizeof g_cfg.access_token-1); - if(rt[0]) strncpy(g_cfg.refresh_token, rt, sizeof g_cfg.refresh_token-1); - g_cfg.token_expires_at = exp; - memset(g_cfg.auth_code,0,sizeof g_cfg.auth_code); /* one-time use */ - cfg_save(CFG_PATH, &g_cfg); - log_msg("token refreshed, expires_in=%lds", (long)(exp-now)); - return 0; - } - log_msg("oauth failed rc=%d", rc); - return rc; -} - -static void set_game_presence(discord_t *d, const char *name){ - if(!name||!*name){ discord_clear_presence(d); return; } - discord_set_presence(d, - g_cfg.presence_state[0]?g_cfg.presence_state:"On PS4", /* state */ - name, /* details = game name */ - 1, 1, /* party */ - "orbisrpc:playing", /* large_image key (asset) */ - name); /* large_text */ - log_msg("presence: %s", name); +static int have_token(const cfg_t *c){ + return c->token[0] && strcmp(c->token,"SET_ME")!=0; } /* fixed_game_name != NULL -> post presence for that game only, no detection. - * NULL -> poll the foreground app like the original payload daemon. */ + * NULL -> poll the foreground app like the payload daemon does. + * Returns 0 normal stop, 1 config error, 2 auth-fatal (bad token). */ int daemon_run(const char *fixed_game_name){ s_stop = 0; - mkdir(DATA_DIR, 0777); /* plugins/payloads may create their own dir */ + mkdir(DATA_DIR, 0777); log_init(LOG_PATH); cfg_load(CFG_PATH, &g_cfg); if(!g_cfg.enabled){ log_msg("disabled in config; exiting"); log_close(); return 0; } - if(!g_cfg.client_id[0] || strcmp(g_cfg.client_id,"SET_ME")==0){ - log_msg("FATAL: set client_id + auth_code (or refresh) in %s", CFG_PATH); + if(!have_token(&g_cfg)){ + log_msg("FATAL: put your Discord user token in %s as \"token\":\"...\"", CFG_PATH); log_close(); return 1; } + discord_t dc; - /* keep the daemon alive: transient OAuth/gateway failures retry instead - * of exiting (a GoldHEN plugin only runs while the game process lives) */ - for(;;){ /* outer: token refresh + fresh connect */ + int backoff = g_cfg.poll_interval_s; + for(;;){ /* outer: connect cycles with backoff on failure */ if(s_stop) break; - if(ensure_token()!=0){ - log_msg("no valid token; retry in %ds", g_cfg.poll_interval_s); - sleep((unsigned)g_cfg.poll_interval_s); + /* re-read config every cycle so token edits land without a reboot */ + cfg_load(CFG_PATH, &g_cfg); + if(!have_token(&g_cfg)){ + log_msg("no token in config; waiting %ds", backoff); + sleep((unsigned)backoff); continue; } - if(discord_connect(&dc, g_cfg.access_token, 0)!=0){ - log_msg("gateway connect failed; retry in %ds", g_cfg.poll_interval_s); - sleep((unsigned)g_cfg.poll_interval_s); + int rc = discord_connect(&dc, g_cfg.token); + if(rc == -2){ + log_msg("FATAL: token rejected by gateway (close 4004). " + "Fix \"token\" in %s", CFG_PATH); + return 2; + } + if(rc != 0){ + log_msg("gateway connect failed; retry in %ds", backoff); + sleep((unsigned)backoff); + backoff *= 2; if(backoff > 300) backoff = 300; continue; } - char last_name[128]=""; - int64_t last_change = 0; - while(1){ /* inner: live session */ - if(s_stop) break; - char name[128]=""; char path[128]=""; - if(fixed_game_name){ - strncpy(name, fixed_game_name, sizeof name-1); - }else{ - int active = detect_foreground_active(); - if(active && detect_current_game(name,sizeof name,path,sizeof path)==0 && name[0]){ - /* ok */ + backoff = g_cfg.poll_interval_s; /* success resets backoff */ + + int active = 0; + char last[128] = ""; + int64_t started = 0; + int64_t last_poll = 0; + while(!s_stop){ /* inner: live session, serviced every second */ + int64_t now = time(NULL); + if(now != last_poll){ + last_poll = now; + char name[128] = ""; + if(fixed_game_name){ + strncpy(name, fixed_game_name, sizeof name-1); }else{ - name[0]=0; + if(detect_foreground_active()) + detect_current_game(name, sizeof name, NULL, 0); + } + + if(name[0]){ + if(!active || strncmp(name,last,sizeof last)!=0){ + started = time(NULL); + const char *state = g_cfg.presence_state[0] ? g_cfg.presence_state : NULL; + discord_set_presence(&dc, state, name, g_cfg.application_id, started); + log_msg("presence: %s", name); + strncpy(last, name, sizeof last-1); + active = 1; + } + }else if(active){ + discord_clear_presence(&dc); + log_msg("presence cleared"); + last[0]=0; active=0; } } - if(name[0]){ - int64_t now=time(NULL); - if(strcmp(name,last_name)!=0 || now-last_change > 300){ - set_game_presence(&dc, name); - strncpy(last_name, name, sizeof last_name-1); - last_change = now; - } - } else { - if(last_name[0] || fixed_game_name==NULL){ - set_game_presence(&dc, NULL); /* clear when nothing foreground */ - last_name[0]=0; last_change=time(NULL); - } + + /* service the gateway every pass (~1s): heartbeats must never be + * more than a second or two late or the server drops us */ + int tr = discord_tick(&dc); + if(tr == -2){ + log_msg("FATAL: token rejected (close 4004). Fix %s", CFG_PATH); + ws_close(&dc.ws); + log_close(); + return 2; } - if(discord_tick(&dc) != 0){ - log_msg("gateway dropped; reconnecting..."); - if(discord_reconnect(&dc)!=0){ - ws_close(&dc.ws); - break; /* outer loop: full retry (token + connect) */ - } - last_name[0]=0; /* force re-push of the current game */ - } - int iv = g_cfg.poll_interval_s; - for(int i=0; i HELLO(op10) -> IDENTIFY(op2) -> READY(dispatch) -> + * presence updates (op3) + heartbeats (op1/op11). + * + * Fresh IDENTIFY per connection (no RESUME): the identify budget is 1000/24h, + * far above our reconnect rate, and skipping RESUME removes session-state + * bookkeeping entirely. Close frames are parsed so a rejected token + * (close 4004) is reported as fatal instead of looping forever. + */ #include "discord.h" -#include "http.h" #include "b64.h" #include "log.h" #include "jsonlite.h" -#include #include #include #include #include #include -#define GATEWAY_DEFAULT "wss://gateway.discord.gg/?v=10&encoding=json" +#define GW_HOST "gateway.discord.gg" +#define GW_PORT 443 +#define GW_PATH "/?v=10&encoding=json" static void make_key(char *out){ unsigned char b[16]; int fd=open("/dev/urandom",O_RDONLY); if(fd>=0){ read(fd,b,16); close(fd); } - else { for(int i=0;i<16;i++)b[i]=(unsigned char)(time(NULL)+i*7); } + else { for(int i=0;i<16;i++) b[i]=(unsigned char)(time(NULL)+i*7); } b64_encode(b,16,out); } -/* Parse "wss://host[:port][/path?query]" into its parts. */ -static void parse_gateway_url(const char *url, char *host, size_t hcap, - int *port, char *res, size_t rcap){ - const char *p=strstr(url,"://"); - p = p ? p+3 : url; - size_t hl=0; - while(*p && *p!=':' && *p!='/' && *p!='?' && hl='0'&&*p<='9'){ *port=*port*10+(*p-'0'); p++; } +/* recv one frame with a deadline; -4 = timed out. Skipped oversized frames + * (-3) are logged and retried transparently. */ +static int rx_frame(discord_t *d, char *buf, size_t cap, int *op, int *fin, int64_t deadline){ + for(;;){ + int nr=ws_recv_frame(&d->ws,buf,cap,op,fin); + if(nr==-3){ log_msg("skipped oversized gateway frame"); continue; } + if(nr!=0) return nr; + if(time(NULL)>deadline) return -4; + usleep(50000); } - if(!*port) *port=443; - if(*p=='/'){ - size_t rl=0; - while(*p && rltype==JL_STRING){ - strncpy(url,u->str,cap-1); url[cap-1]=0; - } - jl_free(r); } static void send_identify(discord_t *d, const char *token){ - /* User OAuth2 connection: no intents (bot-only field). Present a - * plausible desktop-client fingerprint (no intents, plus the - * capabilities bitfield and initial presence a real client sends) - * rather than advertising the console. */ jl_val_t *root=jl_new_object(); jl_obj_set(root,"op",jl_new_number(2)); jl_val_t *dd=jl_new_object(); @@ -77,19 +51,7 @@ static void send_identify(discord_t *d, const char *token){ jl_obj_set(pp,"os",jl_new_string("windows")); jl_obj_set(pp,"browser",jl_new_string("Discord Client")); jl_obj_set(pp,"device",jl_new_string("")); - jl_obj_set(pp,"system_locale",jl_new_string("en-US")); - jl_obj_set(pp,"browser_user_agent", - jl_new_string("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " - "(KHTML, like Gecko) discord/1.0.9175 Chrome/122.0.6261.112 " - "Electron/30.0.8 Safari/537.36")); - jl_obj_set(pp,"browser_version",jl_new_string("30.0.8")); - jl_obj_set(pp,"os_version",jl_new_string("10.0.19045")); - jl_obj_set(pp,"referrer",jl_new_string("")); - jl_obj_set(pp,"referring_domain",jl_new_string("")); - jl_obj_set(pp,"release_channel",jl_new_string("stable")); - jl_obj_set(pp,"client_build_number",jl_new_number(300042)); jl_obj_set(dd,"properties",pp); - jl_obj_set(dd,"capabilities",jl_new_number(125)); jl_val_t *pr=jl_new_object(); jl_obj_set(pr,"status",jl_new_string("online")); jl_obj_set(pr,"activities",jl_new_array()); @@ -101,33 +63,23 @@ static void send_identify(discord_t *d, const char *token){ ws_send_text(&d->ws,s,strlen(s)); free(s); } -static void send_resume(discord_t *d){ - jl_val_t *root=jl_new_object(); - jl_obj_set(root,"op",jl_new_number(6)); - jl_val_t *dd=jl_new_object(); - jl_obj_set(dd,"token",jl_new_string(d->token)); - jl_obj_set(dd,"session_id",jl_new_string(d->session_id)); - jl_obj_set(dd,"seq",jl_new_number((double)d->seq)); - jl_obj_set(root,"d",dd); - char *s=jl_stringify(root); jl_free(root); - ws_send_text(&d->ws,s,strlen(s)); free(s); -} - -/* resume=1: reuse the stored session (fast reconnect). Otherwise IDENTIFY. */ -int discord_connect(discord_t *d, const char *token, int resume){ +int discord_connect(discord_t *d, const char *token){ memset(d,0,sizeof(*d)); strncpy(d->token, token, sizeof d->token-1); - char url[512]; fetch_gateway_url(url,sizeof url); - char host[128]; int port=443; char res[128]; - parse_gateway_url(url, host, sizeof host, &port, res, sizeof res); char key[64]=""; make_key(key); - int rc=ws_connect(&d->ws, host, port, res, key); - if(rc){ log_msg("ws connect fail %d",rc); return rc; } - d->connected=1; d->last_heartbeat=time(NULL); d->last_ack=d->last_heartbeat; - /* receive HELLO */ + int rc=ws_connect(&d->ws, GW_HOST, GW_PORT, GW_PATH, key); + if(rc){ log_msg("ws connect fail %d",rc); return -1; } + d->connected=1; + int64_t now=time(NULL); + d->last_heartbeat=now; d->last_ack=now; + /* HELLO */ char buf[2048]; int op=0,fin=0; - int nr=ws_recv_frame(&d->ws,buf,sizeof buf,&op,&fin); - if(nr<=0||op!=1){log_msg("no HELLO op=%d nr=%d",op,nr);return -1;} + int nr=rx_frame(d,buf,sizeof buf,&op,&fin,now+15); + if(nr<=0 || op!=1){ + log_msg("no HELLO (nr=%d op=%d)",nr,op); + ws_close(&d->ws); d->connected=0; + return -1; + } jl_val_t *h=jl_parse(buf,nr); if(h){ const jl_val_t *dd=jl_obj_get(h,"d"); @@ -138,37 +90,51 @@ int discord_connect(discord_t *d, const char *token, int resume){ jl_free(h); } if(!d->hb_interval_ms) d->hb_interval_ms=45000; - if(resume && d->session_id[0]){ send_resume(d); log_msg("discord: resume sent"); } - else { send_identify(d, token); } - log_msg("discord: gateway connected, hb=%lds", (long)(d->hb_interval_ms/1000)); - return 0; + send_identify(d, token); + log_msg("discord: identify sent, hb=%llds",(long long)(d->hb_interval_ms/1000)); + /* READY confirms the token was accepted */ + int64_t dl=time(NULL)+20; + for(;;){ + nr=rx_frame(d,buf,sizeof buf,&op,&fin,dl); + if(nr<=0){ log_msg("no READY after identify (nr=%d)",nr); break; } + if(op==11){ d->last_ack=time(NULL); continue; } + if(op==8){ + unsigned code = nr>=2 ? (((unsigned char)buf[0]<<8)|((unsigned char)buf[1])) : 0; + log_msg("gateway closed during auth: %u",code); + ws_close(&d->ws); d->connected=0; + return code==4004 ? -2 : -1; + } + if(op==0 && strstr(buf,"READY")){ + const char *p=strstr(buf,"\"s\":"); + if(p){ p+=4; while(*p==' ')p++; if(*p>='0'&&*p<='9') d->seq=(int)strtol(p,NULL,10); } + log_msg("discord: gateway ready"); + return 0; + } + /* other pre-READY ops: ignore */ + } + ws_close(&d->ws); d->connected=0; + return -1; } -int discord_set_presence(discord_t *d, const char *state, const char *details, - int party_size, int party_max, - const char *large_image, const char *large_text){ - jl_val_t *dd=jl_new_object(); +int discord_set_presence(discord_t *d, const char *state, const char *name, + const char *application_id, int64_t started_epoch){ + if(!d->connected) return -1; jl_val_t *act=jl_new_object(); - jl_obj_set(act,"name",jl_new_string(details?details:"")); + jl_obj_set(act,"name",jl_new_string(name?name:"")); jl_obj_set(act,"type",jl_new_number(0)); /* Playing */ - if(state) jl_obj_set(act,"state",jl_new_string(state)); - if(party_size>0 && party_max>0){ - jl_val_t *pty=jl_new_object(); - jl_obj_set(pty,"size",jl_new_array()); - jl_arr_push(jl_obj_get(pty,"size"), jl_new_number(party_size)); - jl_arr_push(jl_obj_get(pty,"size"), jl_new_number(party_max)); - jl_obj_set(act,"party",pty); - } - if(large_image || large_text){ - jl_val_t *assets=jl_new_object(); - if(large_image) jl_obj_set(assets,"large_image",jl_new_string(large_image)); - if(large_text) jl_obj_set(assets,"large_text", jl_new_string(large_text)); - jl_obj_set(act,"assets",assets); + if(state&&state[0]) jl_obj_set(act,"state",jl_new_string(state)); + if(started_epoch>0){ + jl_val_t *ts=jl_new_object(); + jl_obj_set(ts,"start",jl_new_number((double)started_epoch*1000.0)); /* ms epoch */ + jl_obj_set(act,"timestamps",ts); } + if(application_id&&application_id[0]) + jl_obj_set(act,"application_id",jl_new_string(application_id)); + jl_val_t *dd=jl_new_object(); jl_obj_set(dd,"activities",jl_new_array()); jl_arr_push(jl_obj_get(dd,"activities"), act); jl_obj_set(dd,"status",jl_new_string("online")); - jl_obj_set(dd,"since",jl_new_number((double)time(NULL))); + jl_obj_set(dd,"since",jl_new_number(0)); jl_obj_set(dd,"afk",jl_new_bool(0)); jl_val_t *root=jl_new_object(); jl_obj_set(root,"op",jl_new_number(3)); @@ -179,9 +145,12 @@ int discord_set_presence(discord_t *d, const char *state, const char *details, } int discord_clear_presence(discord_t *d){ + if(!d->connected) return -1; jl_val_t *dd=jl_new_object(); jl_obj_set(dd,"activities",jl_new_array()); - jl_obj_set(dd,"status",jl_new_string("invisible")); + jl_obj_set(dd,"status",jl_new_string("online")); /* stay visible, just idle */ + jl_obj_set(dd,"since",jl_new_number(0)); + jl_obj_set(dd,"afk",jl_new_bool(0)); jl_val_t *root=jl_new_object(); jl_obj_set(root,"op",jl_new_number(3)); jl_obj_set(root,"d",dd); @@ -193,59 +162,63 @@ int discord_clear_presence(discord_t *d){ int discord_tick(discord_t *d){ if(!d->connected) return -1; int64_t now=time(NULL); - /* heartbeat: if the gateway hasn't acked in 2 intervals, it's gone */ - if(now - d->last_ack > (d->hb_interval_ms/1000)*2 + 5){ + long hb_s=(long)(d->hb_interval_ms/1000); if(hb_s<5)hb_s=5; + /* gateway must ack heartbeats; 2 missed intervals means it's gone */ + if(d->sent_hb && now-d->last_ack > hb_s*2+15){ log_msg("heartbeat timeout (no ack)"); + d->connected=0; return -1; } - if(now - d->last_heartbeat >= d->hb_interval_ms/1000){ - char hb[64]; snprintf(hb,sizeof hb,"{\"op\":1,\"d\":%d}", d->seq); + /* fire slightly EARLY (tick cadence adds up to ~1s of jitter) so we are + * always inside the gateway's heartbeat window */ + long fire = hb_s>3 ? hb_s-2 : hb_s; + if(now-d->last_heartbeat >= fire){ + char hb[64]; + if(d->seq>0) snprintf(hb,sizeof hb,"{\"op\":1,\"d\":%d}",d->seq); + else snprintf(hb,sizeof hb,"{\"op\":1,\"d\":null}"); ws_send_text(&d->ws,hb,strlen(hb)); - d->last_heartbeat=now; + d->last_heartbeat=now; d->sent_hb=1; } - /* read any pending server frame (non-blocking) */ - char buf[1024]; int op=0,fin=0; - int nr=ws_recv_frame(&d->ws,buf,sizeof buf,&op,&fin); - if(nr<0){ return -1; } - if(nr==0){ return 0; } /* no complete frame yet — not an error */ - switch(op){ - case 0: /* DISPATCH: "s" is the sequence, "t" the event name */ - { - const char *p=strstr(buf,"\"s\":"); - if(p){ p+=4; d->seq=(int)strtol(p,NULL,10); } - const char *t=strstr(buf,"\"t\":"); - if(t && strncmp(t+4,"\"READY\"",7)==0){ - jl_val_t *r=jl_parse(buf,nr); - if(r){ - const jl_val_t *dd=jl_obj_get(r,"d"); - const jl_val_t *si=dd?jl_obj_get(dd,"session_id"):NULL; - if(si&&si->type==JL_STRING) - strncpy(d->session_id,si->str,sizeof d->session_id-1); - jl_free(r); + /* drain pending server frames (non-blocking); 2048 covers the dispatch + * envelope ({"t":..,"s":N,..) with margin — big payloads are consumed + * inside ws_recv_frame regardless of this cap */ + char buf[2048]; int op=0,fin=0; + for(int i=0;i<32;i++){ + int nr=ws_recv_frame(&d->ws,buf,sizeof buf,&op,&fin); + if(nr==-3){ log_msg("skipped oversized frame"); continue; } + if(nr==0) break; + if(nr<0){ d->connected=0; return -1; } + switch(op){ + case 0: /* DISPATCH: only the sequence matters to us */ + { + const char *p=strstr(buf,"\"s\":"); + if(p){ + p+=4; while(*p==' ')p++; + if(*p=='n'){ /* null: not a dispatch seq */ } + else if(*p>='0'&&*p<='9') d->seq=(int)strtol(p,NULL,10); } } + break; + case 7: /* RECONNECT requested */ + log_msg("gateway: reconnect requested"); + d->connected=0; + return -1; + case 9: /* INVALID_SESSION */ + log_msg("gateway: invalid session"); + d->connected=0; + return -1; + case 11: + d->last_ack=now; + break; + case 8: /* CLOSE: payload starts with a 2-byte big-endian code */ + { + unsigned code = nr>=2 ? (((unsigned char)buf[0]<<8)|((unsigned char)buf[1])) : 0; + log_msg("gateway closed: code=%u",code); + d->connected=0; + return code==4004 ? -2 : -1; + } + default: break; } - break; - case 7: /* RECONNECT: server wants us to reconnect */ - log_msg("gateway: reconnect requested"); - return -1; - case 9: /* INVALID_SESSION: resume rejected; force a fresh IDENTIFY */ - log_msg("gateway: invalid session"); - d->session_id[0]=0; - return -1; - case 11: /* HEARTBEAT_ACK */ - d->last_ack=now; - break; - default: - break; } return 0; } - -/* Reconnect: try RESUME with the stored session; falls back to IDENTIFY - * automatically (op 9 clears the session and triggers a fresh connect). */ -int discord_reconnect(discord_t *d){ - ws_close(&d->ws); d->connected=0; - int rc=discord_connect(d, d->token, 1); - return rc; -} \ No newline at end of file diff --git a/orbisrpc/discord.h b/orbisrpc/discord.h index db888ea..db7ca2e 100644 --- a/orbisrpc/discord.h +++ b/orbisrpc/discord.h @@ -1,23 +1,22 @@ -/* discord.h - gateway session + presence. */ +/* discord.h - gateway session + presence (user session token). */ #ifndef DISCORD_H #define DISCORD_H #include "ws.h" typedef struct { ws_t ws; - int32_t seq; /* last dispatch sequence (for RESUME) */ - char session_id[64]; - char token[256]; + char token[160]; int64_t last_heartbeat; /* when we last sent op 1 */ int64_t last_ack; /* when the gateway last acked (op 11) */ int64_t hb_interval_ms; + int seq; /* last dispatch sequence (heartbeat payload) */ int connected; + int sent_hb; /* at least one heartbeat sent */ } discord_t; -/* resume=1 reuses the stored session_id/seq (RESUME op 6), else IDENTIFY. */ -int discord_connect(discord_t *d, const char *token, int resume); -int discord_set_presence(discord_t *d, const char *state, const char *details, - int party_size, int party_max, const char *large_image, - const char *large_text); /* presence update (op 3) */ -int discord_clear_presence(discord_t *d); /* clear activity */ -int discord_tick(discord_t *d); /* heartbeat + keep-alive; returns <0 to reconnect */ -int discord_reconnect(discord_t *d); /* RESUME if possible, else fresh IDENTIFY */ -#endif \ No newline at end of file +/* Returns 0 on READY, -1 net/proto error, -2 auth-fatal (close 4004: don't + * retry — the token is wrong and Discord bans IPs that hammer it). */ +int discord_connect(discord_t *d, const char *token); +int discord_set_presence(discord_t *d, const char *state, const char *name, + const char *application_id, int64_t started_epoch); /* op 3 */ +int discord_clear_presence(discord_t *d); /* clear activity, stay online */ +int discord_tick(discord_t *d); /* 0 ok; -1 drop/reconnect; -2 auth-fatal */ +#endif diff --git a/orbisrpc/http.c b/orbisrpc/http.c deleted file mode 100644 index 935cd7c..0000000 --- a/orbisrpc/http.c +++ /dev/null @@ -1,117 +0,0 @@ -/* http.c - OAuth2 token endpoint via SceHttp. */ -#include "http.h" -#include "cfg.h" -#include "log.h" -#include -#include -#include -#include -#include -#include -#include -#include - -/* Plausible desktop-client UA rather than an app name (fewer obvious - * selfbot fingerprints when talking to Discord's API). */ -#define UA "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) discord/1.0.9175 Chrome/122.0.6261.112 Electron/30.0.8 Safari/537.36" - -#define NET_POOL (64 * 1024) -#define SSL_PL (128 * 1024) -#define HTTP_PL (128 * 1024) - -static int s_http_mem = 0, s_ssl_mem = 0, s_http_ctx = 0; /* ids */ - -/* NOTE: SceHttp/SSL headers declare funcs as void() stubs; we call them - * with the real argument layout (no local redeclaration to avoid conflicts). */ -static int http_init(void){ - if(s_http_ctx) return 0; - int r; - uint32_t ur = sceSysmoduleLoadModuleInternal(ORBIS_SYSMODULE_INTERNAL_NET); - r=(int)ur; - if(r<0){log_msg("load NET fail %d",r);return -1;} - if((r=sceSysmoduleLoadModuleInternal(ORBIS_SYSMODULE_INTERNAL_HTTP))<0){log_msg("load HTTP fail %d",r);return -2;} - if((r=sceSysmoduleLoadModuleInternal(ORBIS_SYSMODULE_INTERNAL_SSL))<0){log_msg("load SSL fail %d",r);return -3;} - if((r=sceNetInit())<0){log_msg("sceNetInit %d",r);return -4;} - s_http_mem=(int)sceNetPoolCreate("orbisrpcN",NET_POOL,0); if(s_http_mem<0){log_msg("netpool %d",s_http_mem);return -5;} -s_ssl_mem=(int)sceSslInit(SSL_PL); if(s_ssl_mem<0){log_msg("sslinit %d",s_ssl_mem);return -6;} - if((r=sceHttpInit(s_http_mem,s_ssl_mem,HTTP_PL))<0){log_msg("httpinit %d",r);return -7;} - s_http_ctx=r; - return 0; -} - -/* --- json helpers (avoid coupling to cfg's parser) --- */ -static void jstr(const char *js,const char *key,char *out,size_t cap){ - char k[64]; size_t kl=strlen(key); if(kl>=sizeof k){if(out)*out=0;return;} - k[0]='\"'; memcpy(k+1,key,kl); k[1+kl]='\"'; k[2+kl]=0; - const char *p=strstr(js,k); if(!p||!out){if(out)*out=0;return;} - p+=2+kl; while(*p==' '||*p==':')p++; - size_t i=0; - if(*p=='"'){ p++; while(*p&&*p!='"'&&i='0'&&*p<='9'&&i=sizeof k)return 0; - k[0]='\"'; memcpy(k+1,key,kl); k[1+kl]='\"'; k[2+kl]=0; - const char *p=strstr(js,k); if(!p)return 0; - p+=2+kl; while(*p==' '||*p==':')p++; return strtol(p,NULL,10); -} - -int http_oauth_token(const char *client_id,const char *client_secret, - const char *code,const char *refresh_token, - char *access_token,size_t at_cap, - char *refresh_out,size_t rt_cap, - int64_t *expires_out_epoch){ - if(http_init()) return -10; - int32_t tpl=0,conn=0,req=0,rc=0,status=0; - if(!(client_id&&*client_id)){log_msg("no client_id");return -11;} - char body[1024]; int blen=0; - blen+=snprintf(body+blen,sizeof body-blen,"client_id=%s",client_id?client_id:""); - if(client_secret&&*client_secret) blen+=snprintf(body+blen,sizeof body-blen,"&client_secret=%s",client_secret); - if(code&&*code) blen+=snprintf(body+blen,sizeof body-blen, - "&grant_type=authorization_code&code=%s&redirect_uri=%s", code, "http://localhost:6770/callback"); - else if(refresh_token&&*refresh_token) blen+=snprintf(body+blen,sizeof body-blen, - "&grant_type=refresh_token&refresh_token=%s", refresh_token); - else blen+=snprintf(body+blen,sizeof body-blen,"&grant_type=client_credentials&scope=applications.commands"); - const char *URL="https://discord.com/api/oauth2/token"; - tpl=sceHttpCreateTemplate(s_http_ctx, UA, ORBIS_HTTP_VERSION_1_1, 0); if(tpl<0){log_msg("tmpl %d",tpl);rc=-1;goto done;} - conn=sceHttpCreateConnectionWithURL(tpl, URL, 0); if(conn<0){log_msg("conn %d",conn);rc=-2;goto done;} - req=sceHttpCreateRequestWithURL(conn, ORBIS_METHOD_POST, URL, (uint64_t)blen); if(req<0){log_msg("req %d",req);rc=-3;goto done;} - sceHttpAddRequestHeader(req,"Content-Type","application/x-www-form-urlencoded",0); - sceHttpAddRequestHeader(req,"User-Agent",UA,0); - if((rc=sceHttpSendRequest(req,body,blen))<0){log_msg("send %d",rc);rc=-4;goto done;} - if((rc=sceHttpGetStatusCode(req,&status))<0){log_msg("stat %d",rc);rc=-5;goto done;} - char resp[2048]; int total=0,len; - while((len=sceHttpReadData(req,resp+total,sizeof resp-total))>0){total+=len; if(total>(int)sizeof resp-1)break;} - resp[total>= (int)sizeof resp? (int)sizeof resp-1:total]=0; - jstr(resp,"access_token",access_token,at_cap); - if(refresh_out) jstr(resp,"refresh_token",refresh_out,rt_cap); - long exp=jnum(resp,"expires_in"); - if(expires_out_epoch)*expires_out_epoch=time(NULL)+exp; - log_msg("oauth status=%d at=%s exp=%lds",status,access_token,(long)exp); - rc = (status==200 && access_token && *access_token) ? 0 : status; -done: - if(req>0)sceHttpDeleteRequest(req); - if(conn>0)sceHttpDeleteConnection(conn); - if(tpl>0)sceHttpDeleteTemplate(tpl); - return rc; -} - -int http_get(const char *url,char *out,size_t cap){ - if(http_init()) return -10; - int32_t tpl=0,conn=0,req=0,rc=0; - tpl=sceHttpCreateTemplate(s_http_ctx,UA,ORBIS_HTTP_VERSION_1_1,0); if(tpl<0)return -1; - conn=sceHttpCreateConnectionWithURL(tpl,url,0); if(conn<0){rc=-2;goto done;} - req=sceHttpCreateRequestWithURL(conn,ORBIS_METHOD_GET,url,0); if(req<0){rc=-3;goto done;} - sceHttpAddRequestHeader(req,"User-Agent",UA,0); - if((rc=sceHttpSendRequest(req,0,0))<0){rc=-4;goto done;} - int total=0,len; - while((len=sceHttpReadData(req,out+total,cap-1-total))>0){total+=len; if(total>(int)cap-1)break;} - out[total>= (int)cap? (int)cap-1:total]=0; - rc=0; -done: - if(req>0)sceHttpDeleteRequest(req); - if(conn>0)sceHttpDeleteConnection(conn); - if(tpl>0)sceHttpDeleteTemplate(tpl); - return rc; -} diff --git a/orbisrpc/http.h b/orbisrpc/http.h deleted file mode 100644 index 4fed2e3..0000000 --- a/orbisrpc/http.h +++ /dev/null @@ -1,14 +0,0 @@ -/* http.h - OAuth token exchange via SceHttp */ -#ifndef HTTP_H -#define HTTP_H -#include -#include -/* POST to token endpoint; fills access_token/refresh_token/expires_in. Returns 0 ok. */ -int http_oauth_token(const char *client_id, const char *client_secret, - const char *code, const char *refresh_token, - char *access_token, size_t at_cap, - char *refresh_out, size_t rt_cap, - int64_t *expires_out_epoch); -/* simple GET into caller buffer */ -int http_get(const char *url, char *out, size_t cap); -#endif \ No newline at end of file diff --git a/orbisrpc/ws.c b/orbisrpc/ws.c index a6a72f5..ae8fee4 100644 --- a/orbisrpc/ws.c +++ b/orbisrpc/ws.c @@ -1,14 +1,11 @@ /* ws.c - WebSocket client over SceNet + PS4 LibreSSL (OpenSSL-style) TLS. - * The SDK's libSceLibreSSL.so exports SSL_CTX_new / SSL_new / SSL_connect / - * SSL_write / SSL_read / SSL_shutdown / SSL_set_fd / SSL_get_error (OpenSSL ABI), - * which is what we use here for the TLS transport. - * - * The socket is put in non-blocking mode (SO_NBIO). ws_recv_frame buffers raw - * bytes and only returns when a complete unmasked server frame is parsed, so the - * daemon's poll loop never stalls on a blocking read. + * Non-blocking socket: every SSL_read/SSL_write return goes through + * SSL_get_error, so WANT_READ/WANT_WRITE means "retry", never "closed". + * The receive buffer grows for large server frames (user-account READY + * payloads are big); frames beyond WS_RBUF_MAX are drained and skipped. + * All client->server frames are masked per RFC 6455 5.3, control frames too. */ #include "ws.h" -#include "b64.h" #include "log.h" #include #include @@ -20,14 +17,12 @@ #include #include -/* OpenSSL-style SSL objects (opaque). We only use pointers + the exported API. */ typedef struct ssl_ctx_st SSL_CTX; typedef struct ssl_st SSL; /* exported from libSceLibreSSL.so (OpenSSL ABI) */ extern SSL_CTX *SSL_CTX_new(const void *method); extern void SSL_CTX_free(SSL_CTX *ctx); -extern void SSL_CTX_set_verify(SSL_CTX *ctx, int mode, void *cb); extern SSL *SSL_new(SSL_CTX *ctx); extern void SSL_free(SSL *s); extern int SSL_set_fd(SSL *s, int fd); @@ -40,7 +35,6 @@ extern int SSL_get_error(SSL *s, int ret); extern const void *SSLv23_client_method(void); extern int SSL_ctrl(SSL *s, int cmd, long larg, void *parg); -/* PS4 net constants (ps4sdk values; sparse in the OpenOrbis headers) */ #ifndef SOL_SOCKET #define SOL_SOCKET 0xffff #endif @@ -50,7 +44,6 @@ extern int SSL_ctrl(SSL *s, int cmd, long larg, void *parg); #define SSL_ERROR_WANT_READ 2 #define SSL_ERROR_WANT_WRITE 3 -/* net pool id (keep once) */ static int s_net_ready = 0; static int s_net_mem = 0; @@ -65,10 +58,38 @@ static int net_ensure(void){ return 0; } +/* Write exactly n bytes over TLS, tolerating WANT_READ/WRITE with deadline. */ +static int ws_send_all(ws_t *w, const unsigned char *data, size_t n){ + SSL *ssl = (SSL*)(intptr_t)w->ssl; + size_t off=0; + int64_t t0=time(NULL); + while(off0){ off+=(size_t)wr; continue; } + int e = SSL_get_error(ssl, wr); + if(e==SSL_ERROR_WANT_READ || e==SSL_ERROR_WANT_WRITE){ + if(time(NULL)-t0 > 10) return -1; + usleep(10000); continue; + } + return -1; + } + return (int)off; +} + +static void next_mask(unsigned char mk[4]){ + static uint32_t mk_seed; + if(!mk_seed) mk_seed = (uint32_t)time(NULL) ^ 0x9e3779b9u ^ (uint32_t)(uintptr_t)&mk_seed; + mk_seed = mk_seed*1664525u + 1013904223u; + mk[0]=(unsigned char)(mk_seed&0xff); mk[1]=(unsigned char)((mk_seed>>8)&0xff); + mk[2]=(unsigned char)((mk_seed>>16)&0xff); mk[3]=(unsigned char)((mk_seed>>24)&0xff); +} + int ws_connect(ws_t *w, const char *host, int port, const char *resource, const char *key){ memset(w,0,sizeof(*w)); - if(net_ensure()<0) return -1; - /* resolve host */ + w->rcap = WS_RBUF_MIN; + w->rbuf = (unsigned char*)malloc(w->rcap); + if(!w->rbuf){ log_msg("ws: rbuf alloc fail"); return -1; } + if(net_ensure()<0) goto fail; int32_t rid = sceNetResolverCreate("orbisrpcR", 0, 0); OrbisNetInAddr in; memset(&in,0,sizeof in); int resolved = 0; @@ -79,39 +100,41 @@ int ws_connect(ws_t *w, const char *host, int port, const char *resource, const } if(!resolved){ struct in_addr ia = { .s_addr = inet_addr(host) }; - if(ia.s_addr == 0xffffffff){ log_msg("resolve fail: %s", host); return -4; } + if(ia.s_addr == 0xffffffff){ log_msg("resolve fail: %s", host); goto fail; } in.s_addr = ia.s_addr; } - /* TCP socket */ int32_t fd = sceNetSocket("orbisrpcWs", ORBIS_NET_AF_INET, ORBIS_NET_SOCK_STREAM, 0); - if(fd < 0){ log_msg("socket fail %d",fd); return -5; } + if(fd < 0){ log_msg("socket fail %d",fd); goto fail; } + w->fd = fd; w->sock = fd; + /* Pack port + IPv4 into sa_data in network byte order. + * s_addr is a big-endian u32 value; on this LE target its FIRST octet + * lives in the HIGH byte, so shift down from the top. + * (v1 packed it low-byte-first -> reversed IPs -> connect() to nowhere.) */ OrbisNetSockaddr sa; memset(&sa,0,sizeof sa); - sa.len = (uint8_t)sizeof(sa); + sa.len = (uint8_t)sizeof sa; sa.sa_family = (OrbisNetSaFamily_t)ORBIS_NET_AF_INET; sa.sa_data[0] = (char)((port >> 8) & 0xff); sa.sa_data[1] = (char)(port & 0xff); - sa.sa_data[2] = (char)(in.s_addr & 0xff); - sa.sa_data[3] = (char)((in.s_addr >> 8) & 0xff); - sa.sa_data[4] = (char)((in.s_addr >> 16) & 0xff); - sa.sa_data[5] = (char)((in.s_addr >> 24) & 0xff); + sa.sa_data[2] = (char)((in.s_addr >> 24) & 0xff); + sa.sa_data[3] = (char)((in.s_addr >> 16) & 0xff); + sa.sa_data[4] = (char)((in.s_addr >> 8) & 0xff); + sa.sa_data[5] = (char)(in.s_addr & 0xff); if(sceNetConnect(fd, &sa, sizeof sa) < 0){ log_msg("connect fail to %s:%d", host, port); - sceNetSocketClose(fd); return -6; + goto fail; } - /* non-blocking so SSL_read never stalls the poll loop */ int on = 1; sceNetSetsockopt(fd, SOL_SOCKET, SO_NBIO, &on, sizeof on); - w->fd = fd; w->sock = fd; w->connected = 1; w->nb = 1; - /* TLS via LibreSSL. The socket is ALREADY non-blocking, so SSL_connect - * returns WANT_READ/WANT_WRITE instead of completing in one call: poll - * with a deadline until the handshake finishes or times out. */ + w->nb = 1; + /* socket already non-blocking -> SSL_connect returns WANT_*; poll it */ SSL_CTX *ctx = SSL_CTX_new(SSLv23_client_method()); if(!ctx){ log_msg("SSL_CTX_new fail"); goto fail; } SSL *ssl = SSL_new(ctx); if(!ssl){ log_msg("SSL_new fail"); SSL_CTX_free(ctx); goto fail; } - if(SSL_set_fd(ssl, (int)fd) != 1){ log_msg("SSL_set_fd fail"); SSL_free(ssl); SSL_CTX_free(ctx); goto fail; } - /* SNI: SSL_set_tlsext_host_name = SSL_ctrl(s, SSL_CTRL_SET_TLSEXT_HOSTNAME(55), - * TLSEXT_NAMETYPE_host_name(0), hostname). Discord TLS needs the host. */ + w->ssl_ctx = (int32_t)(intptr_t)ctx; /* stash early: single cleanup path */ + w->ssl = (int32_t)(intptr_t)ssl; + if(SSL_set_fd(ssl, (int)fd) != 1){ log_msg("SSL_set_fd fail"); goto fail; } + /* SNI: SSL_ctrl(s, SSL_CTRL_SET_TLSEXT_HOSTNAME(55), NAMETYPE_host_name(0), host) */ if(SSL_ctrl(ssl, 55, 0, (void *)host) != 1){ log_msg("SNI warn"); } SSL_set_connect_state(ssl); int cr=-1; int64_t t0=time(NULL); @@ -120,15 +143,13 @@ int ws_connect(ws_t *w, const char *host, int port, const char *resource, const if(cr==1) break; int e = SSL_get_error(ssl, cr); if(e==SSL_ERROR_WANT_READ || e==SSL_ERROR_WANT_WRITE){ - if(time(NULL)-t0 > 5){ log_msg("SSL_connect timeout"); goto fail; } + if(time(NULL)-t0 > 10){ log_msg("SSL_connect timeout"); goto fail; } usleep(20000); continue; } log_msg("SSL_connect fail err=%d", e); goto fail; } - w->ssl_ctx = (int32_t)(intptr_t)ctx; /* stash for teardown */ - w->ssl = (int32_t)(intptr_t)ssl; - /* HTTP Upgrade handshake. Desktop-client UA, and NO Origin header: - * native clients (unlike browsers) don't send Origin to the gateway. */ + /* HTTP Upgrade handshake. Desktop-client UA, NO Origin header (native + * clients don't send Origin to the gateway). */ char req[640]; int n=snprintf(req,sizeof req, "GET %s HTTP/1.1\r\nHost: %s:%d\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " @@ -136,10 +157,10 @@ int ws_connect(ws_t *w, const char *host, int port, const char *resource, const "Safari/537.36\r\n" "Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n\r\n", resource?resource:"/", host, port, key); - if(SSL_write(ssl, req, n) <= 0){ log_msg("SSL_write hs fail"); goto fail; } - /* read handshake response in chunks (socket is non-blocking: handle - * WANT_READ with a deadline instead of busy-waiting forever). */ - char hdr[512]; int hlen=0, rd; t0=time(NULL); + if(ws_send_all(w, (const unsigned char*)req, (size_t)n) < 0){ log_msg("hs write fail"); goto fail; } + /* Read response headers; bytes past "\r\n\r\n" are the first websocket + * frame (usually HELLO arriving early) and MUST be kept, not dropped. */ + char hdr[2048]; int hlen=0, rd; t0=time(NULL); while(hlen<(int)sizeof hdr-1){ rd = SSL_read(ssl, hdr+hlen, (int)(sizeof hdr-1-hlen)); if(rd>0){ @@ -149,123 +170,152 @@ int ws_connect(ws_t *w, const char *host, int port, const char *resource, const } int e = SSL_get_error(ssl, rd); if(e==SSL_ERROR_WANT_READ || e==SSL_ERROR_WANT_WRITE){ - if(time(NULL)-t0 > 5){ log_msg("hs timeout"); goto fail; } + if(time(NULL)-t0 > 10){ log_msg("hs timeout"); goto fail; } usleep(20000); continue; } - goto fail; /* EOF or real error before 101 */ + goto fail; } if(hlen<12 || !strstr(hdr,"101")){ log_msg("no 101: %.40s", hdr); goto fail; } + w->connected = 1; + const char *body = strstr(hdr,"\r\n\r\n"); + if(body){ + body += 4; + size_t bl = (size_t)(hdr+hlen-body); + if(bl > w->rcap) bl = w->rcap; + if(bl){ memcpy(w->rbuf, body, bl); w->rlen = bl; } + } log_msg("ws: connected (handshake ok)"); return 0; fail: - if(w->ssl) SSL_free((SSL*)(intptr_t)w->ssl); + if(w->ssl) SSL_free((SSL*)(intptr_t)w->ssl); if(w->ssl_ctx) SSL_CTX_free((SSL_CTX*)(intptr_t)w->ssl_ctx); - sceNetSocketClose(w->fd); + if(w->fd > 0) sceNetSocketClose(w->fd); + free(w->rbuf); w->rbuf=NULL; w->rcap=0; w->connected=0; w->ssl=0; w->ssl_ctx=0; w->fd=0; w->sock=0; return -9; } -/* --- send ----------------------------------------------------------- */ int ws_send_text(ws_t *w, const char *msg, size_t len){ if(!w->connected) return -1; - if(len > 4080){ log_msg("ws frame too big (%zu); refusing", len); return -1; } - unsigned char frame[4096]; size_t f=0; - frame[f++]=0x81; - if(len<126){ frame[f++]=(unsigned char)(0x80|len); } - else if(len<65536){ frame[f++]=0x80|126; frame[f++]=(len>>8)&0xff; frame[f++]=len&0xff; } - else { frame[f++]=0x80|127; for(int i=7;i>=0;i--)frame[f++]=(len>>(i*8))&0xff; } - /* RFC 6455 5.3: a fresh random mask per frame. Cheap LCG seeded per socket. */ - static uint32_t mk_seed; - if(!mk_seed) mk_seed = (uint32_t)time(NULL) ^ (uint32_t)(uintptr_t)w ^ 0x9e3779b9u; - mk_seed = mk_seed*1664525u + 1013904223u; - unsigned char mk[4]; - mk[0]=(unsigned char)(mk_seed&0xff); mk[1]=(unsigned char)((mk_seed>>8)&0xff); - mk[2]=(unsigned char)((mk_seed>>16)&0xff); mk[3]=(unsigned char)((mk_seed>>24)&0xff); - memcpy(frame+f, mk, 4); f+=4; - for(size_t i=0;issl, frame, (int)f); + if(len > 16384){ log_msg("ws frame too big (%zu); refusing", len); return -1; } + unsigned char mk[4]; next_mask(mk); + unsigned char hdr[14]; size_t f=0; + hdr[f++]=0x81; /* FIN | text */ + if(len<126){ hdr[f++]=(unsigned char)(0x80|len); } + else { hdr[f++]=0x80|126; hdr[f++]=(unsigned char)((len>>8)&0xff); hdr[f++]=(unsigned char)(len&0xff); } + memcpy(hdr+f, mk, 4); f+=4; + unsigned char *buf=(unsigned char*)malloc(f+len); + if(!buf) return -1; + memcpy(buf,hdr,f); + for(size_t i=0;iconnected) return -1; - unsigned char p[2]={0x89,0x00}; - return SSL_write((SSL*)(intptr_t)w->ssl, p, 2); + unsigned char mk[4]; next_mask(mk); + unsigned char f[6] = { (unsigned char)(0x80|op), 0x80, mk[0], mk[1], mk[2], mk[3] }; + return ws_send_all(w, f, 6); } -/* RFC 6455: pong frames must be sent in reply to server pings. */ -int ws_send_pong(ws_t *w){ - if(!w->connected) return -1; - unsigned char p[2]={0x8A,0x00}; - return SSL_write((SSL*)(intptr_t)w->ssl, p, 2); -} - -/* --- recv (non-blocking, buffered) ---------------------------------- */ -/* Try to parse one complete server frame starting at rbuf[rpos]. - * On success fills *fin and *opcode, copies payload into buf (truncated to cap), - * advances rpos past the frame, and returns the number of bytes copied into buf - * (may be less than the full payload length for oversized frames). - * Returns 0 if an incomplete frame is pending. */ -static int try_parse_frame(ws_t *w, char *buf, size_t cap, int *opcode_out, int *fin_out){ - const unsigned char *b = w->rbuf + w->rpos; +/* Peek at the pending frame header without consuming. 1 = full header ready. */ +static int peek_frame(ws_t *w, size_t *hdr_out, uint64_t *plen_out){ size_t avail = w->rlen - w->rpos; + const unsigned char *b = w->rbuf + w->rpos; if(avail < 2) return 0; - int fin = (b[0]&0x80)!=0, op = b[0]&0x0f; size_t plen = b[1]&0x7f, hdr = 2; if(plen==126){ if(avail < 4) return 0; - plen = (b[2]<<8)|b[3]; hdr=4; + plen = (size_t)((b[2]<<8)|b[3]); hdr=4; } else if(plen==127){ if(avail < 10) return 0; - plen=0; for(int i=2;i<10;i++) plen=(plen<<8)|b[i]; hdr=10; + plen=0; for(int i=2;i<10;i++) plen=(plen<<8)|b[i]; + hdr=10; } - if(avail < hdr+plen) return 0; /* not complete yet */ - const unsigned char *p = b+hdr; - /* server frames are unmasked; handle mask bit defensively */ - size_t copy = plen; if(copy>cap-1) copy=cap-1; - memcpy(buf, p, copy); - buf[copy]=0; - size_t consumed = hdr+plen; - w->rpos += consumed; - if(w->rpos >= w->rlen){ w->rpos = 0; w->rlen = 0; } - if(opcode_out)*opcode_out=op; if(fin_out)*fin_out=fin; - return (int)copy; /* only what actually landed in buf */ + *hdr_out=hdr; *plen_out=(uint64_t)plen; + return 1; } int ws_recv_frame(ws_t *w, char *buf, size_t cap, int *opcode_out, int *fin_out){ if(!w->connected) return -1; + SSL *ssl = (SSL*)(intptr_t)w->ssl; for(;;){ - /* compact the buffer when it's been fully consumed */ - if(w->rpos==0 && w->rlen==0) { /* empty */ } - else if(w->rpos>0){ + /* compact consumed bytes to the front */ + if(w->rpos>0){ memmove(w->rbuf, w->rbuf+w->rpos, w->rlen-w->rpos); w->rlen -= w->rpos; w->rpos = 0; } - int got = try_parse_frame(w, buf, cap, opcode_out, fin_out); - if(got != 0) return got; - /* need more bytes */ - if(w->rlen >= sizeof w->rbuf){ /* buffer full, frame too big */ - w->rlen = 0; w->rpos = 0; return -2; + if(w->skip_left>0){ + /* draining an oversized frame: drop whatever is buffered */ + size_t have = w->rlen - w->rpos; + size_t take = have < (size_t)w->skip_left ? have : (size_t)w->skip_left; + w->skip_left -= take; w->rpos += take; + if(w->rpos >= w->rlen){ w->rpos=0; w->rlen=0; } + if(w->skip_left==0){ + if(opcode_out)*opcode_out=w->skip_op; + return -3; /* whole oversized frame skipped */ + } + } else { + size_t hdr; uint64_t plen; + if(peek_frame(w,&hdr,&plen)){ + const unsigned char *b = w->rbuf + w->rpos; + int fin=(b[0]&0x80)!=0, wire_op=b[0]&0x0f; + /* absurd length: skip BEFORE arithmetic (hdr+plen could + * overflow uint64 and smuggle a tiny "total" past the cap) */ + if(plen > WS_RBUF_MAX){ + size_t avail = w->rlen - w->rpos; + size_t h = (hdr < avail) ? hdr : avail; + w->rpos += h; /* eat the header */ + size_t payload_here = avail - h; + w->skip_left = plen - (uint64_t)payload_here; + w->skip_op = wire_op; + if(w->rpos >= w->rlen){ w->rpos=0; w->rlen=0; } + continue; + } + uint64_t total = (uint64_t)hdr + plen; + if(total > (uint64_t)w->rcap){ + size_t ncap=w->rcap; + while(ncap < (size_t)total && ncap < WS_RBUF_MAX) ncap*=2; + unsigned char *nb=(unsigned char*)realloc(w->rbuf,ncap); + if(nb){ w->rbuf=nb; w->rcap=ncap; log_msg("ws: rbuf grown to %zu", ncap); } + else { log_msg("ws: rbuf grow fail"); } + } + if((uint64_t)(w->rlen-w->rpos) >= total){ + size_t copy = (size_t)plen; + if(copy > cap-1) copy = cap-1; + memcpy(buf, b+hdr, copy); + buf[copy]=0; + w->rpos += (size_t)total; + if(w->rpos >= w->rlen){ w->rpos=0; w->rlen=0; } + if(opcode_out)*opcode_out=wire_op; + if(fin_out)*fin_out=fin; + return (int)copy; + } + } + if(w->rlen == w->rcap){ /* full with no parseable frame: give up cleanly */ + w->rpos=0; w->rlen=0; return -2; + } } - int rd = SSL_read((SSL*)(intptr_t)w->ssl, w->rbuf+w->rlen, (int)(sizeof w->rbuf-w->rlen)); - if(rd > 0){ w->rlen += rd; continue; } - if(rd == 0){ - int e = SSL_get_error((SSL*)(intptr_t)w->ssl, rd); - if(e==SSL_ERROR_WANT_READ || e==SSL_ERROR_WANT_WRITE) return 0; /* no data yet */ - return -1; /* connection closed */ - } - return -1; + int rd = SSL_read(ssl, w->rbuf+w->rlen, (int)(w->rcap-w->rlen)); + if(rd > 0){ w->rlen += (size_t)rd; continue; } + int e = SSL_get_error(ssl, rd); + if(e==SSL_ERROR_WANT_READ || e==SSL_ERROR_WANT_WRITE) return 0; /* no data yet */ + return -1; /* closed / error */ } } int ws_close(ws_t *w){ - if(!w->connected) return 0; - unsigned char c[2]={0x88,0x00}; - SSL_write((SSL*)(intptr_t)w->ssl, c, 2); + if(!w->connected){ free(w->rbuf); w->rbuf=NULL; w->rcap=0; return 0; } + ws_send_control(w, 0x8); /* best-effort masked CLOSE */ SSL_shutdown((SSL*)(intptr_t)w->ssl); SSL_free((SSL*)(intptr_t)w->ssl); SSL_CTX_free((SSL_CTX*)(intptr_t)w->ssl_ctx); sceNetSocketClose(w->fd); + free(w->rbuf); w->rbuf=NULL; w->rcap=0; w->connected=0; w->sock=0; w->ssl=0; w->ssl_ctx=0; w->fd=0; w->rlen=0; w->rpos=0; + w->skip_left=0; return 0; } diff --git a/orbisrpc/ws.h b/orbisrpc/ws.h index 2468d0a..bc50a14 100644 --- a/orbisrpc/ws.h +++ b/orbisrpc/ws.h @@ -6,23 +6,25 @@ #include #include -#define WS_RBUF 16384 /* raw socket buffer (large enough for gateway dispatches) */ +#define WS_RBUF_MIN 16384 /* initial raw socket buffer */ +#define WS_RBUF_MAX (2*1024*1024) /* grow limit; bigger frames are skipped */ typedef struct { int32_t sock; int32_t ssl; int32_t ssl_ctx; int32_t connected; int32_t fd; - int nb; /* underlying socket non-blocking flag */ - unsigned char rbuf[WS_RBUF]; /* raw bytes from SSL_read */ - size_t rlen; /* valid bytes in rbuf */ - size_t rpos; /* consumed parse position */ + int nb; /* underlying socket non-blocking flag */ + unsigned char *rbuf; /* raw bytes from SSL_read (heap, grows) */ + size_t rcap; /* allocated size of rbuf */ + size_t rlen; /* valid bytes in rbuf */ + size_t rpos; /* consumed parse position */ + uint64_t skip_left; /* bytes left of an oversized frame being drained */ + int skip_op; /* opcode of the frame being drained */ } ws_t; int ws_connect(ws_t *w, const char *host, int port, const char *resource, const char *key); int ws_send_text(ws_t *w, const char *msg, size_t len); -int ws_send_ping(ws_t *w); -int ws_send_pong(ws_t *w); -/* Returns frame payload length (>0), 0 if no complete frame yet, <0 on error. - * Server frames are unmasked (RFC 6455). */ +/* Returns frame payload length (>0), 0 if no complete frame yet, <0 on error, + * or -3 if an oversized frame was drained and skipped (payload lost). */ int ws_recv_frame(ws_t *w, char *buf, size_t cap, int *opcode_out, int *fin_out); int ws_close(ws_t *w); #endif diff --git a/plugin/Makefile b/plugin/Makefile index f57f7ed..589fe7d 100644 --- a/plugin/Makefile +++ b/plugin/Makefile @@ -20,13 +20,13 @@ INTDIR := $(BUILD_DIR) # Our daemon sources (payload main.c is NOT part of the plugin; plugin.c is the entry) DAEMON_SRC := $(ORBISRPC)/log.c $(ORBISRPC)/cfg.c $(ORBISRPC)/jsonlite.c $(ORBISRPC)/b64.c \ - $(ORBISRPC)/http.c $(ORBISRPC)/ws.c $(ORBISRPC)/discord.c $(ORBISRPC)/detect.c \ + $(ORBISRPC)/ws.c $(ORBISRPC)/discord.c $(ORBISRPC)/detect.c \ $(ORBISRPC)/daemon.c PLUGIN_SRC := $(SRCDIR)/plugin.c CFILES := $(PLUGIN_SRC) $(DAEMON_SRC) OBJS := $(patsubst %.c,$(INTDIR)/%.o,$(notdir $(CFILES))) -LIBS := -lSceLibcInternal -lSceNet -lSceNetCtl -lSceLibreSSL -lSceSsl -lSceHttp \ +LIBS := -lSceLibcInternal -lSceNet -lSceNetCtl -lSceLibreSSL \ -lSceSysmodule -lSceUserService -lSceAppInstUtil -lSceAppContent \ -lGoldHEN_Hook -lkernel diff --git a/scripts/build.sh b/scripts/build.sh index 17510e0..85431f8 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -11,7 +11,7 @@ TARGET="x86_64-pc-freebsd12-elf" CFLAGS="--target=$TARGET -fPIC -std=gnu11 -Wall -Wno-unused \ -Wno-int-conversion -Wno-incompatible-pointer-types \ -isystem $SDK/include" -LIBS="-lc -lkernel -lSceNet -lSceNetCtl -lSceLibreSSL -lSceSsl -lSceHttp -lSceSysmodule \ +LIBS="-lc -lkernel -lSceNet -lSceNetCtl -lSceLibreSSL -lSceSysmodule \ -lSceUserService -lSceAppInstUtil -lSceAppContent" LDFLAGS="-m elf_x86_64 -pie --eh-frame-hdr -L$SDK/lib $LIBS $SDK/lib/crt1.o --script $SDK/link.x" ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -19,7 +19,7 @@ cd "$ROOT" export OO_PS4_TOOLCHAIN="$SDK" OUT="$ROOT/build"; mkdir -p "$OUT" echo "=== compiling ===" -for f in log cfg jsonlite b64 http ws detect discord daemon main; do +for f in log cfg jsonlite b64 ws detect discord daemon main; do "$CC" $CFLAGS -c -o "$OUT/$f.o" "orbisrpc/$f.c" || { echo "compile $f FAILED"; exit 1; } done echo "=== linking ($LD) ==="