chore(release): v2.2.3 — artifact attestations, bugfixes, rate limit metrics

This commit is contained in:
Timo
2026-06-10 00:56:29 +02:00
parent af59b4c64c
commit 34f0c2b265
15 changed files with 176 additions and 47 deletions
+17
View File
@@ -11,6 +11,8 @@ jobs:
permissions:
contents: write
packages: write
id-token: write
attestations: write
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -35,6 +37,7 @@ jobs:
type=ref,event=tag
- name: Build and push Docker image
id: build
uses: docker/build-push-action@v5
with:
context: .
@@ -44,10 +47,19 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation
uses: actions/attest@v4
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
release-extension:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -103,6 +115,11 @@ jobs:
npm install
npm run build:extension
- name: Generate artifact attestation for extensions
uses: actions/attest@v4
with:
subject-path: dist/koalasync-*.zip
- name: Build Website
run: node website/build.js
+22 -6
View File
@@ -54,6 +54,16 @@ The easiest and safest way to install KoalaSync is directly through the official
2. **Invite Friends:** Share the auto-copied invite link. Once they click it, they automatically join.
3. **Pick a Video:** Navigate to the Sync Tab, select the tab playing your video, and grab some popcorn! 🍿
---
### 🌐 Localization & Translations
Both the official KoalaSync website and the **v2.0 Browser Extension** feature full dynamic localization:
- **Available Languages**: Support is included for 13 languages: English (`en`), German (`de`), French (`fr`), Spanish (`es`), Portuguese (Brazil) (`pt-BR`), Russian (`ru`), Italian (`it`), Polish (`pl`), Turkish (`tr`), Dutch (`nl`), Japanese (`ja`), Korean (`ko`), and European Portuguese (`pt`).
- **Real-Time Extension Localization**: Inside the extension Settings panel, users can swap languages instantly. The entire interface, notifications, Empty States, and onboarding guides re-translate dynamically in real-time.
- **Contributing**: We welcome community translations for both the website and the extension! Please refer directly to the [TRANSLATION.md](website/TRANSLATION.md) guide for step-by-step instructions on how to audit, refine, or add new languages.
---
### 🛠️ For Developers & Self-Hosters
@@ -93,15 +103,21 @@ To connect your extension to a self-hosted server, open the popup → **Room** t
To verify your relay is reachable from outside, visit `https://your-domain.com` in a browser — it should return `{"status":"online","service":"KoalaSync Relay"}`.
---
#### Supply Chain Security (v2.2.2+)
### 🌐 Localization & Translations
All official release artifacts (Docker images and extension binaries) are published with signed [artifact attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations) to prove they were built from this repository's source code.
Both the official KoalaSync website and the **v2.0 Browser Extension** feature full dynamic localization:
- **Available Languages**: Support is included for 13 languages: English (`en`), German (`de`), French (`fr`), Spanish (`es`), Portuguese (Brazil) (`pt-BR`), Russian (`ru`), Italian (`it`), Polish (`pl`), Turkish (`tr`), Dutch (`nl`), Japanese (`ja`), Korean (`ko`), and European Portuguese (`pt`).
- **Real-Time Extension Localization**: Inside the extension Settings panel, users can swap languages instantly. The entire interface, notifications, Empty States, and onboarding guides re-translate dynamically in real-time.
- **Contributing**: We welcome community translations for both the website and the extension! Please refer directly to the [TRANSLATION.md](website/TRANSLATION.md) guide for step-by-step instructions on how to audit, refine, or add new languages.
**Verify a Docker image:**
```bash
gh attestation verify oci://ghcr.io/shik3i/koalasync:latest \
-R Shik3i/KoalaSync
```
**Verify an extension binary:**
```bash
gh attestation verify dist/koalasync-chrome.zip \
-R Shik3i/KoalaSync
```
---
+25
View File
@@ -4,6 +4,31 @@ All notable changes to the KoalaSync browser extension and relay server.
---
## [v2.2.3] — 2026-06-10
### Added
- **Artifact Attestations (Supply Chain Security)**: All release artifacts (Docker images, extension ZIPs) are now published with signed [SLSA provenance attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations) via `actions/attest@v4`. Anyone can verify that an artifact was built from this repository using `gh attestation verify`.
- **Admin health: `rateLimits.denied` counters**: New rolling counters track actual rate-limit denials (429 responses), separate from `rateLimits.trackedClients` which reports unique IPs in the tracking window.
- **Docker HEALTHCHECK**: Container health is now checked every 30s via `GET /health`.
- **`npm start` script**: Server can now be started with `npm start`.
### Fixed
- **Server: `activeLobby` no longer silently overwritten**: If a second peer sends `EPISODE_LOBBY` while a lobby is already active, the request is now ignored instead of destroying the first peer's lobby.
- **CORS log sanitization**: Rejected origin headers are sanitized (`\r\n` stripped) to prevent log injection.
- **Extension: pagehide resource leak**: `keepAlivePort`, `lobbyPollTimer`, heartbeats, and `MutationObserver` are now properly cleaned up when a tab is hidden or enters bfcache.
- **Extension: unhandled storage rejections**: `chrome.storage.session.set()` calls in the disconnect handler now have `.catch(() => {})`.
- **`'Pixel'` duplicate in name generator**: Second occurrence replaced with `'Nitro'` for better name diversity.
- **`'opposum'` typo**: Corrected to `'opossum'` in the emoji map and added `'Opossum'` to `USERNAME_NOUNS`.
- **Test reliability**: `test-server-routes.mjs` now sets `ADMIN_METRICS_TOKEN` before importing the server module, fixing standalone test execution.
- **`MAX_PEERS_PER_ROOM` konsistent**: `.env` auf 25 gesetzt (wie `.env.example`).
- **pt-BR.json duplicate removed**: Duplicate `FOOTER_DISCLAIMER` key removed from website locale.
### Changed
- **Admin health: `rateLimitEntries` renamed to `rateLimits.trackedClients`**: The field now accurately describes that it tracks unique clients in the rate-limit window, not denial counts. Update your json_exporter/Grafana config accordingly.
- **README restructured**: Sections reordered by progressive technical depth. New "Supply Chain Security" subsection under "For Developers & Self-Hosters" with verification commands.
---
## [v2.2.2] — 2026-06-09
### Added
+37 -12
View File
@@ -54,26 +54,51 @@ modules:
path: '{.memory.heapTotal}'
help: "V8 engine heap total in bytes"
# Rate limiter tracking — unique clients currently in each tracking window
# (not rate-limit denials; these include legitimate traffic too)
- name: koalasync_rate_limit_connections
path: '{.rateLimitEntries.connections}'
help: "Number of entries tracked in the connection rate limiter"
path: '{.rateLimits.trackedClients.connections}'
help: "Unique clients tracked in the connection rate limiter window"
- name: koalasync_rate_limit_events
path: '{.rateLimitEntries.events}'
help: "Number of entries in the event rate limiter"
path: '{.rateLimits.trackedClients.events}'
help: "Unique sockets tracked in the event rate limiter window"
- name: koalasync_rate_limit_health
path: '{.rateLimitEntries.health}'
help: "Number of entries in the health rate limiter"
path: '{.rateLimits.trackedClients.health}'
help: "Unique IPs tracked in the health endpoint rate limiter window"
- name: koalasync_rate_limit_admin_metrics_auth
path: '{.rateLimitEntries.adminMetricsAuth}'
help: "Number of entries in the admin metrics auth rate limiter"
path: '{.rateLimits.trackedClients.adminMetricsAuth}'
help: "Unique IPs tracked in the admin metrics auth rate limiter window"
- name: koalasync_rate_limit_auth_failures
path: '{.rateLimitEntries.authFailures}'
help: "Number of entries in the authentication failures cache"
path: '{.rateLimits.trackedClients.authFailures}'
help: "Unique IPs tracked in the authentication failures cache"
- name: koalasync_rate_limit_room_list
path: '{.rateLimitEntries.roomList}'
help: "Number of entries in the room list cooldown cache"
path: '{.rateLimits.trackedClients.roomList}'
help: "Unique sockets in the room list cooldown cache"
# Actual rate-limit denials — incremented only when a 429 is served
- name: koalasync_rate_limit_denied_connections
path: '{.rateLimits.denied.connections}'
help: "Total connection attempts denied by rate limiter"
- name: koalasync_rate_limit_denied_events
path: '{.rateLimits.denied.events}'
help: "Total socket events denied by rate limiter"
- name: koalasync_rate_limit_denied_health
path: '{.rateLimits.denied.health}'
help: "Total health endpoint requests denied by rate limiter"
- name: koalasync_rate_limit_denied_admin_metrics_auth
path: '{.rateLimits.denied.adminMetricsAuth}'
help: "Total admin metrics auth attempts denied by rate limiter"
- name: koalasync_rate_limit_denied_room_list
path: '{.rateLimits.denied.roomList}'
help: "Total room list refresh requests denied by rate limiter"
+2 -2
View File
@@ -544,12 +544,12 @@ async function connect() {
isForceSyncInitiator: false,
forceSyncAcks: [],
forceSyncDeadline: null
});
}).catch(() => {});
if (currentRoom) {
currentRoom.peers = [];
if (storageInitialized) chrome.storage.session.set({ currentRoom });
if (storageInitialized) chrome.storage.session.set({ currentRoom }).catch(() => {});
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
}
broadcastConnectionStatus('disconnected');
+5
View File
@@ -837,6 +837,11 @@
window.addEventListener('pagehide', () => {
pageVisible = false;
closeAudioContext();
if (keepAlivePort) { try { keepAlivePort.disconnect(); } catch (_e) { /* ignore */ } keepAlivePort = null; }
if (lobbyPollTimer) { clearInterval(lobbyPollTimer); lobbyPollTimer = null; }
if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; }
if (proactiveHeartbeatTimeout) { clearTimeout(proactiveHeartbeatTimeout); proactiveHeartbeatTimeout = null; }
observer.disconnect();
});
window.addEventListener('pageshow', (event) => {
// event.persisted is true ONLY when restored from bfcache, not on initial load
+5 -2
View File
@@ -1,12 +1,12 @@
{
"name": "koalasync",
"version": "2.1.3",
"version": "2.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "koalasync",
"version": "2.1.3",
"version": "2.2.2",
"devDependencies": {
"archiver": "^7.0.1",
"esbuild": "^0.28.0",
@@ -1240,6 +1240,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1373,6 +1374,7 @@
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -1895,6 +1897,7 @@
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
+6 -3
View File
@@ -77,9 +77,12 @@ assert.equal(adminHealth.avgPeersPerRoom, 2.5, 'admin metrics should include ave
assert.equal(adminHealth.maxPeersInRoom, 3, 'admin metrics should include max room size');
assert.deepEqual(adminHealth.memory, { rss: 10, heapUsed: 5, heapTotal: 8 }, 'admin metrics should expose process memory');
assert.deepEqual(
adminHealth.rateLimitEntries,
{ connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 },
'admin metrics should expose aggregate rate-limit map sizes'
adminHealth.rateLimits,
{
trackedClients: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 },
denied: { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 }
},
'admin metrics should expose rate-limit tracking and denial counts'
);
console.log('server ops tests passed');
+6 -3
View File
@@ -1,5 +1,8 @@
import assert from 'node:assert/strict';
import {
// Must set env before dynamic import — ESM hoists static imports
process.env.ADMIN_METRICS_TOKEN = process.env.ADMIN_METRICS_TOKEN || 'test-admin-token-with-more-than-32-chars';
const {
ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE,
HEALTH_RATE_LIMIT_PER_MINUTE,
healthCounts,
@@ -9,9 +12,9 @@ import {
rooms,
startServer,
stopServerForTests
} from '../server/index.js';
} = await import('../server/index.js');
const adminToken = process.env.ADMIN_METRICS_TOKEN || 'test-admin-token-with-more-than-32-chars';
const adminToken = process.env.ADMIN_METRICS_TOKEN;
const baseHeaders = { 'x-forwarded-for': '203.0.113.10' };
function url(path) {
+3
View File
@@ -10,4 +10,7 @@ RUN cd server && npm install --production
COPY server/ ./server/
WORKDIR /app/server
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "index.js"]
+26 -7
View File
@@ -82,7 +82,8 @@ app.get('/health', (req, res) => {
adminMetricsAuth: adminMetricsAuthCounts.size,
authFailures: failedAuthAttempts.size,
roomList: roomListCooldowns.size
}
},
rateLimitDenied
})
));
});
@@ -96,7 +97,7 @@ export const io = new Server(httpServer, {
if (!origin || origin === 'https://sync.koalastuff.net' || origin.startsWith('chrome-extension://') || origin.startsWith('moz-extension://')) {
callback(null, true);
} else {
log('CORS', `Rejected origin: ${origin}`);
log('CORS', `Rejected origin: ${(origin || '').replace(/[\r\n]/g, '')}`);
callback(new Error('Not allowed by CORS'));
}
},
@@ -192,6 +193,15 @@ export const healthCounts = new Map(); // ip -> { count, resetTime }
export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
// Actual rate-limit denial counters (incremented only when a request is denied)
const rateLimitDenied = {
connections: 0,
events: 0,
health: 0,
adminMetricsAuth: 0,
roomList: 0
};
// Clean up connection counts and event counts to prevent memory leak
const rateLimitCleanupInterval = setInterval(() => {
const now = Date.now();
@@ -228,7 +238,9 @@ function checkConnectionRate(ip) {
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
entry.count++;
connectionCounts.set(ip, entry);
return entry.count <= 10;
if (entry.count <= 10) return true;
rateLimitDenied.connections++;
return false;
}
function checkEventRate(socketId) {
@@ -237,7 +249,9 @@ function checkEventRate(socketId) {
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 10000; }
entry.count++;
eventCounts.set(socketId, entry);
return entry.count <= 30;
if (entry.count <= 30) return true;
rateLimitDenied.events++;
return false;
}
function checkHealthRate(ip) {
@@ -246,7 +260,9 @@ function checkHealthRate(ip) {
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
entry.count++;
healthCounts.set(ip, entry);
return entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE;
if (entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE) return true;
rateLimitDenied.health++;
return false;
}
function checkAdminMetricsAuthRate(ip) {
@@ -255,7 +271,9 @@ function checkAdminMetricsAuthRate(ip) {
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
entry.count++;
adminMetricsAuthCounts.set(ip, entry);
return entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE;
if (entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE) return true;
rateLimitDenied.adminMetricsAuth++;
return false;
}
/**
@@ -585,7 +603,7 @@ io.on('connection', (socket) => {
socket.to(mapping.roomId).emit(eventName, relayPayload);
// --- Side-effects: Server-side Episode Lobby Tracking ---
if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle) {
if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle && !room.activeLobby) {
room.activeLobby = {
expectedTitle: relayPayload.expectedTitle,
initiatorPeerId: mapping.peerId,
@@ -613,6 +631,7 @@ io.on('connection', (socket) => {
return;
}
if (!checkCooldown(roomListCooldowns, socket.id, ROOM_LIST_COOLDOWN_MS)) {
rateLimitDenied.roomList++;
socket.emit(EVENTS.ERROR, { message: 'Room list refresh is rate limited. Try again in a few seconds.' });
return;
}
+18 -8
View File
@@ -48,7 +48,8 @@ export function buildHealthPayload({
now = Date.now(),
uptime = 0,
memoryUsage = () => process.memoryUsage(),
rateLimitSizes = {}
rateLimitSizes = {},
rateLimitDenied = {}
}) {
const payload = {
status: 'ok',
@@ -75,13 +76,22 @@ export function buildHealthPayload({
roomsWithLobby: roomValues.filter(room => !!room.activeLobby).length,
avgPeersPerRoom,
maxPeersInRoom,
rateLimitEntries: {
connections: rateLimitSizes.connections || 0,
events: rateLimitSizes.events || 0,
health: rateLimitSizes.health || 0,
adminMetricsAuth: rateLimitSizes.adminMetricsAuth || 0,
authFailures: rateLimitSizes.authFailures || 0,
roomList: rateLimitSizes.roomList || 0
rateLimits: {
trackedClients: {
connections: rateLimitSizes.connections || 0,
events: rateLimitSizes.events || 0,
health: rateLimitSizes.health || 0,
adminMetricsAuth: rateLimitSizes.adminMetricsAuth || 0,
authFailures: rateLimitSizes.authFailures || 0,
roomList: rateLimitSizes.roomList || 0
},
denied: {
connections: rateLimitDenied.connections || 0,
events: rateLimitDenied.events || 0,
health: rateLimitDenied.health || 0,
adminMetricsAuth: rateLimitDenied.adminMetricsAuth || 0,
roomList: rateLimitDenied.roomList || 0
}
},
memory: {
rss: mem.rss,
+1
View File
@@ -4,6 +4,7 @@
"description": "",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
+3 -3
View File
@@ -21,7 +21,7 @@ export const USERNAME_ADJECTIVES = [
'Cosmic', 'Neon', 'Shadow', 'Crystal', 'Thunder', 'Silent', 'Golden',
'Fierce', 'Noble', 'Mystic', 'Frozen', 'Blazing', 'Sapphire', 'Iron', 'Crimson',
'Turbo', 'Zen', 'Pixel', 'Cyber', 'Solar', 'Lunar', 'Astro', 'Hyper',
'Steel', 'Rogue', 'Alpha', 'Omega', 'Royal', 'Pixel', 'Warp', 'Frost',
'Steel', 'Rogue', 'Alpha', 'Omega', 'Royal', 'Nitro', 'Warp', 'Frost',
];
export const USERNAME_NOUNS = [
@@ -39,7 +39,7 @@ export const USERNAME_NOUNS = [
'Mouse', 'Pig', 'Boar', 'Polar', 'Orangutan', 'Mammoth',
'Crow', 'Duck', 'Swan', 'Penguin', 'Parrot', 'Peacock',
'Dove', 'Dodo', 'Turkey', 'Flamingo', 'Chicken', 'Rooster', 'Goose',
'Dolphin', 'Whale', 'Crab', 'Lobster', 'Octopus', 'Squid',
'Dolphin', 'Whale', 'Crab', 'Lobster', 'Octopus', 'Opossum', 'Squid',
'Jellyfish', 'Turtle',
'Crocodile', 'Lizard', 'Snake', 'Frog', 'Toad', 'Gecko',
'Bee', 'Ant', 'Spider', 'Scorpion', 'Butterfly', 'Ladybug',
@@ -102,7 +102,7 @@ export const ANIMAL_EMOJI_MAP = {
'mammoth': '🦣',
'meerkat': '🦦',
'octopus': '🐙',
'opposum': '🐭',
'opossum': '🐭',
'ostrich': '🐦',
'panther': '🐆',
'pelican': '🦩',
-1
View File
@@ -175,6 +175,5 @@
"FOOTER_LEGAL_LINK": "imprint",
"FOOTER_PRIVACY_LINK": "privacy",
"FOOTER_DISCLAIMER": "KoalaSync não é afiliado, endossado ou associado à Netflix, Disney+, Amazon, YouTube, Twitch ou qualquer outra plataforma de streaming. Todas as marcas registradas são propriedade de seus respectivos titulares.",
"FOOTER_DISCLAIMER": "KoalaSync não é afiliado, endossado ou associado à Netflix, Disney+, Amazon, YouTube, Twitch ou qualquer outra plataforma de streaming. Todas as marcas registradas são propriedade de seus respectivos titulares.",
"FOOTER_SUPPORT": "Me pague um café no Ko-Fi"
}