diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..294035b --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +UDR_IP=192.168.1.1 +UDR_USER=admin +UDR_PASSWORD=changeme +APP_SECRET_KEY=changeme diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cacad58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.env +data/*.json +*.log +.DS_Store +CLAUDE.md +.env diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..fc723f3 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install --omit=dev +COPY src/ ./src/ +EXPOSE 3001 +CMD ["node", "src/index.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..c7cf215 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,17 @@ +{ + "name": "barkwho-backend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js" + }, + "dependencies": { + "express": "^4.18.2", + "axios": "^1.6.7", + "node-cron": "^3.0.3", + "cors": "^2.8.5", + "uuid": "^9.0.0" + } +} diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..92a23e2 --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,51 @@ +import express from 'express'; +import cors from 'cors'; +import { login } from './services/unifiClient.js'; +import { loadPolicies } from './services/policyEngine.js'; +import { loadTimers } from './services/timerService.js'; +import { loadCurfew } from './services/curfewService.js'; +import policiesRouter from './routes/policies.js'; +import devicesRouter from './routes/devices.js'; +import timersRouter from './routes/timers.js'; +import curfewRouter from './routes/curfew.js'; +import rulesRouter from './routes/rules.js'; + +const app = express(); +const PORT = 3001; + +app.use(cors()); +app.use(express.json()); + +app.use('/api/policies', policiesRouter); +app.use('/api/devices', devicesRouter); +app.use('/api/timers', timersRouter); +app.use('/api/curfew', curfewRouter); +app.use('/api/rules', rulesRouter); + +app.get('/api/health', (req, res) => { + res.json({ status: 'ok', timestamp: Date.now() }); +}); + +async function startup() { + console.log('[BarkWho] Starting backend...'); + + // Load persistent state first + loadPolicies(); + loadTimers(); + loadCurfew(); + + // Attempt UDR authentication (non-blocking if no creds) + const authed = await login(); + if (!authed) { + console.warn('[BarkWho] Running without UDR connection - configure .env to connect'); + } + + app.listen(PORT, '0.0.0.0', () => { + console.log(`[BarkWho] Backend listening on port ${PORT}`); + }); +} + +startup().catch((err) => { + console.error('[BarkWho] Startup failed:', err); + process.exit(1); +}); diff --git a/backend/src/routes/curfew.js b/backend/src/routes/curfew.js new file mode 100644 index 0000000..5153c4b --- /dev/null +++ b/backend/src/routes/curfew.js @@ -0,0 +1,34 @@ +import { Router } from 'express'; +import { getCurfew, updateCurfew, overrideCurfew } from '../services/curfewService.js'; + +const router = Router(); + +router.get('/', (req, res) => { + try { + res.json(getCurfew()); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.put('/', (req, res) => { + try { + const result = updateCurfew(req.body); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.post('/override', (req, res) => { + try { + const { minutes } = req.body; + if (!minutes) return res.status(400).json({ error: '"minutes" is required' }); + const result = overrideCurfew(Number(minutes)); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +export default router; diff --git a/backend/src/routes/devices.js b/backend/src/routes/devices.js new file mode 100644 index 0000000..bb4d372 --- /dev/null +++ b/backend/src/routes/devices.js @@ -0,0 +1,35 @@ +import { Router } from 'express'; +import { getClients, blockClient, unblockClient } from '../services/unifiClient.js'; + +const router = Router(); + +router.get('/', async (req, res) => { + try { + const clients = await getClients(); + res.json(clients); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.post('/:mac/nuke', async (req, res) => { + try { + const { mac } = req.params; + const result = await blockClient(decodeURIComponent(mac)); + res.json({ mac, blocked: true, result }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.post('/:mac/unnuke', async (req, res) => { + try { + const { mac } = req.params; + const result = await unblockClient(decodeURIComponent(mac)); + res.json({ mac, blocked: false, result }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +export default router; diff --git a/backend/src/routes/policies.js b/backend/src/routes/policies.js new file mode 100644 index 0000000..856e8c5 --- /dev/null +++ b/backend/src/routes/policies.js @@ -0,0 +1,63 @@ +import { Router } from 'express'; +import { getCategories, toggleCategory, assignRules, addCategory, deleteCategory } from '../services/policyEngine.js'; + +const router = Router(); + +router.get('/', async (req, res) => { + try { + const categories = await getCategories(); + res.json(categories); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.post('/:category/toggle', async (req, res) => { + try { + const { category } = req.params; + const { allowed } = req.body; + if (typeof allowed !== 'boolean') { + return res.status(400).json({ error: '"allowed" must be a boolean' }); + } + const results = await toggleCategory(decodeURIComponent(category), allowed); + res.json({ category, allowed, results }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.put('/:category/rules', (req, res) => { + try { + const { category } = req.params; + const { ruleIds } = req.body; + if (!Array.isArray(ruleIds)) { + return res.status(400).json({ error: '"ruleIds" must be an array' }); + } + const result = assignRules(decodeURIComponent(category), ruleIds); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.post('/', (req, res) => { + try { + const { name, icon } = req.body; + if (!name) return res.status(400).json({ error: '"name" is required' }); + const result = addCategory(name, icon); + res.json(result); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +router.delete('/:category', (req, res) => { + try { + deleteCategory(decodeURIComponent(req.params.category)); + res.json({ success: true }); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +export default router; diff --git a/backend/src/routes/rules.js b/backend/src/routes/rules.js new file mode 100644 index 0000000..d5cfb80 --- /dev/null +++ b/backend/src/routes/rules.js @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import { getTrafficRules } from '../services/unifiClient.js'; + +const router = Router(); + +router.get('/', async (req, res) => { + try { + const rules = await getTrafficRules(); + const list = Array.isArray(rules) ? rules : rules?.data || []; + res.json(list); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +export default router; diff --git a/backend/src/routes/timers.js b/backend/src/routes/timers.js new file mode 100644 index 0000000..668aca3 --- /dev/null +++ b/backend/src/routes/timers.js @@ -0,0 +1,36 @@ +import { Router } from 'express'; +import { getTimers, createTimer, cancelTimer } from '../services/timerService.js'; + +const router = Router(); + +router.get('/', (req, res) => { + try { + res.json(getTimers()); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.post('/', async (req, res) => { + try { + const { category, minutes } = req.body; + if (!category || !minutes) { + return res.status(400).json({ error: '"category" and "minutes" are required' }); + } + const timer = await createTimer(category, Number(minutes)); + res.json(timer); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.delete('/:id', (req, res) => { + try { + const timer = cancelTimer(req.params.id); + res.json({ cancelled: true, timer }); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +export default router; diff --git a/backend/src/services/curfewService.js b/backend/src/services/curfewService.js new file mode 100644 index 0000000..bbb66e1 --- /dev/null +++ b/backend/src/services/curfewService.js @@ -0,0 +1,124 @@ +import fs from 'fs'; +import path from 'path'; +import cron from 'node-cron'; +import { toggleCategory } from './policyEngine.js'; + +const DATA_PATH = '/data/curfew.json'; + +const DEFAULT_CURFEW = { + enabled: true, + blockTime: '21:00', + unblockTime: '07:00', + categories: ['Social Media', 'Streaming', 'Gaming'], + overrideUntil: null, +}; + +let curfewConfig = {}; +let blockJob = null; +let unblockJob = null; + +export function loadCurfew() { + try { + if (fs.existsSync(DATA_PATH)) { + const raw = fs.readFileSync(DATA_PATH, 'utf-8'); + curfewConfig = JSON.parse(raw); + console.log('[Curfew] Loaded curfew config from disk'); + } else { + curfewConfig = { ...DEFAULT_CURFEW }; + saveCurfew(); + console.log('[Curfew] Created default curfew config'); + } + } catch (err) { + console.error('[Curfew] Error loading curfew:', err.message); + curfewConfig = { ...DEFAULT_CURFEW }; + } + scheduleCurfew(); +} + +function saveCurfew() { + const dir = path.dirname(DATA_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(DATA_PATH, JSON.stringify(curfewConfig, null, 2)); +} + +function timeToCron(timeStr) { + const [hours, minutes] = timeStr.split(':').map(Number); + return `${minutes} ${hours} * * *`; +} + +function scheduleCurfew() { + if (blockJob) { blockJob.stop(); blockJob = null; } + if (unblockJob) { unblockJob.stop(); unblockJob = null; } + + if (!curfewConfig.enabled) { + console.log('[Curfew] Curfew is disabled'); + return; + } + + const blockCron = timeToCron(curfewConfig.blockTime); + const unblockCron = timeToCron(curfewConfig.unblockTime); + + blockJob = cron.schedule(blockCron, async () => { + if (isOverrideActive()) { + console.log('[Curfew] Override active, skipping block'); + return; + } + console.log('[Curfew] Bedtime! Blocking categories...'); + for (const cat of curfewConfig.categories) { + try { + await toggleCategory(cat, false); + } catch (err) { + console.error(`[Curfew] Failed to block ${cat}:`, err.message); + } + } + }); + + unblockJob = cron.schedule(unblockCron, async () => { + console.log('[Curfew] Good morning! Unblocking categories...'); + curfewConfig.overrideUntil = null; + saveCurfew(); + for (const cat of curfewConfig.categories) { + try { + await toggleCategory(cat, true); + } catch (err) { + console.error(`[Curfew] Failed to unblock ${cat}:`, err.message); + } + } + }); + + console.log(`[Curfew] Scheduled: block at ${curfewConfig.blockTime}, unblock at ${curfewConfig.unblockTime}`); +} + +function isOverrideActive() { + if (!curfewConfig.overrideUntil) return false; + if (Date.now() < curfewConfig.overrideUntil) return true; + curfewConfig.overrideUntil = null; + saveCurfew(); + return false; +} + +export function getCurfew() { + return { + ...curfewConfig, + overrideActive: isOverrideActive(), + }; +} + +export function updateCurfew(updates) { + Object.assign(curfewConfig, updates); + saveCurfew(); + scheduleCurfew(); + return getCurfew(); +} + +export function overrideCurfew(minutes) { + curfewConfig.overrideUntil = Date.now() + minutes * 60 * 1000; + saveCurfew(); + // Unblock categories during override + for (const cat of curfewConfig.categories) { + toggleCategory(cat, true).catch((err) => + console.error(`[Curfew] Override unblock failed for ${cat}:`, err.message) + ); + } + return getCurfew(); +} diff --git a/backend/src/services/policyEngine.js b/backend/src/services/policyEngine.js new file mode 100644 index 0000000..7f97633 --- /dev/null +++ b/backend/src/services/policyEngine.js @@ -0,0 +1,109 @@ +import fs from 'fs'; +import path from 'path'; +import { getTrafficRules, getTrafficRule, updateTrafficRule } from './unifiClient.js'; + +const DATA_PATH = '/data/policy_map.json'; + +const DEFAULT_CATEGORIES = { + 'Social Media': { ruleIds: [], icon: 'social' }, + 'Streaming': { ruleIds: [], icon: 'streaming' }, + 'Adult Content': { ruleIds: [], icon: 'adult' }, + 'Gaming': { ruleIds: [], icon: 'gaming' }, +}; + +let policyMap = {}; + +export function loadPolicies() { + try { + if (fs.existsSync(DATA_PATH)) { + const raw = fs.readFileSync(DATA_PATH, 'utf-8'); + policyMap = JSON.parse(raw); + console.log('[PolicyEngine] Loaded policy map from disk'); + } else { + policyMap = structuredClone(DEFAULT_CATEGORIES); + savePolicies(); + console.log('[PolicyEngine] Created default policy map'); + } + } catch (err) { + console.error('[PolicyEngine] Error loading policies:', err.message); + policyMap = structuredClone(DEFAULT_CATEGORIES); + } +} + +function savePolicies() { + const dir = path.dirname(DATA_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(DATA_PATH, JSON.stringify(policyMap, null, 2)); +} + +export async function getCategories() { + const allRules = await getTrafficRules(); + const ruleList = Array.isArray(allRules) ? allRules : allRules?.data || []; + const ruleMap = new Map(ruleList.map((r) => [r._id, r])); + + const categories = {}; + for (const [name, config] of Object.entries(policyMap)) { + const rules = config.ruleIds.map((id) => { + const rule = ruleMap.get(id); + return { + id, + name: rule?.description || rule?.name || id, + enabled: rule?.enabled ?? false, + }; + }); + // Category is "allowed" when ALL its rules are disabled (not blocking) + const allDisabled = rules.length === 0 || rules.every((r) => !r.enabled); + categories[name] = { + icon: config.icon, + ruleIds: config.ruleIds, + rules, + allowed: allDisabled, + }; + } + return categories; +} + +export async function toggleCategory(categoryName, allowed) { + const config = policyMap[categoryName]; + if (!config) throw new Error(`Category "${categoryName}" not found`); + + const results = []; + for (const ruleId of config.ruleIds) { + const rule = await getTrafficRule(ruleId); + if (!rule) { + results.push({ ruleId, success: false, error: 'Rule not found' }); + continue; + } + // Inversion: UI "allowed" = rule disabled, UI "blocked" = rule enabled + rule.enabled = !allowed; + try { + await updateTrafficRule(ruleId, rule); + results.push({ ruleId, success: true }); + } catch (err) { + results.push({ ruleId, success: false, error: err.message }); + } + } + return results; +} + +export function assignRules(categoryName, ruleIds) { + if (!policyMap[categoryName]) { + policyMap[categoryName] = { ruleIds: [], icon: 'custom' }; + } + policyMap[categoryName].ruleIds = ruleIds; + savePolicies(); + return policyMap[categoryName]; +} + +export function addCategory(name, icon = 'custom') { + if (policyMap[name]) throw new Error(`Category "${name}" already exists`); + policyMap[name] = { ruleIds: [], icon }; + savePolicies(); + return policyMap[name]; +} + +export function deleteCategory(name) { + if (!policyMap[name]) throw new Error(`Category "${name}" not found`); + delete policyMap[name]; + savePolicies(); +} diff --git a/backend/src/services/timerService.js b/backend/src/services/timerService.js new file mode 100644 index 0000000..243b549 --- /dev/null +++ b/backend/src/services/timerService.js @@ -0,0 +1,116 @@ +import fs from 'fs'; +import path from 'path'; +import { v4 as uuidv4 } from 'uuid'; +import { toggleCategory } from './policyEngine.js'; + +const DATA_PATH = '/data/timers.json'; + +let activeTimers = []; +let timeoutHandles = new Map(); + +export function loadTimers() { + try { + if (fs.existsSync(DATA_PATH)) { + const raw = fs.readFileSync(DATA_PATH, 'utf-8'); + activeTimers = JSON.parse(raw); + console.log(`[TimerService] Loaded ${activeTimers.length} timers from disk`); + resumeTimers(); + } else { + activeTimers = []; + saveTimers(); + } + } catch (err) { + console.error('[TimerService] Error loading timers:', err.message); + activeTimers = []; + } +} + +function saveTimers() { + const dir = path.dirname(DATA_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(DATA_PATH, JSON.stringify(activeTimers, null, 2)); +} + +function resumeTimers() { + const now = Date.now(); + const expired = []; + const valid = []; + + for (const timer of activeTimers) { + const remaining = timer.expiresAt - now; + if (remaining <= 0) { + expired.push(timer); + } else { + valid.push(timer); + scheduleTimeout(timer, remaining); + } + } + + // Fire expired timers immediately + for (const timer of expired) { + console.log(`[TimerService] Timer ${timer.id} expired while offline, re-blocking ${timer.category}`); + onTimerExpire(timer); + } +} + +function scheduleTimeout(timer, ms) { + const handle = setTimeout(() => onTimerExpire(timer), ms); + timeoutHandles.set(timer.id, handle); +} + +async function onTimerExpire(timer) { + console.log(`[TimerService] Timer expired: ${timer.id} for ${timer.category}`); + try { + await toggleCategory(timer.category, false); // Re-block + } catch (err) { + console.error(`[TimerService] Failed to re-block ${timer.category}:`, err.message); + } + activeTimers = activeTimers.filter((t) => t.id !== timer.id); + timeoutHandles.delete(timer.id); + saveTimers(); +} + +export async function createTimer(category, minutes) { + const now = Date.now(); + const expiresAt = now + minutes * 60 * 1000; + const timer = { + id: uuidv4(), + category, + minutes, + createdAt: now, + expiresAt, + }; + + // Allow the category during bonus time + await toggleCategory(category, true); + + activeTimers.push(timer); + saveTimers(); + scheduleTimeout(timer, minutes * 60 * 1000); + + console.log(`[TimerService] Created timer ${timer.id}: ${category} for ${minutes}m`); + return timer; +} + +export function getTimers() { + const now = Date.now(); + return activeTimers.map((t) => ({ + ...t, + remainingMs: Math.max(0, t.expiresAt - now), + })); +} + +export function cancelTimer(id) { + const timer = activeTimers.find((t) => t.id === id); + if (!timer) throw new Error('Timer not found'); + + const handle = timeoutHandles.get(id); + if (handle) clearTimeout(handle); + timeoutHandles.delete(id); + + activeTimers = activeTimers.filter((t) => t.id !== id); + saveTimers(); + + console.log(`[TimerService] Cancelled timer ${id}`); + return timer; +} diff --git a/backend/src/services/unifiClient.js b/backend/src/services/unifiClient.js new file mode 100644 index 0000000..8bd3183 --- /dev/null +++ b/backend/src/services/unifiClient.js @@ -0,0 +1,123 @@ +import axios from 'axios'; +import https from 'https'; + +const agent = new https.Agent({ rejectUnauthorized: false }); + +let baseURL = ''; +let cookies = ''; +let csrfToken = ''; + +function getEnvConfig() { + const ip = process.env.UDR_IP; + const user = process.env.UDR_USER; + const pass = process.env.UDR_PASSWORD; + if (!ip || !user || !pass) { + return null; + } + return { ip, user, pass }; +} + +const client = axios.create({ httpsAgent: agent, timeout: 10000 }); + +client.interceptors.response.use( + (res) => res, + async (error) => { + const original = error.config; + if (error.response?.status === 401 && !original._retry) { + original._retry = true; + await login(); + original.headers['Cookie'] = cookies; + original.headers['x-csrf-token'] = csrfToken; + return client(original); + } + return Promise.reject(error); + } +); + +export async function login() { + const config = getEnvConfig(); + if (!config) { + console.warn('[UniFi] No UDR credentials configured - running in demo mode'); + return false; + } + baseURL = `https://${config.ip}`; + try { + const res = await axios.post( + `${baseURL}/api/auth/login`, + { username: config.user, password: config.pass }, + { httpsAgent: agent, timeout: 10000 } + ); + const setCookie = res.headers['set-cookie']; + if (setCookie) { + cookies = setCookie.map((c) => c.split(';')[0]).join('; '); + } + csrfToken = res.headers['x-csrf-token'] || ''; + console.log('[UniFi] Authenticated successfully'); + return true; + } catch (err) { + console.error('[UniFi] Login failed:', err.message); + return false; + } +} + +function authHeaders() { + return { + Cookie: cookies, + 'x-csrf-token': csrfToken, + }; +} + +export async function getTrafficRules() { + if (!baseURL) return []; + const res = await client.get( + `${baseURL}/proxy/network/v2/api/site/default/trafficrules`, + { headers: authHeaders() } + ); + return res.data; +} + +export async function getTrafficRule(ruleId) { + if (!baseURL) return null; + const rules = await getTrafficRules(); + const list = Array.isArray(rules) ? rules : rules?.data || []; + return list.find((r) => r._id === ruleId) || null; +} + +export async function updateTrafficRule(ruleId, rulePayload) { + if (!baseURL) return null; + const res = await client.put( + `${baseURL}/proxy/network/v2/api/site/default/trafficrules/${ruleId}`, + rulePayload, + { headers: { ...authHeaders(), 'Content-Type': 'application/json' } } + ); + return res.data; +} + +export async function getClients() { + if (!baseURL) return []; + const res = await client.get( + `${baseURL}/proxy/network/api/s/default/stat/sta`, + { headers: authHeaders() } + ); + return res.data?.data || res.data || []; +} + +export async function blockClient(mac) { + if (!baseURL) return null; + const res = await client.post( + `${baseURL}/proxy/network/api/s/default/cmd/stamgr`, + { cmd: 'block-sta', mac: mac.toLowerCase() }, + { headers: { ...authHeaders(), 'Content-Type': 'application/json' } } + ); + return res.data; +} + +export async function unblockClient(mac) { + if (!baseURL) return null; + const res = await client.post( + `${baseURL}/proxy/network/api/s/default/cmd/stamgr`, + { cmd: 'unblock-sta', mac: mac.toLowerCase() }, + { headers: { ...authHeaders(), 'Content-Type': 'application/json' } } + ); + return res.data; +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1c4855d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + backend: + build: ./backend + ports: + - "3001:3001" + volumes: + - ./data:/data + env_file: + - .env + restart: unless-stopped + + frontend: + build: ./frontend + ports: + - "5173:5173" + depends_on: + - backend + restart: unless-stopped diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..342df35 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +EXPOSE 5173 +CMD ["npm", "run", "dev"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4e11756 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + +
+ + + + + + +