8000 fix: 올리브영 중계의 안전 복구와 의존성 보안 보완 by hmmhmmhm · Pull Request #311 · hmmhmmhm/daiso-mcp · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/oliveyoung-http-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,11 @@ HTTP 모드의 health에 `lastFailure: { status, at }`가 추가됩니다. `stat
기존 소유 브라우저를 임시 독립 세션으로 실행한 정상 페이지 이동 후 네 공식 조회(매장9개·상품 검색·상품 식별자·매장 재고)는 모두 `SUCCESS`를 반환했고 감시 프로세스의 종료도 확인했습니다. HTTP 실패를 헤더 변형·쿠키 복사·보안 검증 자동화로 회피하지 않습니다. browser 모드 복구는 명시적 설정 변경으로만 수행하며, 브라우저 없는 실행이 필요하면 현재 HTTP403 차단이 해소되거나 제공자가 지원하는 다른 접근 경로가 필요합니다. 이 검증 자체는 운영 설정이나 원장을 수정하지 않았습니다.

인증된 운영 점검의 `x-health-check-force-fresh: true`는 상품·매장 fallback과 상품 식별자 캐시를 우회하고, 재고 보강 오류를 그대로 보고합니다. 릴레이에 `x-relay-cache-bypass: true`를 전달하여 저장 응답과 진행 중인 동일 요청도 재사용하지 않습니다. 이때도 인증·호출 한도·동시 작업 상한을 적용합니다. 인증되지 않은 공개 요청은 이 우회를 사용할 수 없습니다.

## 브라우저 감시자 비정상 종료 후 복구

브라우저 모드에서 감시자가 비정상 종료하면 새 브라우저를 즉시 실행하지 않습니다. 준비 완료 때 확보한 감시자 PID, 브라우저 PID·프로세스 그룹·생성 시각·전용 crashpad 경로와 마커를 대조합니다. 감시자가 숫자형 비정상 종료 코드로 끝났고 해당 PID·그룹·crashpad 프로세스가 모두 사라진 경우에만, 현재 사용자 소유의 일반 마커 파일을 `.exited-...` 이름으로 보존 이동합니다. 파일 교체·심볼릭 링크·큰 본문·소유권 불일치·조회 기한 초과가 있으면 기존 실패를 유지합니다. 시작 전 실패나 종료되지 않은 감시자, 시그널 종료도 자동 복구하지 않습니다.

이 확인은 기존 종료 기한 안에서 한 번만 공유합니다. 확인이 끝나면 다음 요청에서 새 브라우저를 준비할 수 있지만, 처음 실패한 원본 요청을 성공으로 바꾸거나 자동으로 무한 재시도하지 않습니다. 인증 자료와 누적 호출 원장은 수정하지 않으며, 원본의 간헐적 차단·지연을 해소한다는 의미도 아닙니다.

