Files
eWOL/ewol-install.sh
2025-08-24 11:27:25 +02:00

519 lines
16 KiB
Bash

#!/bin/sh
# eWOL — enhanced Wake-On-LAN UI for OpenWrt (vanilla uhttpd) or GL.iNet devices
#
# Features:
# - Dark/light UI (switch, remembers preference, default dark)
# - Font size zoom +/- buttons (remembers preference)
# - Simple Lua CGI API using LuCI libs
# - Self-contained in /www/ewol ; single symlink into /www/cgi-bin
#
# Optional:
# - Set ALLOW_PUBLIC=0 to restrict to LAN only (basic check).
# - You can tweak LAN_IF and PING_TIMEOUT if needed.
# (GL.iNet devices usually have br-lan as default, but please double-check yours first.)
set -eu
EWOL_DIR="/www/ewol"
CGI_LINK="/www/cgi-bin/ewol-ctl"
LAN_IF="br-lan"
PING_TIMEOUT=1
ALLOW_PUBLIC=0
mkdir -p "$EWOL_DIR"
# ---------------
# index.html
# ---------------
cat > "$EWOL_DIR/index.html" << 'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<link rel="icon" type="image/png" href="/luci-static/bootstrap/favicon.png"/>
<link rel="stylesheet" href="/ewol/style.css"/>
<title>eWOL: enhanced Wake-On-LAN</title>
</head>
<body>
<header>
<div class="wrap row">
<h1>eWOL - <span class="muted">enhanced Wake-On-LAN</span></h1>
<div class="row" style="gap:8px">
<span id="status" class="pill">Idle</span>
<label class="toggle"><input id="themeToggle" type="checkbox" checked/> Dark mode</label>
<button id="fontPlus" class="font-resize-btn" title="Increase font size">A+</button>
<button id="fontMinus" class="font-resize-btn" title="Decrease font size">A-</button>
<button id="refresh">Refresh</button>
</div>
</div>
</header>
<main class="wrap">
<div id="err" class="card" style="display:none"></div>
<div class="card">
<table id="grid">
<thead>
<tr>
<th class="sortable" data-key="name">Hostname</th>
<th class="sortable" data-key="mac">MAC</th>
<th class="sortable" data-key="ip">IP</th>
<th>Status</th>
<th class="center">Action</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="footer">
Served from <code>/www/ewol</code><br/>
API: <code>/cgi-bin/ewol-ctl</code><br/>
<a href="https://github.com/domomg/ewol" target="_blank" rel="noopener">GitHub: domomg/ewol</a>
</div>
</main>
<script src="/ewol/script.js" defer></script>
</body>
</html>
EOF
# ---------------------------
# style.css
# ---------------------------
cat > "$EWOL_DIR/style.css" << 'EOF'
:root {
--bg: #0b0c10; --fg: #e5e7eb; --muted:#9ca3af; --card:#111827; --border:#1f2937; --accent:#60a5fa; --ok:#22c55e; --err:#ef4444;
--base-font-size: 14px;
}
@media (prefers-color-scheme: light){
:root { --bg:#f7fafc; --fg:#111827; --muted:#6b7280; --card:#ffffff; --border:#e5e7eb; --accent:#2563eb; --ok:#16a34a; --err:#dc2626; }
}
[data-theme="light"]:root { --bg:#f7fafc; --fg:#111827; --muted:#6b7280; --card:#ffffff; --border:#e5e7eb; --accent:#2563eb; --ok:#16a34a; --err:#dc2626; }
[data-theme="dark"]:root { --bg:#0b0c10; --fg:#e5e7eb; --muted:#9ca3af; --card:#111827; --border:#1f2937; --accent:#60a5fa; --ok:#22c55e; --err:#ef4444; }
* { box-sizing: border-box }
body {
margin:0;
font-size: var(--base-font-size, 14px);
line-height: 1.45;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, sans-serif;
background:var(--bg); color:var(--fg);
}
header { position: sticky; top:0; z-index:10; backdrop-filter: blur(6px); background: color-mix(in oklab, var(--bg) 88%, transparent); border-bottom:1px solid var(--border); }
.wrap { max-width: 980px; margin: 0 auto; padding: 16px; }
h1 { margin:0; font-size: 20px; }
.muted { color: var(--muted); }
.row { display:flex; gap:12px; align-items:center; justify-content:space-between; flex-wrap:wrap; }
button, .btn { cursor:pointer; border:1px solid var(--border); background:var(--card); color:var(--fg); padding:6px 10px; border-radius:10px; }
button:hover { border-color: var(--accent); }
.pill { font-size:12px; padding:3px 8px; border-radius:999px; border:1px solid var(--border); }
.ok { color: var(--ok); }
.err { color: var(--err); }
main { padding:16px; }
.card { background:var(--card); border:1px solid var(--border); border-radius:14px; padding: 12px; }
table { width:100%; border-collapse: collapse; }
th, td { padding: 10px; border-bottom:1px solid var(--border); text-align:left; }
th { font-weight:600; user-select:none; }
th.sortable { cursor:pointer; }
tr:hover td { background: color-mix(in oklab, var(--card) 92%, var(--accent) 8% ); }
.actions { display:flex; gap:8px; }
.center { text-align:center; }
.footer { color: var(--muted); font-size:12px; padding:12px 0 20px }
.toggle { display:inline-flex; align-items:center; gap:8px }
img.status-icon { height:18px;vertical-align:middle;margin-right:3px; }
.font-resize-btn { font-size:16px; min-width:32px; }
EOF
# ---------------
# script.js
# ---------------
cat > "$EWOL_DIR/script.js" << 'EOF'
'use strict';
(function(){
const api = '/cgi-bin/ewol-ctl';
const $ = (sel, el=document)=>el.querySelector(sel);
// Status icons for eWOL, GL.iNet default LuCI resources
const ICONS = {
Online: '/luci-static/resources/icons/port_up.png',
Offline: '/luci-static/resources/icons/port_down.png',
Loading: '/luci-static/resources/icons/loading.gif',
Sent: '/luci-static/resources/icons/port_up.png',
Error: '/luci-static/resources/icons/signal-none.png'
};
const state = { rows: [], sortKey:'name', sortDir:1, statusMap: {} };
// --- FONT SIZE LOGIC ---
const FONT_MIN = 10, FONT_MAX = 22, FONT_STEP = 2, FONT_DEFAULT = 14;
function setFontSize(size) {
size = Math.max(FONT_MIN, Math.min(FONT_MAX, size));
document.documentElement.style.setProperty('--base-font-size', size + 'px');
try { localStorage.setItem('ewol-font-size', size); } catch(e){}
}
function loadFontSize() {
let size = FONT_DEFAULT;
try {
size = parseInt(localStorage.getItem('ewol-font-size')) || FONT_DEFAULT;
} catch(e){}
setFontSize(size);
}
function handleFontPlus() {
let size = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--base-font-size')) || FONT_DEFAULT;
setFontSize(size + FONT_STEP);
}
function handleFontMinus() {
let size = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--base-font-size')) || FONT_DEFAULT;
setFontSize(size - FONT_STEP);
}
function setStatus(text, cls){
const pill = $('#status');
pill.textContent = text;
pill.className = 'pill ' + (cls||'');
}
function showError(msg){
const box = $('#err');
box.style.display = 'block';
box.textContent = msg;
setStatus('Error', 'err');
}
function sortRows(){
const k = state.sortKey, d = state.sortDir;
state.rows.sort((a,b)=>{
const x = a[k] ?? '', y = b[k] ?? '';
return (String(x).localeCompare(String(y), undefined, {numeric:true})) * d;
});
}
function render(){
sortRows();
const tb = $('#grid tbody');
tb.innerHTML = '';
for(const r of state.rows){
const statusText = state.statusMap[r.name] || '';
let statusIcon = '';
let label = '';
let statusClass = '';
if(statusText === 'Online') {
statusIcon = ICONS.Online; label = 'Online'; statusClass = 'ok';
} else if(statusText === 'Offline') {
statusIcon = ICONS.Offline; label = 'Offline'; statusClass = 'err';
} else if(statusText === 'Loading') {
statusIcon = ICONS.Loading; label = ''; statusClass = '';
} else if(statusText === 'Error') {
statusIcon = ICONS.Error; label = 'Error'; statusClass = 'err';
} else if(statusText === 'Sent') {
statusIcon = ICONS.Sent; label = 'Sent!'; statusClass = 'ok';
}
// For Sent, show ASCII green check and "Sent!", we don't have any usable resource on GL.iNet devices
const iconHtml = (label === 'Sent!') ? '<span style="color:var(--ok);font-weight:bold;">✔</span> ' : (statusIcon ? `<img class="status-icon" src="${statusIcon}" alt="">` : '');
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.name||''}</td>
<td><code>${r.mac||''}</code></td>
<td><code>${r.ip||''}</code></td>
<td><span id="status-${r.name}" class="${statusClass}">${iconHtml}${label}</span></td>
<td class="center actions">
<button data-act="wake" data-host="${r.name}">Wake</button>
<button data-act="ping" data-host="${r.name}">Check</button>
</td>`;
tb.appendChild(tr);
}
}
async function call(cmd, params={}){
const url = new URL(location.origin + api);
url.searchParams.set('cmd', cmd);
Object.entries(params).forEach(([k,v])=>url.searchParams.set(k,v));
const res = await fetch(url, { cache:'no-store' });
if(!res.ok) throw new Error('HTTP '+res.status);
const data = await res.json();
if(!data.ok) throw new Error(data.err||'Unknown error');
return data.data;
}
async function loadHosts(){
try{
setStatus('Loading…');
const data = await call('hl');
state.rows = data;
state.statusMap = {};
render();
setStatus('Ready');
}catch(e){ showError('Failed to load hosts: '+e.message); }
}
async function wake(name){
state.statusMap[name] = 'Sent';
render();
setStatus('Waking…');
try{
await call('hw', { host:name });
setStatus('Magic packet sent', 'ok');
}catch(e){
state.statusMap[name] = 'Error';
render();
showError('Wake failed: '+e.message);
}
}
async function ping(name){
state.statusMap[name] = 'Loading';
render();
setStatus('Checking…');
try{
const data = await call('ping', { host:name });
const online = !!data.online;
state.statusMap[name] = online ? 'Online' : 'Offline';
render();
setStatus('Ready', online ? 'ok' : 'err');
}catch(e){
state.statusMap[name] = 'Error';
render();
showError('Ping failed: '+e.message);
}
}
function handleClick(ev){
const btn = ev.target.closest('button[data-act]');
if(!btn) return;
const host = btn.dataset.host;
if(btn.dataset.act==='wake') wake(host);
if(btn.dataset.act==='ping') ping(host);
}
// --- THEME LOGIC ---
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
$('#themeToggle').checked = theme === 'dark';
try { localStorage.setItem('theme', theme); } catch(e){}
}
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function loadTheme() {
let theme = 'dark';
try {
theme = localStorage.getItem('theme') || getSystemTheme();
} catch(e){
theme = getSystemTheme();
}
applyTheme(theme);
}
function handleThemeToggle() {
const theme = $('#themeToggle').checked ? 'dark' : 'light';
applyTheme(theme);
}
function handleSort(ev){
const th = ev.target.closest('th.sortable');
if(!th) return;
const key = th.dataset.key;
if(key){
if(state.sortKey === key){
state.sortDir = -state.sortDir;
}else{
state.sortKey = key; state.sortDir = 1;
}
render();
}
}
function init(){
$('#grid').addEventListener('click', handleClick);
$('#grid thead').addEventListener('click', handleSort);
$('#refresh').addEventListener('click', loadHosts);
// Theme logic
$('#themeToggle').addEventListener('change', handleThemeToggle);
loadTheme();
// Font size logic
$('#fontPlus').addEventListener('click', handleFontPlus);
$('#fontMinus').addEventListener('click', handleFontMinus);
loadFontSize();
loadHosts();
}
window.addEventListener('DOMContentLoaded', init);
})();
EOF
# ---------------------------
# api.lua (CGI) -- fix ping for all OpenWrt
# ---------------------------
cat > "$EWOL_DIR/api.lua" << 'EOF'
#!/usr/bin/lua
-- eWOL CGI API
local json = require('luci.jsonc')
local nixio = require('nixio')
local sys = require('luci.sys')
local ip = require('luci.ip')
local DEVICES_JSON = '/www/ewol/devices.json'
local LAN_IF = 'br-lan'
local PING_TIMEOUT = 1
local ALLOW_PUBLIC = 0 -- Set 1 to allow WAN/public
local function in_lan(addr)
local a = ip.IPv4(addr)
local dev = ip.IPv4(ip.getaddr(LAN_IF) or '0.0.0.0/0')
return a and dev and a:is4() and dev:contains(a)
end
local function enforce_origin()
if ALLOW_PUBLIC == 1 then return true end
local ra = nixio.getenv('REMOTE_ADDR') or ''
if ra == '' then return false end
if in_lan(ra) then return true end
return false
end
local function wol_send(mac)
return sys.call(string.format("etherwake -D -i %s %q >/dev/null 2>&1", LAN_IF, mac)) == 0
end
-- Use system ping for best compatibility, not luci.sys.ping
local function ping_host(ipaddr)
return os.execute("ping -c 1 -w 1 " .. ipaddr .. " >/dev/null 2>&1") == 0
end
local function load_devices()
local f = io.open(DEVICES_JSON, 'r')
if not f then return nil end
local data = f:read('*a')
f:close()
return json.parse(data)
end
local function json_out(tbl)
io.write('Status: 200 OK\r\n')
io.write('Content-Type: application/json\r\n\r\n')
io.write(json.stringify(tbl))
end
local function main()
local args = require('luci.http').urldecode_params(nixio.getenv('QUERY_STRING') or '')
local ret = { ok=false }
if not enforce_origin() then
ret.err = 'Forbidden: not from LAN';
return json_out(ret)
end
local devices = load_devices()
if not devices then ret.err = 'Missing devices.json'; return json_out(ret) end
local cmd = args.cmd or 'hl'
if cmd == 'hl' then
local rows = {}
for name, info in pairs(devices) do
rows[#rows+1] = { name=name, mac=info.mac, ip=info.ip }
end
ret.ok = true; ret.data = rows
elseif cmd == 'hw' then
local h = devices[args.host]
if not h then ret.err = 'Unknown host'
else
if wol_send(h.mac) then
ret.ok = true; ret.data = { sent=true }
else ret.err = 'WoL command failed' end
end
elseif cmd == 'ping' then
local h = devices[args.host]
if not h or not h.ip then ret.err = 'Unknown host or missing IP'
else
local online = ping_host(h.ip)
ret.ok = true; ret.data = { online = online }
end
else
ret.err = 'Unknown command'
end
return json_out(ret)
end
main()
EOF
# ---------------------------
# devices.json sample file
# ---------------------------
if [ ! -f "$EWOL_DIR/devices.json" ]; then
cat > "$EWOL_DIR/devices.json" << 'EOF'
{
"PC1": {
"mac": "11:11:11:11:11:11",
"ip": "192.168.1.101"
},
"PC2": {
"mac": "22:22:22:22:22:22",
"ip": "192.168.1.102"
},
"PC3": {
"mac": "33:33:33:33:33:33",
"ip": "192.168.1.103"
}
}
EOF
fi
# ---------------------------
# permissions + cgi link
# ---------------------------
chmod 644 "$EWOL_DIR/index.html"
chmod 644 "$EWOL_DIR/script.js"
chmod 644 "$EWOL_DIR/style.css"
chmod 644 "$EWOL_DIR/devices.json"
chmod 755 "$EWOL_DIR/api.lua"
[ -L "$CGI_LINK" ] || ln -s "$EWOL_DIR/api.lua" "$CGI_LINK"
cat <<EOF
eWOL installed!
----------------------------------------------------------------------------
Next steps:
1) Edit $EWOL_DIR/devices.json with your hosts:
{
"office-pc": { "mac": "AA:BB:CC:DD:EE:FF", "ip": "192.168.1.100" },
"nas": { "mac": "11:22:33:44:55:66", "ip": "192.168.1.101" }
}
2) Install etherwake if missing:
opkg update && opkg install etherwake
3) Open http://<router-ip>/ewol/index.html
Optional settings:
If you attempt to open http://<router-ip>/ewol and it gives 403, set
'option index_page "index.html"'
in /etc/config/uhttpd and restart uhttpd with
/etc/init.d/uhttpd restart
----------------------------------------------------------------------------
Optional security:
Default denies access from WAN. To allow public access, export
EWOL_ALLOW_PUBLIC=1 in uhttpd env or set ALLOW_PUBLIC=1 in api.lua.
----------------------------------------------------------------------------
EOF