Add full IKAR administration console with secure session controls
Tests / test (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s

This commit is contained in:
sevenhill
2026-10-07 23:42:02 +03:00
parent 2fcd23a4cf
commit b4c27da560
29 changed files with 1762 additions and 88 deletions
+37
View File
@@ -0,0 +1,37 @@
"""Private cookie/CSRF client for IKAR deployment checks. Never logs credentials."""
import json
from http.cookies import SimpleCookie
from urllib.parse import quote
from urllib.request import Request, urlopen
class AdminHttp:
def __init__(self, base='http://127.0.0.1:5099'):
self.base = base.rstrip('/')
self.cookies = {}
self.csrf = None
def request(self, path, method='GET', payload=None, reason=None):
headers = {'Accept': 'application/json', 'X-Forwarded-Proto': 'https'}
if self.cookies: headers['Cookie'] = '; '.join(key + '=' + value for key, value in self.cookies.items())
if method not in ('GET', 'HEAD') and self.csrf: headers['X-Ikar-CSRF'] = self.csrf
if reason: headers['X-Admin-Reason'] = quote(reason)
data = None if payload is None else json.dumps(payload).encode()
if data is not None: headers['Content-Type'] = 'application/json'
with urlopen(Request(self.base + path, data=data, method=method, headers=headers), timeout=30) as response:
for value in response.headers.get_all('Set-Cookie', []):
cookie = SimpleCookie(); cookie.load(value)
for key, morsel in cookie.items():
if morsel['max-age'] == '0' or not morsel.value: self.cookies.pop(key, None)
else: self.cookies[key] = morsel.value
body = response.read()
return response.status, json.loads(body) if body else None
def login(self, settings):
_, state = self.request('/admin/api/session'); self.csrf = state['csrfToken']
status, _ = self.request('/admin/api/session/login', 'POST', {'username': settings['Admin__Username'], 'password': settings['Admin__Password']})
if status != 204: raise RuntimeError('Admin login failed')
_, state = self.request('/admin/api/session'); self.csrf = state['csrfToken']
if not state['authenticated']: raise RuntimeError('Admin cookie authentication failed')
def logout(self):
self.request('/admin/api/session/logout', 'POST')
+7 -8
View File
@@ -1,6 +1,7 @@
"""Delete only the reserved identities created by this release's tests."""
from pathlib import Path
import json,sqlite3,urllib.request,uuid
from admin_http import AdminHttp
root=Path('/mnt/data/ikar_server')
with sqlite3.connect(f'file:{root / "shared/data/ikar.db"}?mode=ro',uri=True) as db:
candidates=db.execute('select Id,NormalizedPhoneNumber,DisplayName from Users where IsBot=0 and IsBlocked=0').fetchall()
@@ -9,12 +10,10 @@ with sqlite3.connect(f'file:{root / "shared/data/ikar.db"}?mode=ro',uri=True) as
ids=[user_id for user_id,phone,name in candidates if phone=='2025550101' and name=='IKAR']
if ids:
settings=dict(line.split('=',1) for line in (root/'shared/server.env').read_text().splitlines() if '=' in line)
body=json.dumps({'username':settings['Admin__Username'],'password':settings['Admin__Password']}).encode()
request=urllib.request.Request('http://127.0.0.1:5099/admin/api/session/login',data=body,headers={'Content-Type':'application/json'})
with urllib.request.urlopen(request,timeout=15) as response:
cookies='; '.join(value.split(';',1)[0] for value in response.headers.get_all('Set-Cookie',[]))
assert cookies and response.status==204
for user_id in ids:
request=urllib.request.Request(f'http://127.0.0.1:5099/admin/api/users/{uuid.UUID(user_id)}',method='DELETE',headers={'Cookie':cookies})
with urllib.request.urlopen(request,timeout=15) as response: assert response.status==204
admin=AdminHttp(); admin.login(settings)
try:
for user_id in ids:
status,_=admin.request(f'/admin/api/users/{uuid.UUID(user_id)}','DELETE',reason='Release smoke-test identity cleanup')
assert status==204
finally: admin.logout()
print(json.dumps({'reservedTestAccountsDeleted':len(ids)}))
+22 -1
View File
@@ -1,6 +1,7 @@
"""Deploy the complete tested IKAR API build, preserving other containers."""
from pathlib import Path
import hashlib
import ipaddress
import json
import os
import re
@@ -55,7 +56,21 @@ text = compose.read_text()
old_image = f'image: ikar_server:{previous_name}'
if text.count(old_image) != 1:
raise RuntimeError('Unexpected API image in compose')
compose.write_text(text.replace(old_image, f'image: ikar_server:{release_name}', 1))
text = text.replace(old_image, f'image: ikar_server:{release_name}', 1)
# The loopback-bound Docker port reaches ASP.NET from the bridge gateway.
# Trust that exact address so Caddy's HTTPS/IP headers work without trusting all peers.
inspection = json.loads(subprocess.check_output(['docker', 'inspect', 'ikar_server'], text=True))[0]
gateways = [str(ipaddress.ip_address(item['Gateway'])) for item in inspection['NetworkSettings']['Networks'].values() if item.get('Gateway')]
if len(gateways) != 1:
raise RuntimeError('Expected exactly one API bridge gateway')
setting = ' ReverseProxy__KnownProxies:'
if setting in text:
text = re.sub(r'(?m)^ ReverseProxy__KnownProxies:.*$', f'{setting} "{gateways[0]}"', text)
else:
marker = ' IkarUpdates__LocalOnly: "true"'
if text.count(marker) != 1: raise RuntimeError('Unexpected API environment section')
text = text.replace(marker, marker + f'\n{setting} "{gateways[0]}"', 1)
compose.write_text(text)
print('Building IKAR API image', flush=True)
with (root / f'build-{release_name}.log').open('w') as log:
subprocess.run(['docker', 'build', '-f', 'Dockerfile.release', '-t', f'ikar_server:{release_name}', '.'],
@@ -89,6 +104,11 @@ try:
if integrity != 'ok' or 'PublicLastSeenUnixMs' not in columns:
raise RuntimeError('Database or presence schema verification failed')
subprocess.run(['docker', 'exec', 'ikar_server', 'ffmpeg', '-version'], stdout=subprocess.DEVNULL, check=True)
request = urllib.request.Request('http://127.0.0.1:5099/admin/api/session', headers={'X-Forwarded-Proto': 'https'})
with urllib.request.urlopen(request, timeout=10) as response:
admin_state = json.load(response)
if not admin_state.get('configured') or not admin_state.get('csrfToken'):
raise RuntimeError('Production admin login or HTTPS proxy configuration failed')
pending = root / f'current.next-{release_name}'
pending.symlink_to(release, target_is_directory=True)
os.replace(pending, current)
@@ -102,6 +122,7 @@ report = {
'backup': str(backup), 'backupIntegrity': 'ok', 'health': health,
'databaseIntegrity': integrity, 'publicPresenceColumn': True,
'serverSha256': running_sha, 'ffmpegAvailable': True,
'adminLoginConfigured': True, 'adminCsrfAvailable': True, 'trustedProxy': gateways[0],
'protectedServicesUnchanged': snapshot() == protected_before,
'protectedServices': protected_before,
}
+39
View File
@@ -0,0 +1,39 @@
"""Seed the isolated local admin UI test database; never accepts a production path."""
import json, sqlite3, uuid
from datetime import datetime, timezone
from pathlib import Path
repo = Path(__file__).resolve().parent.parent
server = json.loads((repo / 'artifacts/admin-local-server.json').read_text(encoding='utf-8-sig'))
root = Path(server['root']).resolve()
if not root.name.startswith('ikar-admin-ui-') or server['url'] != 'http://127.0.0.1:15199':
raise RuntimeError('Only the isolated loopback fixture is allowed')
db = sqlite3.connect(root / 'Data/ikar.db')
db.execute('PRAGMA foreign_keys=ON')
now = datetime.now(timezone.utc).isoformat()
def row(table, values):
for _, name, kind, required, default, primary in db.execute(f'PRAGMA table_info("{table}")'):
if name not in values and required and default is None and not primary:
values[name] = 0 if kind == 'INTEGER' else now if name.endswith(('At', 'Until')) else ''
fields = ','.join('"' + key + '"' for key in values)
db.execute(f'INSERT INTO "{table}" ({fields}) VALUES ({",".join("?" for _ in values)})', list(values.values()))
def user(name, bot=False):
identifier = str(uuid.uuid4()).upper()
row('Users', {'Id': identifier, 'Username': name, 'NormalizedUsername': name.upper(), 'DisplayName': name, 'IsBot': int(bot)})
return identifier
owner = user('UI пользователь <script>alert(1)</script>')
botuser = user('ui-test-bot', True)
for number in range(29): user(f'UI пользователь {number:02}')
session = str(uuid.uuid4()).upper()
row('UserSessions', {'Id': session, 'UserId': owner, 'TokenHash': 'test-fixture-secret', 'ClientPlatform': 'android', 'ClientVersionCode': 5, 'ClientVersionName': '1.0.4'})
chat = str(uuid.uuid4()).upper()
row('Chats', {'Id': chat, 'Type': 2, 'Title': 'UI группа', 'CreatedById': owner, 'ReactionSettingsJson': '{}'})
row('ChatMembers', {'ChatId': chat, 'UserId': owner, 'IsOwner': 1, 'Role': 2})
message = str(uuid.uuid4()).upper()
row('Messages', {'Id': message, 'ChatId': chat, 'SenderId': owner, 'Text': 'Сообщение для проверки модерации'})
bot = str(uuid.uuid4()).upper()
row('Bots', {'Id': bot, 'UserId': botuser, 'OwnerUserId': owner, 'TokenHash': 'not-exposed-test-secret', 'IsEnabled': 1})
row('ModerationCases', {'SourceLogId': 100001, 'ReporterId': owner, 'ReporterName': 'UI заявитель', 'TargetKind': 'message', 'TargetId': message, 'ChatId': chat, 'PayloadJson': '{"reasonId":1,"typeId":4,"ids":[1]}', 'Status': 'open', 'CreatedUnixMs': int(datetime.now(timezone.utc).timestamp()*1000)})
db.commit(); db.close()
(repo / 'artifacts/admin-ui-fixture.json').write_text(json.dumps({'userId': owner.lower(), 'chatId': chat.lower(), 'messageId': message.lower(), 'botId': bot.lower()}))
print(json.dumps({'seeded': True, 'users': 32, 'reports': 1}))
+27
View File
@@ -0,0 +1,27 @@
param([int]$Port = 15199)
$ErrorActionPreference = 'Stop'
$taskRepo = Split-Path $PSScriptRoot -Parent
$taskRunDir = Join-Path ([IO.Path]::GetTempPath()) ('ikar-admin-ui-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $taskRunDir | Out-Null
Copy-Item -LiteralPath "$taskRepo/ikar_server/src/Ikar.Server/wwwroot" -Destination $taskRunDir -Recurse
Copy-Item -LiteralPath "$taskRepo/ikar_server/src/Ikar.Server/bin/Debug/net10.0" -Destination "$taskRunDir/runtime" -Recurse
$taskEnvironment = @{
ASPNETCORE_ENVIRONMENT = 'Development'
ASPNETCORE_URLS = "http://127.0.0.1:$Port"
SeedDemoData = 'false'
ConnectionStrings__IkarDb = "Data Source=$taskRunDir/Data/ikar.db;Pooling=False"
Admin__Username = 'admin-test'
Admin__Password = 'test-only-password'
IkarUpdates__LocalOnly = 'true'
Logging__LogLevel__Default = 'Warning'
}
$taskProcess = Start-Process -FilePath dotnet -ArgumentList @("$taskRunDir/runtime/Ikar.Server.dll") -WorkingDirectory $taskRunDir -WindowStyle Hidden -RedirectStandardOutput "$taskRunDir/server.stdout.log" -RedirectStandardError "$taskRunDir/server.stderr.log" -Environment $taskEnvironment -PassThru
$taskReady = $false
for ($taskAttempt = 0; $taskAttempt -lt 30; $taskAttempt++) {
if ($taskProcess.HasExited) { throw "Test server exited; inspect $taskRunDir/server.stderr.log" }
try { $taskHealth = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/health" -TimeoutSec 2; if ($taskHealth.status -eq 'ok') { $taskReady = $true; break } } catch { }
Start-Sleep -Milliseconds 500
}
if (!$taskReady) { Stop-Process -Id $taskProcess.Id; throw 'Test server did not become healthy' }
@{pid=$taskProcess.Id;root=$taskRunDir;url="http://127.0.0.1:$Port"} | ConvertTo-Json | Set-Content "$taskRepo/artifacts/admin-local-server.json"
Get-Content "$taskRepo/artifacts/admin-local-server.json"
+49
View File
@@ -0,0 +1,49 @@
"""Authenticate privately on the Pi and verify the deployed administration API."""
import json, sqlite3
from pathlib import Path
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from admin_http import AdminHttp
root = Path('/mnt/data/ikar_server')
settings = dict(line.split('=', 1) for line in (root/'shared/server.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
base = 'http://127.0.0.1:5099'
for route in ('users/list', 'reports', 'bots', 'audit', 'operations', 'admin-sessions'):
try:
urlopen(base + '/admin/api/' + route, timeout=15)
raise RuntimeError('Unauthenticated admin access is possible')
except HTTPError as error:
if error.code != 401: raise
client = AdminHttp(base)
client.login(settings)
try:
endpoints = {}
for route in ('status', 'users/list', 'chats/list', 'attachments/list', 'reports', 'bots', 'audit', 'admin-sessions', 'operations', 'releases', 'push-queue', 'app-updates/compatibility-report'):
code, data = client.request('/admin/api/' + route)
if code != 200: raise RuntimeError('Admin endpoint failed: ' + route)
endpoints[route] = code
if route == 'admin-sessions' and not any(item['current'] for item in data): raise RuntimeError('Current admin session is missing')
if route == 'operations':
if not data['dataProtectionPersistent'] or not data['backups']: raise RuntimeError('Persistent keys or backups are missing')
operations = {key: data[key] for key in ('diskFreeBytes', 'diskTotalBytes', 'openReports', 'queuedPush')}
if route == 'releases': release = data['release']
# A missing CSRF token must fail without terminating the valid session.
cookies = '; '.join(key + '=' + value for key, value in client.cookies.items())
try:
urlopen(Request(base + '/admin/api/session/logout', method='POST', headers={'Cookie': cookies, 'X-Forwarded-Proto': 'https'}), timeout=15)
raise RuntimeError('A mutation without CSRF was accepted')
except HTTPError as error:
if error.code != 400: raise
if not client.request('/admin/api/session')[1]['authenticated']: raise RuntimeError('CSRF check damaged valid session')
finally:
client.logout()
with sqlite3.connect(f'file:{root}/shared/data/ikar.db?mode=ro', uri=True) as db:
if db.execute('PRAGMA integrity_check').fetchone()[0] != 'ok': raise RuntimeError('Database integrity failed')
source = db.execute("SELECT COUNT(*) FROM ModerationLogEntries WHERE Action='complaint.submitted'").fetchone()[0]
cases = db.execute('SELECT COUNT(*) FROM ModerationCases').fetchone()[0]
if cases < source: raise RuntimeError('Complaint migration is incomplete')
report = {'passed': True, 'authenticatedEndpoints': endpoints, 'anonymousDenied': True, 'csrfRequired': True, 'logout': True,
'databaseIntegrity': 'ok', 'complaintsMigrated': cases, 'operations': operations,
'androidRelease': {'versionName': release['versionName'], 'versionCode': release['versionCode'], 'sha256': release['sha256']}}
(root/'admin-console-verification.json').write_text(json.dumps(report, indent=2))
print(json.dumps(report))
+32
View File
@@ -0,0 +1,32 @@
// Authenticated read-only browser verification; credentials stay in the ignored local file.
const fs = require('node:fs'); const path = require('node:path'); const assert = require('node:assert/strict');
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const repo = path.resolve(__dirname, '..');
const account = JSON.parse(fs.readFileSync(path.join(repo, '.secrets/ikar-admin-access.json'), 'utf8').replace(/^\uFEFF/, ''));
(async () => {
const browser = await chromium.launch({headless: true, executablePath: process.env.ADMIN_BROWSER || 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', args: ['--host-resolver-rules=MAP ikar.kusoft.xyz 195.216.241.165']});
try {
const context = await browser.newContext({viewport: {width: 1440, height: 1000}}); const page = await context.newPage();
page.setDefaultTimeout(15000);
const errors = []; page.on('pageerror', error => errors.push(error.message));
await page.goto(account.url);
await page.locator('#loginUsername').fill(account.username); await page.locator('#loginPassword').fill(account.password);
await page.locator('#loginForm button').click(); await page.waitForFunction(() => document.querySelector('#usersCount').textContent.includes('пользователей'));
assert.equal(await page.locator('#adminIdentity').textContent(), account.username);
const cookie = (await context.cookies()).find(x => x.name === 'Ikar.Admin');
assert.ok(cookie && cookie.secure && cookie.httpOnly && cookie.sameSite === 'Strict');
await page.screenshot({path: path.join(repo, 'artifacts/admin-live-overview.png'), fullPage: true});
const sections = ['users', 'chats', 'media', 'reports', 'bots', 'moderation', 'audit', 'security', 'updates', 'operations'];
for (const section of sections) {
await page.locator(`.nav-item[data-section=${section}]`).click();
await page.waitForFunction(section => document.querySelector(`.section[data-panel=${section}]`).classList.contains('active'), section);
if (section === 'security') await page.waitForFunction(() => document.querySelector('#securityContent').textContent.includes('Эта сессия'));
if (section === 'updates') await page.waitForFunction(() => document.querySelector('#releasesContent').textContent.includes('1.0.4'));
if (section === 'operations') await page.waitForFunction(() => document.querySelector('#operationsDetails').textContent.includes('Свободно на диске'));
}
assert.deepEqual(errors, []);
await page.locator('#logoutButton').click(); await page.locator('#loginForm').waitFor({state: 'visible'});
const result = {passed: true, publicUrl: account.url, authenticatedBrowser: true, secureCookie: true, sections, browserErrors: errors, logout: true};
fs.writeFileSync(path.join(repo, 'artifacts/admin-public-ui-verification.json'), JSON.stringify(result, null, 2)); console.log(JSON.stringify(result));
} finally { await browser.close(); }
})().catch(error => { console.error(error.message); process.exitCode = 1; });
+74
View File
@@ -0,0 +1,74 @@
// Run against the isolated fixture from start_admin_test_server.ps1, never production.
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const repo = path.resolve(__dirname, '..');
const run = JSON.parse(fs.readFileSync(path.join(repo, 'artifacts/admin-local-server.json'), 'utf8').replace(/^\uFEFF/, ''));
const fixture = JSON.parse(fs.readFileSync(path.join(repo, 'artifacts/admin-ui-fixture.json'), 'utf8'));
if (run.url !== 'http://127.0.0.1:15199') throw new Error('Only the loopback fixture is allowed');
(async () => {
const browser = await chromium.launch({headless: true, executablePath: process.env.ADMIN_BROWSER || 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'});
try {
const page = await browser.newPage({viewport: {width: 1440, height: 1000}});
page.setDefaultTimeout(15000);
const errors = []; page.on('pageerror', e => errors.push(e.message));
page.on('dialog', d => d.accept());
await page.goto(run.url + '/admin/');
await page.locator('#loginUsername').fill('admin-test');
await page.locator('#loginPassword').fill('test-only-password');
await page.locator('#loginForm button').click();
await page.waitForFunction(() => document.querySelector('#usersCount').textContent.includes('пользователей'));
assert.equal(await page.locator('#adminIdentity').textContent(), 'admin-test');
await page.screenshot({path: path.join(repo, 'artifacts/admin-overview.png'), fullPage: true});
await page.locator('.nav-item[data-section=users]').click();
await page.locator('#usersPager [data-page="2"]').click();
await page.waitForFunction(() => document.querySelector('#usersPager').textContent.includes('Страница 2'));
await page.locator('#globalSearch').fill('UI пользователь <script>');
await page.waitForFunction(() => document.querySelectorAll('#usersTable tr').length === 1);
assert.equal(await page.locator('#usersTable script').count(), 0);
await page.locator(`[data-action=console-user][data-id="${fixture.userId}"]`).click();
await page.locator('#consoleDialog input[name=displayName]').fill('Имя после редактирования');
await page.locator('form[data-console-form=user] button').click();
await page.waitForFunction(() => document.querySelector('#dialogTitle').textContent.startsWith('Имя после редактирования'));
await page.locator('[data-action=console-revoke-all]').click();
await page.waitForFunction(() => !document.querySelector('[data-action=console-revoke-session]'));
await page.locator('[data-action=console-close]').click();
await page.locator('#globalSearch').fill('');
await page.locator('.nav-item[data-section=reports]').click();
await page.locator('[data-action=console-report]').click();
await page.locator('form[data-console-form=report] textarea').fill('Спам подтверждён, сообщение скрыто');
await page.locator('form[data-console-form=report] select[name=action]').selectOption('delete-message');
await page.locator('form[data-console-form=report] button').click();
await page.waitForFunction(() => !document.querySelector('#consoleDialog').open);
await page.locator('#reportStatus').selectOption('resolved');
await page.locator('[data-action=console-report]').waitFor();
await page.locator('.nav-item[data-section=bots]').click();
await page.locator(`[data-action=console-bot][data-id="${fixture.botId}"]`).click();
await page.locator('form[data-console-form=bot] input[name=enabled]').uncheck();
await page.locator('form[data-console-form=bot] button').click();
await page.waitForFunction(() => document.querySelector('#botsContent').textContent.includes('Отключён'));
await page.locator('.nav-item[data-section=chats]').click();
await page.locator(`[data-action=open-chat][data-chat-id="${fixture.chatId}"]`).click();
await page.locator('[data-action=console-messages]').click();
await page.locator(`[data-action=console-message][data-id="${fixture.messageId}"][data-mode=restore]`).click();
await page.locator(`[data-action=console-message][data-id="${fixture.messageId}"][data-mode=hide]`).waitFor();
await page.locator('[data-action=console-close]').click();
for (const section of ['media', 'moderation', 'audit', 'security', 'updates', 'operations']) {
await page.locator(`.nav-item[data-section=${section}]`).click();
if (section === 'audit') await page.waitForFunction(() => document.querySelector('#auditContent').textContent.includes('admin-test'));
if (section === 'security') await page.waitForFunction(() => document.querySelector('#securityContent').textContent.includes('Эта сессия'));
if (section === 'operations') await page.waitForFunction(() => document.querySelector('#operationsDetails').textContent.includes('Свободно на диске'));
}
await page.locator('.nav-item[data-section=overview]').click();
await page.setViewportSize({width: 390, height: 844});
await page.screenshot({path: path.join(repo, 'artifacts/admin-mobile.png'), fullPage: true});
const width = await page.evaluate(() => ({viewport: innerWidth, content: document.documentElement.scrollWidth}));
assert.ok(width.content <= width.viewport + 1, `Mobile overflow: ${JSON.stringify(width)}`);
await page.locator('#logoutButton').click(); await page.locator('#loginForm').waitFor({state: 'visible'});
assert.deepEqual(errors, []);
const result = {passed: true, login: true, pagination: true, escapedHtml: true, profileEdit: true, sessionRevocation: true, reportResolution: true, botSettings: true, messageRestore: true, allSections: true, responsive: width, browserErrors: errors, logout: true};
fs.writeFileSync(path.join(repo, 'artifacts/admin-ui-verification.json'), JSON.stringify(result, null, 2));
console.log(JSON.stringify(result));
} finally { await browser.close(); }
})().catch(e => { console.error(e); process.exitCode = 1; });