이미 실패 상태로 남은 구버전 프로세스는 먼저 유휴 상태를 확인해 정상 종료합니다. 남은 마커의 감시자 PID·브라우저 PID·원래 그룹·전용 crashpad 프로세스가 모두 없는지 확인한 뒤 마커를 보존 이동하고 LaunchAgent를 다시 시작합니다. 소유권이 불명확하면 다른 브라우저를 종료하거나 마커를 삭제하지 않습니다. 복구 후 캐시 우회 실제 조회와 CLI/MCP, 정기 경로 스냅샷을 확인합니다.
36 changes: 18 additions & 18 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
},
"miniflare": {
"sharp": "0.35.5",
"undici": "7.29.1"
"undici": "7.30.1"
}
},
"dependencies": {
Expand Down
3 changes: 3 additions & 0 deletions scripts/relay/ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ const crashHelper = (root: ProcessIdentity, row: ProcessIdentity) => {
};
const members = (root: ProcessIdentity, rows: ProcessIdentity[]) =>
rows.filter((row) => row.pgid === root.pgid || crashHelper(root, row));
export function hasOwnedProcesses(root: ProcessIdentity, rows: ProcessIdentity[]): boolean {
return members(root, rows).length > 0;
}
export function ownedGroup(root: ProcessIdentity, rows: ProcessIdentity[]): ProcessIdentity[] {
if (root.pid !== root.pgid || !rows.some((row) => same(root, row)))
throw new Error('Browser process identity uncertain');
Expand Down
43 changes: 43 additions & 0 deletions scripts/relay/recover-exited-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/** 종료한 감시자의 소유 프로세스가 모두 사라진 경우에만 실패 마커를 보존 이동합니다. */
import { constants } from 'node:fs';
import { open, lstat, rename } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { bounded } from './lifecycle.js';
import { hasOwnedProcesses, processSnapshot, type ProcessIdentity } from './ownership.js';
export async function recoverExitedGuard(
markerPath: string,
guardPid: number,
root: ProcessIdentity,
deadline: number,
snapshot = processSnapshot,
): Promise<boolean> {
const remaining = () => Math.max(0, deadline - Date.now());
try {
if (!root.crashDir || !root.crashDir.startsWith(markerPath + '.')) return false;
const handle = await open(
markerPath,
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
);
try {
const stat = await bounded(handle.stat(), remaining());
if (!stat.isFile() || stat.size > 16384 || stat.uid !== process.getuid!()) return false;
const marker = JSON.parse(await bounded(handle.readFile('utf8'), remaining()));
if (marker?.guardPid !== guardPid) return false;
for (const key of ['pid', 'pgid', 'birth', 'crashDir'] as const)
if (marker.browser?.[key] !== root[key]) return false;
const rows = await bounded(snapshot(), remaining());
if (rows.some((row) => row.pid === guardPid || row.pid === root.pid)) return false;
if (hasOwnedProcesses(root, rows)) return false;
const current = await bounded(lstat(markerPath), remaining());
if (current.dev !== stat.dev || current.ino !== stat.ino) return false;
if (!remaining()) return false;
await bounded(rename(markerPath, `${markerPath}.exited-${randomUUID()}`), remaining());
return true;
} finally {
await handle.close();
}
} catch {
// 소유권·파일·조회 기한 중 하나라도 확인할 수 없으면 기존 실패 상태를 유지합니다.
return false;
}
}
23 changes: 19 additions & 4 deletions scripts/relay/supervisor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/** 독립 감시 프로세스와 연결하고 소유 브라우저의 종료 확인을 기다립니다. */
import { recoverExitedGuard } from './recover-exited-guard.js';
import { fork } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
Expand All @@ -16,10 +17,23 @@ export async function launchGuardedBrowser(markerPath: string): Promise<BrowserO
resolve(code);
}),
);
const closed = async (deadline = Date.now() + 9000) => {
if (!exited && guard.connected) guard.send({ type: 'close', deadline });
const code = await bounded(exit, Math.max(0, deadline - Date.now()));
if (code !== 0) throw new Error('Browser guard cleanup unconfirmed');
let readyRoot: ProcessIdentity | undefined;
let closing: Promise<void> | undefined;
const closed = (deadline = Date.now() + 9000): Promise<void> => {
closing ??= (async () => {
if (!exited && guard.connected) guard.send({ type: 'close', deadline });
const code = await bounded(exit, Math.max(0, deadline - Date.now()));
if (code === 0) return;
if (
typeof code === 'number' &&
readyRoot &&
guard.pid &&
(await recoverExitedGuard(markerPath, guard.pid, readyRoot, deadline))
)
return;
throw new Error('Browser guard cleanup unconfirmed');
})();
return closing;
};
try {
const ready = await bounded(
Expand All @@ -33,6 +47,7 @@ export async function launchGuardedBrowser(markerPath: string): Promise<BrowserO
}),
40000,
);
readyRoot = ready.root;
const browser = await bounded(chromium.connect(ready.endpoint), 10000);
return {
browser,
Expand Down
161 changes: 161 additions & 0 deletions tests/relay/recover-exited-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { mkdtemp, readFile, writeFile, symlink, readdir, rm, mkdir } from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { recoverExitedGuard } from '../../scripts/relay/recover-exited-guard.js';
const changes = vi.hoisted(() => ({ mode: 'normal' }));
vi.mock('node:fs/promises', async (original) => {
const real = await original<typeof import('node:fs/promises')>();
return {
...real,
lstat: async (...args: Parameters<typeof real.lstat>) => {
const value = await real.lstat(...args);
if (changes.mode === 'device') Object.assign(value, { dev: -1 });
if (changes.mode === 'expire') vi.setSystemTime(Date.now() + 6000);
return value;
},
rename: async (...args: Parameters<typeof real.rename>) => {
if (changes.mode === 'rename') throw new Error('archive failed');
return real.rename(...args);
},
};
});
let dir: string;
let path: string;
let root: { pid: number; ppid: number; pgid: number; rss: number; birth: string; crashDir: string };
const snapshot = vi.fn();
beforeEach(async () => {
changes.mode = 'normal';
dir = await mkdtemp(join(tmpdir(), 'oy-guard-recovery-'));
path = join(dir, 'owner.json');
root = {
pid: 123,
ppid: 122,
pgid: 123,
rss: 10,
birth: 'original',
crashDir: path + '.unique.crashpad',
};
await writeFile(path, JSON.stringify({ guardPid: 122, browser: root }), { mode: 0o600 });
snapshot.mockReset().mockResolvedValue([]);
});
afterEach(async () => {
vi.useRealTimers();
vi.restoreAllMocks();
await rm(dir, { recursive: true, force: true });
});
it('감시자와 소유 그룹이 모두 사라진 경우에만 마커를 보존 이동한다', async () => {
expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(true);
const names = await readdir(dir);
expect(names).toHaveLength(1);
expect(names[0]).toMatch(/^owner.json\.exited-/);
expect(JSON.parse(await readFile(join(dir, names[0]), 'utf8')).browser).toEqual(root);
});
it.each([
{ pid: 122, pgid: 999 },
{ pid: 123, pgid: 999 },
{ pid: 999, pgid: 123 },
])('PID 재사용이나 잔여 프로세스가 있으면 이동하지 않는다: %j', async (row) => {
snapshot.mockResolvedValue([{ ...root, ...row, birth: 'reused' }]);
expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(false);
expect(await readdir(dir)).toEqual(['owner.json']);
});
it('다른 프로세스 그룹의 전용 crashpad도 남아 있으면 차단한다', async () => {
snapshot.mockResolvedValue([
{
...root,
pid: 999,
pgid: 999,
command: 'chrome_crashpad_handler --database=' + root.crashDir,
},
]);
expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(false);
});
it('snapshot 실패는 종료 증거가 아니다', async () => {
snapshot.mockRejectedValue(new Error('ps failed'));
expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(false);
});
it('기한 이후 도착한 snapshot은 나중에도 마커를 이동하지 않는다', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
snapshot.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve([]), 100)));
const task = recoverExitedGuard(path, 122, root, Date.now() + 50, snapshot);
while (!snapshot.mock.calls.length) await new Promise((resolve) => setImmediate(resolve));
await vi.advanceTimersByTimeAsync(200);
expect(await task).toBe(false);
expect(await readdir(dir)).toEqual(['owner.json']);
});
it.each(['guardPid', 'pid', 'pgid', 'birth', 'crashDir'])(
'마커의 %s 식별자가 다르면 차단한다',
async (key) => {
const marker = { guardPid: 122, browser: { ...root } };
if (key === 'guardPid') marker.guardPid = 999;
else Object.assign(marker.browser, { [key]: 'different' });
await writeFile(path, JSON.stringify(marker));
expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(false);
},
);
it.each(['', '{', ' '.repeat(16385), 'null'])('불완전하거나 큰 마커를 거절한다', async (body) => {
await writeFile(path, body);
expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(false);
});
it('심볼릭 링크는 따라가지 않는다', async () => {
const link = path + '.link';
const linkedRoot = { ...root, crashDir: link + '.unique.crashpad' };
await writeFile(path, JSON.stringify({ guardPid: 122, browser: linkedRoot }));
await symlink(path, link);
expect(await recoverExitedGuard(link, 122, linkedRoot, Date.now() + 5000, snapshot)).toBe(false);
expect(JSON.parse(await readFile(path, 'utf8')).guardPid).toBe(122);
});
it('snapshot 중 마커가 교체되면 새 마커를 이동하지 않는다', async () => {
snapshot.mockImplementation(async () => {
await rm(path);
await writeFile(path, JSON.stringify({ guardPid: 999 }));
return [];
});
expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(false);
expect(JSON.parse(await readFile(path, 'utf8')).guardPid).toBe(999);
});

