diff --git a/client.js b/client.js index 8e14217..8a28eeb 100644 --- a/client.js +++ b/client.js @@ -1,8 +1,8 @@ const SteamUser = require('steam-user'); const SteamCommunity = require('steamcommunity'); -const fs = require('fs'); const winston = require("winston"); const config = require("./config.js"); +const { createSteamLifecycle } = require('./steam-lifecycle'); const logger = winston.createLogger({ level: 'info', @@ -18,65 +18,14 @@ const users = {}; const steamUser = new SteamUser(); const steamCommunity = new SteamCommunity(); -steamUser.setOption("renewRefreshTokens", true); -steamUser.on("refreshToken", (refreshToken) => { - logger.info("Refresh token: " + refreshToken); - fs.writeFileSync('refresh.token', refreshToken); -}); - -try { - const refreshToken = fs.readFileSync('refresh.token', 'utf8'); - - if (refreshToken && refreshToken.length > 0) { - const LogOnOptionsAUTO = { - logonID: config.logonID, - refreshToken: refreshToken, - steamID: config.steamID, - }; - steamUser.logOn(LogOnOptionsAUTO); - } else { - steamUser.logOn({ - accountName: config.accountName, - password: config.password, - logonID: config.logonID, - steamID: config.steamID, - }); - } -} catch (e) { - logger.warn(`failed to load session: ${e.message}`) - - steamUser.logOn({ - accountName: config.accountName, - password: config.password, - logonID: config.logonID, - steamID: config.steamID, - }); -} - -const steamLoginPromise = new Promise((resolve, reject) => { - steamUser.on('loggedOn', async () => { - logger.info(`login to Steam as ${steamUser.steamID}`); - try { - steamUser.webLogOn(); - } catch (err) { - logger.warn(`failed to start web login: ${err.message}`); - } - - resolve(); - }); -}); - -const steamWebLoginPromise = new Promise((resolve, reject) => { - steamUser.on('webSession', async (sessionID, cookies) => { - logger.info(`web session received: ${sessionID}`); - - steamCommunity.setCookies(cookies); - if (config.identitySecret) { - steamCommunity.startConfirmationChecker(10000, config.identitySecret); - } - - resolve() - }); +const { + steamLoginPromise, + steamWebLoginPromise, +} = createSteamLifecycle({ + steamUser, + steamCommunity, + logger, + config, }); async function getUserInfo(steamID, onUserInfoReceived) { diff --git a/steam-lifecycle.js b/steam-lifecycle.js new file mode 100644 index 0000000..66c96c2 --- /dev/null +++ b/steam-lifecycle.js @@ -0,0 +1,318 @@ +const fs = require('fs'); +const EResult = require('steam-user/enums/EResult'); + +const DEFAULT_INITIAL_RETRY_DELAY_MS = 5000; +const DEFAULT_MAX_RETRY_DELAY_MS = 5 * 60 * 1000; + +const RECOVERABLE_ERESULTS = new Set([ + EResult.Invalid, + EResult.Fail, + EResult.NoConnection, + EResult.Busy, + EResult.Timeout, + EResult.ServiceUnavailable, + EResult.TryAnotherCM, +]); + +const NON_RECOVERABLE_ERESULTS = new Set([ + EResult.InvalidPassword, + EResult.LoggedInElsewhere, + EResult.Banned, + EResult.AccountNotFound, + EResult.InvalidSteamID, + EResult.AccountDisabled, + EResult.AlreadyLoggedInElsewhere, + EResult.Suspended, + EResult.PasswordUnset, + EResult.IllegalPassword, + EResult.AccountLogonDenied, + EResult.InvalidLoginAuthCode, + EResult.AccountLogonDeniedNoMail, + EResult.ExpiredLoginAuthCode, + EResult.IPLoginRestrictionFailed, + EResult.AccountLockedDown, + EResult.AccountLogonDeniedVerifiedEmailRequired, + EResult.RequirePasswordReEntry, + EResult.RateLimitExceeded, + EResult.AccountLoginDeniedNeedTwoFactor, + EResult.AccountLoginDeniedThrottle, + EResult.TwoFactorCodeMismatch, + EResult.TimeNotSynced, + EResult.NeedCaptcha, + EResult.IPBanned, + EResult.LimitedUserAccount, +].filter((value) => typeof value === 'number')); + +const NON_RECOVERABLE_MESSAGE_PATTERNS = [ + /invalid password/i, + /invalid refresh token/i, + /refreshToken is not/i, + /not valid for logging in/i, + /does not match refreshToken/i, + /steam guard/i, + /two[- ]?factor/i, + /account login denied/i, + /logged in elsewhere/i, +]; + +const RECOVERABLE_MESSAGE_PATTERNS = [ + /no steam servers available/i, + /no connection/i, + /service unavailable/i, + /try another cm/i, + /timeout/i, + /timed out/i, + /econnreset/i, + /econnrefused/i, + /enotfound/i, + /eai_again/i, + /socket/i, + /tls/i, + /network/i, + /rate limit/i, +]; + +function getEResultName(eresult) { + if (eresult === undefined || eresult === null) { + return undefined; + } + + return EResult[eresult] || String(eresult); +} + +function buildLogOnOptions(config, fsModule = fs, refreshTokenPath = 'refresh.token') { + try { + const refreshToken = fsModule.readFileSync(refreshTokenPath, 'utf8').trim(); + + if (refreshToken.length > 0) { + return { + options: { + logonID: config.logonID, + refreshToken, + steamID: config.steamID, + }, + mode: 'refreshToken', + }; + } + } catch (err) { + // Missing or unreadable refresh tokens are expected on first start. + } + + return { + options: { + accountName: config.accountName, + password: config.password, + logonID: config.logonID, + steamID: config.steamID, + }, + mode: 'credentials', + }; +} + +function isRecoverableSteamError(err) { + if (!err) { + return true; + } + + const message = String(err.message || err); + if (NON_RECOVERABLE_ERESULTS.has(err.eresult)) { + return false; + } + + if (NON_RECOVERABLE_MESSAGE_PATTERNS.some((pattern) => pattern.test(message))) { + return false; + } + + if (RECOVERABLE_ERESULTS.has(err.eresult)) { + return true; + } + + if (RECOVERABLE_MESSAGE_PATTERNS.some((pattern) => pattern.test(message))) { + return true; + } + + return false; +} + +function createSteamLifecycle({ + steamUser, + steamCommunity, + logger, + config, + fsModule = fs, + setTimeoutFn = setTimeout, + clearTimeoutFn = clearTimeout, + refreshTokenPath = 'refresh.token', + initialRetryDelayMs = DEFAULT_INITIAL_RETRY_DELAY_MS, + maxRetryDelayMs = DEFAULT_MAX_RETRY_DELAY_MS, +}) { + let loginResolved = false; + let loginRejected = false; + let retryDelayMs = initialRetryDelayMs; + let retryTimer = null; + let confirmationCheckerStarted = false; + + let resolveLogin; + let rejectLogin; + const steamLoginPromise = new Promise((resolve, reject) => { + resolveLogin = resolve; + rejectLogin = reject; + }); + + let resolveWebLogin; + const steamWebLoginPromise = new Promise((resolve) => { + resolveWebLogin = resolve; + }); + + function clearRetryTimer() { + if (retryTimer) { + clearTimeoutFn(retryTimer); + retryTimer = null; + } + } + + function logOn(reason) { + const { options, mode } = buildLogOnOptions(config, fsModule, refreshTokenPath); + + logger.info('steam logon started', { + reason, + mode, + steamID: config.steamID, + }); + + steamUser.logOn(options); + } + + function scheduleRetry(err) { + if (retryTimer) { + logger.warn('steam reconnect already scheduled', { + delayMs: retryDelayMs, + error: err && err.message, + eresult: err && err.eresult, + eresultName: err && getEResultName(err.eresult), + }); + return; + } + + const delayMs = retryDelayMs; + logger.warn('steam reconnect scheduled', { + delayMs, + error: err && err.message, + eresult: err && err.eresult, + eresultName: err && getEResultName(err.eresult), + }); + + retryTimer = setTimeoutFn(() => { + retryTimer = null; + retryDelayMs = Math.min(retryDelayMs * 2, maxRetryDelayMs); + + try { + logOn('retry'); + } catch (retryErr) { + logger.error('steam reconnect attempt failed to start', { + error: retryErr.message, + }); + scheduleRetry(retryErr); + } + }, delayMs); + } + + steamUser.setOption('renewRefreshTokens', true); + + steamUser.on('refreshToken', (refreshToken) => { + logger.info('steam refresh token received'); + + try { + fsModule.writeFileSync(refreshTokenPath, refreshToken); + } catch (err) { + logger.error('failed to write steam refresh token', { + error: err.message, + }); + } + }); + + steamUser.on('loggedOn', () => { + clearRetryTimer(); + retryDelayMs = initialRetryDelayMs; + loginResolved = true; + + logger.info(`login to Steam as ${steamUser.steamID}`); + + try { + steamUser.webLogOn(); + } catch (err) { + logger.warn(`failed to start web login: ${err.message}`); + } + + resolveLogin(); + }); + + steamUser.on('disconnected', (eresult, msg) => { + logger.warn('steam disconnected', { + eresult, + eresultName: getEResultName(eresult), + message: msg, + autoRelogin: steamUser.options && steamUser.options.autoRelogin, + }); + }); + + steamUser.on('error', (err) => { + const recoverable = isRecoverableSteamError(err); + + logger.error('steam error', { + error: err && err.message, + eresult: err && err.eresult, + eresultName: err && getEResultName(err.eresult), + recoverable, + }); + + if (recoverable) { + scheduleRetry(err); + return; + } + + clearRetryTimer(); + + if (!loginResolved && !loginRejected) { + loginRejected = true; + rejectLogin(err); + } + }); + + steamUser.on('webSession', (sessionID, cookies) => { + logger.info(`web session received: ${sessionID}`); + + steamCommunity.setCookies(cookies); + if (config.identitySecret && !confirmationCheckerStarted) { + try { + steamCommunity.startConfirmationChecker(10000, config.identitySecret); + confirmationCheckerStarted = true; + } catch (err) { + logger.warn('failed to start confirmation checker', { + error: err.message, + }); + } + } + + resolveWebLogin(); + }); + + logOn('initial'); + + return { + steamLoginPromise, + steamWebLoginPromise, + isRecoverableSteamError, + buildLogOnOptions, + getEResultName, + }; +} + +module.exports = { + DEFAULT_INITIAL_RETRY_DELAY_MS, + DEFAULT_MAX_RETRY_DELAY_MS, + buildLogOnOptions, + createSteamLifecycle, + getEResultName, + isRecoverableSteamError, +}; diff --git a/test/steam-lifecycle.test.js b/test/steam-lifecycle.test.js new file mode 100644 index 0000000..9b66b54 --- /dev/null +++ b/test/steam-lifecycle.test.js @@ -0,0 +1,321 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { EventEmitter } = require('node:events'); + +const EResult = require('steam-user/enums/EResult'); +const { + buildLogOnOptions, + createSteamLifecycle, + isRecoverableSteamError, +} = require('../steam-lifecycle'); + +class FakeSteamUser extends EventEmitter { + constructor() { + super(); + this.options = { autoRelogin: true }; + this.logOnCalls = []; + this.webLogOnCalls = 0; + this.steamID = null; + } + + setOption(name, value) { + this.options[name] = value; + } + + logOn(options) { + this.logOnCalls.push(options); + } + + webLogOn() { + this.webLogOnCalls += 1; + } +} + +function createLogger() { + const entries = []; + return { + entries, + info(message, meta) { + entries.push({ level: 'info', message, meta }); + }, + warn(message, meta) { + entries.push({ level: 'warn', message, meta }); + }, + error(message, meta) { + entries.push({ level: 'error', message, meta }); + }, + }; +} + +function createFsModule(token = '') { + const writes = []; + return { + writes, + readFileSync(path, encoding) { + assert.equal(path, 'refresh.token'); + assert.equal(encoding, 'utf8'); + if (!token) { + const err = new Error('missing'); + err.code = 'ENOENT'; + throw err; + } + return token; + }, + writeFileSync(path, data) { + writes.push({ path, data }); + }, + }; +} + +function createTimerControls() { + const timers = []; + return { + timers, + setTimeoutFn(fn, delayMs) { + const timer = { fn, delayMs, cleared: false }; + timers.push(timer); + return timer; + }, + clearTimeoutFn(timer) { + timer.cleared = true; + }, + runNextTimer() { + const timer = timers.find((item) => !item.cleared && !item.ran); + assert.ok(timer, 'expected a pending timer'); + timer.ran = true; + timer.fn(); + return timer; + }, + }; +} + +function createLifecycle(overrides = {}) { + const steamUser = overrides.steamUser || new FakeSteamUser(); + const steamCommunity = overrides.steamCommunity || { + cookies: [], + checkerCalls: [], + setCookies(cookies) { + this.cookies.push(cookies); + }, + startConfirmationChecker(intervalMs, identitySecret) { + this.checkerCalls.push({ intervalMs, identitySecret }); + }, + }; + const logger = overrides.logger || createLogger(); + const fsModule = overrides.fsModule || createFsModule(overrides.refreshToken); + const timerControls = overrides.timerControls || createTimerControls(); + const config = { + accountName: 'account', + password: 'password', + logonID: 123, + steamID: 'self-id', + identitySecret: 'identity-secret', + ...overrides.config, + }; + + const lifecycle = createSteamLifecycle({ + steamUser, + steamCommunity, + logger, + config, + fsModule, + setTimeoutFn: timerControls.setTimeoutFn, + clearTimeoutFn: timerControls.clearTimeoutFn, + initialRetryDelayMs: 10, + maxRetryDelayMs: 40, + }); + + return { + lifecycle, + steamUser, + steamCommunity, + logger, + fsModule, + timerControls, + }; +} + +test('buildLogOnOptions uses refresh token when available', () => { + const fsModule = createFsModule(' token-value \n'); + + assert.deepEqual(buildLogOnOptions({ + accountName: 'account', + password: 'password', + logonID: 7, + steamID: 'self-id', + }, fsModule), { + mode: 'refreshToken', + options: { + logonID: 7, + refreshToken: 'token-value', + steamID: 'self-id', + }, + }); +}); + +test('buildLogOnOptions falls back to credentials without a refresh token', () => { + const fsModule = createFsModule(''); + + assert.deepEqual(buildLogOnOptions({ + accountName: 'account', + password: 'password', + logonID: 7, + steamID: 'self-id', + }, fsModule), { + mode: 'credentials', + options: { + accountName: 'account', + password: 'password', + logonID: 7, + steamID: 'self-id', + }, + }); +}); + +test('steam disconnected logs warning and lets steam-user autoRelogin handle reconnect', () => { + const { steamUser, logger } = createLifecycle(); + + assert.equal(steamUser.logOnCalls.length, 1); + + steamUser.emit('disconnected', EResult.NoConnection, 'connection closed'); + + assert.equal(steamUser.logOnCalls.length, 1); + assert.deepEqual(logger.entries.find((entry) => entry.message === 'steam disconnected'), { + level: 'warn', + message: 'steam disconnected', + meta: { + eresult: EResult.NoConnection, + eresultName: 'NoConnection', + message: 'connection closed', + autoRelogin: true, + }, + }); +}); + +test('recoverable steam errors schedule a single retry and re-log on', () => { + const { steamUser, logger, timerControls } = createLifecycle({ + refreshToken: 'refresh-token', + }); + + const err = new Error('No Steam servers available'); + err.eresult = EResult.NoConnection; + + steamUser.emit('error', err); + steamUser.emit('error', err); + + assert.equal(timerControls.timers.length, 1); + assert.equal(timerControls.timers[0].delayMs, 10); + assert.equal(steamUser.logOnCalls.length, 1); + assert.equal(logger.entries.filter((entry) => entry.message === 'steam reconnect scheduled').length, 1); + assert.equal(logger.entries.filter((entry) => entry.message === 'steam reconnect already scheduled').length, 1); + + timerControls.runNextTimer(); + + assert.equal(steamUser.logOnCalls.length, 2); + assert.deepEqual(steamUser.logOnCalls[1], { + logonID: 123, + refreshToken: 'refresh-token', + steamID: 'self-id', + }); +}); + +test('non-recoverable initial steam errors reject login and do not retry', async () => { + const { lifecycle, steamUser, timerControls } = createLifecycle(); + const err = new Error('InvalidPassword'); + err.eresult = EResult.InvalidPassword; + + steamUser.emit('error', err); + + await assert.rejects(lifecycle.steamLoginPromise, err); + assert.equal(timerControls.timers.length, 0); +}); + +test('loggedOn clears pending retry, resets backoff, resolves login, and starts web login', async () => { + const { lifecycle, steamUser, timerControls } = createLifecycle({ + refreshToken: 'refresh-token', + }); + const err = new Error('timeout'); + err.eresult = EResult.Timeout; + + steamUser.emit('error', err); + assert.equal(timerControls.timers.length, 1); + + steamUser.steamID = 'self-id'; + steamUser.emit('loggedOn'); + await lifecycle.steamLoginPromise; + + assert.equal(timerControls.timers[0].cleared, true); + assert.equal(steamUser.webLogOnCalls, 1); + + steamUser.emit('error', err); + assert.equal(timerControls.timers.length, 2); + assert.equal(timerControls.timers[1].delayMs, 10); +}); + +test('webSession refreshes cookies repeatedly but starts confirmation checker once', async () => { + const { lifecycle, steamUser, steamCommunity } = createLifecycle(); + + steamUser.emit('webSession', 'session-1', ['cookie-1']); + steamUser.emit('webSession', 'session-2', ['cookie-2']); + + await lifecycle.steamWebLoginPromise; + assert.deepEqual(steamCommunity.cookies, [['cookie-1'], ['cookie-2']]); + assert.deepEqual(steamCommunity.checkerCalls, [{ + intervalMs: 10000, + identitySecret: 'identity-secret', + }]); +}); + +test('refreshToken writes token and logs write failures without creating connection log files', () => { + const fsModule = createFsModule(); + const logger = createLogger(); + const { steamUser } = createLifecycle({ fsModule, logger }); + + steamUser.emit('refreshToken', 'new-token'); + + assert.deepEqual(fsModule.writes, [{ + path: 'refresh.token', + data: 'new-token', + }]); + + const failingFsModule = { + readFileSync() { + const err = new Error('missing'); + err.code = 'ENOENT'; + throw err; + }, + writeFileSync() { + throw new Error('disk full'); + }, + }; + const failingLogger = createLogger(); + const { steamUser: failingSteamUser } = createLifecycle({ + fsModule: failingFsModule, + logger: failingLogger, + }); + + failingSteamUser.emit('refreshToken', 'new-token'); + + assert.equal( + failingLogger.entries.some((entry) => entry.message === 'failed to write steam refresh token'), + true + ); +}); + +test('isRecoverableSteamError classifies expected recoverable and fatal errors', () => { + const networkErr = new Error('socket disconnected before secure TLS connection was established'); + networkErr.eresult = EResult.Fail; + assert.equal(isRecoverableSteamError(networkErr), true); + + const authErr = new Error('InvalidPassword'); + authErr.eresult = EResult.InvalidPassword; + assert.equal(isRecoverableSteamError(authErr), false); + + const replacedSessionErr = new Error('LoggedInElsewhere'); + replacedSessionErr.eresult = EResult.LoggedInElsewhere; + assert.equal(isRecoverableSteamError(replacedSessionErr), false); + + const throttledErr = new Error('AccountLoginDeniedThrottle'); + throttledErr.eresult = EResult.AccountLoginDeniedThrottle; + assert.equal(isRecoverableSteamError(throttledErr), false); +});