it.each(['device', 'rename', 'expire'])(
'장치 변경·이동 실패·기한 만료를 성공으로 바꾸지 않는다: %s',
async (mode) => {
if (mode === 'expire') vi.useFakeTimers({ toFake: ['Date'] });
changes.mode = mode;
expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(false);
expect(await readdir(dir)).toEqual(['owner.json']);
},
);
it('다른 UID의 마커를 이동하지 않는다', async () => {
const uid = process.getuid!();
vi.spyOn(process, 'getuid').mockReturnValue(uid + 1);
expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(false);
});
it('디렉터리를 마커로 사용하지 않는다', async () => {
await rm(path);
await mkdir(path);
expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(false);
});
it.each([undefined, '/unrelated.crashpad'])(
'고유 crashDir 증거가 없으면 거절한다',
async (crashDir) => {
expect(
await recoverExitedGuard(path, 122, { ...root, crashDir }, Date.now() + 5000, snapshot),
).toBe(false);
},
);
it('브라우저 정보가 없으면 소유권을 추측하지 않는다', async () => {
await writeFile(path, JSON.stringify({ guardPid: 122 }));
expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(false);
});
it('기본 운영 프로세스 스냅샷으로도 완전 소멸을 확인한다', async () => {
const missingRoot = { ...root, pid: 2147483000, pgid: 2147483000 };
await writeFile(path, JSON.stringify({ guardPid: 2147483001, browser: missingRoot }));
expect(await recoverExitedGuard(path, 2147483001, missingRoot, Date.now() + 5000)).toBe(true);
});

it('FIFO 마커도 파일 열기에서 멈추지 않고 거절한다', async () => {
await rm(path);
execFileSync('/usr/bin/mkfifo', [path]);
expect(await recoverExitedGuard(path, 122, root, Date.now() + 1000, snapshot)).toBe(false);
});
Loading
Loading
0