├── .github └── FUNDING.yml ├── config.php ├── config.toml.example ├── LICENSE ├── README.md ├── report.py └── index.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | ko_fi: dusoft 4 | -------------------------------------------------------------------------------- /config.php: -------------------------------------------------------------------------------- 1 | = (3, 11): 15 | import tomllib 16 | else: 17 | import tomli as tomllib 18 | import subprocess 19 | import json 20 | import requests 21 | import binascii 22 | 23 | with open("config.toml", "rb") as f: 24 | config = tomllib.load(f) 25 | 26 | content={} 27 | 28 | # always extract hostname 29 | output=subprocess.run("/usr/bin/hostname", universal_newlines = True, stdout = subprocess.PIPE) 30 | hostname=output.stdout.strip() 31 | content["hostname"]=hostname 32 | 33 | headers = {"User-Agent": "RPi Monitor Dashboard/"+__version__, "X-Hostname": hostname, "X-Crc32": str(binascii.crc32(open("report.py", "rb").read())), "Content-Type": "application/json"} 34 | 35 | # check for available updates 36 | response=requests.head(config["receiver"], headers = headers) 37 | 38 | # check for python script update and overwrite, if auto update enabled 39 | if "X-Update" in response.headers and response.headers["X-Update"][2]=="1" and config['auto_update']==True: 40 | response=requests.get(config["receiver"]+'?update=3', headers = headers) 41 | try: 42 | if len(response.text)>0: 43 | subprocess.run('cp report.py report.py.bak', shell = True) 44 | f = open("report.py", "w") 45 | f.write(response.text) 46 | f.close() 47 | except Exception: 48 | # ignore, skip 49 | pass 50 | 51 | # check for one-time commands and proceed with execution 52 | if "X-Update" in response.headers and response.headers["X-Update"][1]=="1": 53 | response=requests.get(config["receiver"]+'?update=2', headers = headers) 54 | commands=response.text.split("\n") 55 | try: 56 | for line_no, command in enumerate(commands): 57 | command=command.replace("{hostname}", hostname) 58 | subprocess.run([command], shell = True) 59 | except Exception: 60 | # ignore, skip 61 | pass 62 | 63 | # check for config update and proceed with update 64 | if "X-Update" in response.headers and response.headers["X-Update"][0]=="1": 65 | # backup current config in case the new one is messed up 66 | subprocess.run('cp config.toml config.toml.bak', shell = True) 67 | response=requests.get(config["receiver"]+'?update=1', headers = headers) 68 | current_config=open("config.toml", "rt").read() 69 | current_config_lines=current_config.split("\n") 70 | new_config='' 71 | for line_no, line in enumerate(current_config_lines): 72 | new_config=new_config+line+"\n" 73 | # search for our special line 74 | pos=line.strip().find("#-#*+*#-#") 75 | # if found, assemble new config from existing receiver and config update received 76 | if pos!=-1: 77 | new_config=new_config+response.text; 78 | new_config=new_config.strip() 79 | f = open("config.toml", "w") 80 | f.write(new_config) 81 | f.close() 82 | # validate new config 83 | try: 84 | with open("config.toml", "rb") as f: 85 | test_config = tomllib.load(f) 86 | # once validated, confirm it back to receiver 87 | # content["_config"]=open("config.toml", "rt").read() 88 | #json_data = json.dumps(content) 89 | #response = requests.post(config["receiver"], data=json_data, headers=headers) 90 | except tomllib.TOMLDecodeError: 91 | # new config parse fail, invalid TOML config, fallback to backup config 92 | subprocess.run('cp config.toml.bak config.toml', shell = True) 93 | # backup cleanup 94 | subprocess.run('rm config.toml.bak', shell = True) 95 | break; 96 | 97 | with open("config.toml", "rb") as f: 98 | config = tomllib.load(f) 99 | 100 | # include config contents in the payload 101 | content["_config"]=open("config.toml", "rt").read() 102 | 103 | for name, command in config["commands"].items(): 104 | try: 105 | # simple command (TOML string) 106 | if isinstance(command, str): 107 | command=command.replace("{hostname}", hostname) 108 | parts=command.split(" ") 109 | output=subprocess.run(parts, universal_newlines = True, stdout = subprocess.PIPE) 110 | content[name]=output.stdout.strip() 111 | # command with text search and extraction (TOML array) 112 | elif isinstance(command, list): 113 | command[0]=command[0].replace("{hostname}", hostname) 114 | parts=command[0].split(" ") 115 | output=subprocess.run(parts, universal_newlines = True, stdout = subprocess.PIPE) 116 | content[name]="" 117 | for string in command[1]: 118 | for line in output.stdout.split("\n"): 119 | if string in line: 120 | content[name]=content[name]+line.strip()+"\n" 121 | content[name]=content[name].strip() 122 | except Exception: 123 | # ignore, skip 124 | pass 125 | 126 | try: 127 | for name, command in config["commands_shell"].items(): 128 | command=command.replace("{hostname}", hostname) 129 | subprocess.run([command], shell = True) 130 | except Exception: 131 | # ignore, skip 132 | pass 133 | 134 | # post payload 135 | json_data = json.dumps(content) 136 | response = requests.post(config["receiver"], data = json_data, headers = headers) 137 | 138 | headers.pop("Content-Type") 139 | 140 | # if screenshot command exists, upload image 141 | try: 142 | if config["commands_shell"]["screenshot"]: 143 | files = {'screenshot': open(hostname+".png", 'rb')} 144 | r = requests.post(config["receiver"], files = files, headers = headers) 145 | except Exception: 146 | # ignore, skip 147 | pass 148 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | config update, 1 => one-time commands, 2 => python script code update] 31 | $available_updates = [0, 0, 0]; 32 | // Handle config update check 33 | if (isset(getallheaders()['X-Hostname'])) { 34 | $filename = 'logs/' . md5(getallheaders()['X-Hostname']) . '_new.config'; 35 | } 36 | if ((isset($filename) and file_exists($filename)) or file_exists('logs/_new.config')) { 37 | $available_updates[0] = '1'; 38 | } 39 | unset($filename); 40 | // Handle one-time commands update check 41 | if (isset(getallheaders()['X-Hostname'])) { 42 | $filename = 'logs/' . md5(getallheaders()['X-Hostname']) . '_new.onetime'; 43 | } 44 | if ((isset($filename) and file_exists($filename)) or file_exists('logs/_new.onetime')) { 45 | $available_updates[1] = '1'; 46 | } 47 | // Handle python script update check 48 | if (isset(getallheaders()['X-Crc32']) and getallheaders()['X-Crc32'] != $crc32) { 49 | $available_updates[2] = '1'; 50 | } 51 | $available_updates = implode('', $available_updates); 52 | if ((int) $available_updates) { 53 | header('HTTP/1.1 200 OK'); 54 | header('X-Update: ' . $available_updates); 55 | } 56 | exit; 57 | } 58 | 59 | $display_dashboard = false; 60 | if ($_SERVER['REQUEST_METHOD'] == 'POST') { 61 | // Handle uploads and receive data 62 | if (isset($_POST) and isset($_POST['new_config'])) { 63 | // save new config 64 | if (isset($_POST['all']) and $_POST['all'] == 1) { 65 | file_put_contents('logs/_new.config', $_POST['new_config']); 66 | } else { 67 | file_put_contents('logs/' . md5($_POST['hostname']) . '_new.config', $_POST['new_config']); 68 | } 69 | $display_dashboard = true; 70 | } elseif (isset($_POST) and isset($_POST['new_onetime'])) { 71 | // save new config 72 | if (isset($_POST['all']) and $_POST['all'] == 1) { 73 | file_put_contents('logs/_new.onetime', $_POST['new_onetime']); 74 | } else { 75 | file_put_contents('logs/' . md5($_POST['hostname']) . '_new.onetime', $_POST['new_onetime']); 76 | } 77 | $display_dashboard = true; 78 | } elseif (isset($_FILES) and isset($_FILES['screenshot']['name'])) { 79 | // Handle image upload 80 | $filename = pathinfo($_FILES['screenshot']['name'])['filename']; 81 | if (move_uploaded_file($_FILES['screenshot']['tmp_name'], 'logs/' . md5($filename) . '.png')) { 82 | header('HTTP/1.1 200 OK'); 83 | } else { 84 | header('HTTP/1.1 401 Unauthorized'); 85 | header('WWW-Authenticate: Digest realm="' . $realm . '",qop="auth",algorithm=SHA-256,nonce="' . uniqid() . '",opaque="' . hash('sha256', $realm) . '"'); 86 | } 87 | exit; 88 | } else { 89 | // receive data 90 | header('HTTP/1.1 200 OK'); 91 | $content = json_decode(file_get_contents('php://input')); 92 | if (isset($content->hostname) and isset($content->_config)) { 93 | file_put_contents('logs/' . md5($content->hostname) . '.config', $content->_config); 94 | } 95 | unset($content->_config); 96 | if (isset($content->hostname)) { 97 | file_put_contents('logs/' . md5($content->hostname) . '.log', json_encode($content)); 98 | echo '{"status":"OK"}'; 99 | } else { 100 | echo '{"status":"Missing identifier"}'; 101 | } 102 | exit; 103 | } 104 | } 105 | 106 | if ($_SERVER['REQUEST_METHOD'] == 'GET' or $display_dashboard === true) { 107 | // Handle authentication, dashboard and configuration update for remotes 108 | if (isset($_GET['update'])) { 109 | if ($_GET['update'] == 1) { 110 | $filename = '_new.config'; 111 | } elseif ($_GET['update'] == 2) { 112 | $filename = '_new.onetime'; 113 | } elseif ($_GET['update'] == 3) { 114 | $filename = $latest_code_filename; 115 | } 116 | // Send update to remote devices 117 | if (file_exists('logs/' . $filename)) { 118 | // count, if all remotes retrieved updated config 119 | $file = file_get_contents('logs/' . $filename); 120 | $counter_file = 'logs/' . str_replace('.', '_', $filename) . '.count'; 121 | $count = 1; 122 | if (file_exists($counter_file)) { 123 | $counter = fopen($counter_file, 'r+'); 124 | flock($counter, LOCK_EX); 125 | $count = trim(fread($counter, filesize($counter_file))); 126 | $count++; 127 | rewind($counter); 128 | fwrite($counter, $count); 129 | flock($counter, LOCK_UN); 130 | fclose($counter); 131 | } else { 132 | $counter = fopen($counter_file, 'w'); 133 | flock($counter, LOCK_EX); 134 | fwrite($counter, $count); 135 | flock($counter, LOCK_UN); 136 | fclose($counter); 137 | } 138 | // delete new config once retrieved by all remotes 139 | if (count(glob('logs/*.log')) == $count) { 140 | unlink('logs/' . $filename); 141 | unlink($counter_file); 142 | } 143 | } 144 | // not elseif, we can have both update for all and an individual update that overrides all device update 145 | if (isset(getallheaders()['X-Hostname']) and file_exists('logs/' . md5(getallheaders()['X-Hostname']) . $filename)) { 146 | $file = file_get_contents('logs/' . md5(getallheaders()['X-Hostname']) . $filename); 147 | unlink('logs/' . md5(getallheaders()['X-Hostname']) . $filename); 148 | } 149 | echo $file; 150 | exit; 151 | } 152 | if (isset($_GET['cancel'])) { 153 | // cancel config update 154 | if ($_GET['cancel'] == 1) { 155 | if (isset($_GET['hostname']) and file_exists('logs/' . md5($_GET['hostname']) . '_new.config')) { 156 | unlink('logs/' . md5($_GET['hostname']) . '_new.config'); 157 | $flash = 'ℹ️ Config update canceled.'; 158 | } elseif (file_exists('logs/_new.config')) { 159 | unlink('logs/_new.config'); 160 | $flash = 'ℹ️ Config update canceled for all devices.'; 161 | } 162 | } elseif ($_GET['cancel'] == 2) { 163 | if (isset($_GET['hostname']) and file_exists('logs/' . md5($_GET['hostname']) . '_new.onetime')) { 164 | unlink('logs/' . md5($_GET['hostname']) . '_new.onetime'); 165 | $flash = 'ℹ️ One-time commands canceled.'; 166 | } elseif (file_exists('logs/_new.onetime')) { 167 | unlink('logs/_new.onetime'); 168 | $flash = 'ℹ️ One-time commands canceled for all devices.'; 169 | } 170 | } 171 | } 172 | // Assume user is authenticated, unless auth is configured and digest tells you otherwise 173 | $authenticated = true; 174 | if ($config['username'] and $config['password']) { 175 | // Handle Digest authentication, if user/pass configured 176 | $realm = 'RPi Monitor Dashboard'; 177 | if (empty($_SERVER['PHP_AUTH_DIGEST'])) { 178 | header('HTTP/1.1 401 Unauthorized'); 179 | header('WWW-Authenticate: Digest realm="' . $realm . '",qop="auth",algorithm=SHA-256,nonce="' . uniqid() . '",opaque="' . hash('sha256', $realm) . '"'); 180 | $error = '

Not authorized. Enter username and password to login.

'; 181 | $authenticated = false; 182 | } elseif (!($data = http_digest_parse($_SERVER['PHP_AUTH_DIGEST'])) or $data['username'] != $config['username']) { 183 | $error = '

Incorrect login details.

'; 184 | $authenticated = false; 185 | } 186 | if (isset($data['response'])) { 187 | $hash1 = hash('sha256', $data['username'] . ':' . $realm . ':' . $config['password']); 188 | $hash2 = hash('sha256', $_SERVER['REQUEST_METHOD'] . ':' . $data['uri']); 189 | $valid_response = hash('sha256', $hash1 . ':' . $data['nonce'] . ':' . $data['nc'] . ':' . $data['cnonce'] . ':' . $data['qop'] . ':' . $hash2); 190 | if ($data['response'] != $valid_response) { 191 | header('HTTP/1.1 401 Unauthorized'); 192 | header('WWW-Authenticate: Digest realm="' . $realm . '",qop="auth",algorithm=SHA-256,nonce="' . uniqid() . '",opaque="' . hash('sha256', $realm) . '"'); 193 | $error = '

Incorrect login details.

'; 194 | $authenticated = false; 195 | } 196 | } 197 | } 198 | echo 'RPi Monitor Dashboard'; 219 | if ($authenticated === true) { 220 | // Display dashboard, if auth check passed 221 | echo '

RPi Monitor Dashboard

'; 222 | if (file_exists('logs/_new.config')) { 223 | echo '
⚠️ New configuration will be applied on next contact to all devices.
Config
'; 224 | } 225 | if (file_exists('logs/_new.onetime')) { 226 | echo '
⚠️ New one-time commands will be applied on next contact to all devices.
One-time commands
'; 227 | } 228 | if (isset($flash) and !isset($_GET['hostname'])) { 229 | echo '
' . $flash . '
'; 230 | } 231 | echo '
'; 232 | foreach (glob('logs/*.log') as $log) { 233 | $modified_time = filemtime($log); 234 | $content = file_get_contents($log); 235 | $content = json_decode($content); 236 | echo ''; 242 | if (isset($flash) and isset($_GET['hostname']) and $_GET['hostname'] == $content->hostname) { 243 | echo '
' . $flash . '
'; 244 | } 245 | if (file_exists('logs/' . md5($content->hostname) . '_new.config')) { 246 | echo '
⚠️ New configuration will be applied on next contact.
Config
'; 247 | } 248 | if (file_exists('logs/' . md5($content->hostname) . '_new.onetime')) { 249 | echo '
⚠️ One-time commands will be applied on next contact.
One-time commands
'; 250 | } 251 | echo 'Last update: ' . date('F d Y H:i:s', $modified_time); 252 | $config_filename = str_ireplace('.log', '.config', $log); 253 | if (file_exists($config_filename)) { 254 | $cleaned_config = stripReceiverFromTOMLConfig(file_get_contents($config_filename)); 255 | echo '
Config
'; 256 | } 257 | $onetime_filename = str_ireplace('.log', '.onetime', $log); 258 | $onetime = ''; 259 | if (file_exists($onetime_filename)) { 260 | $onetime = file_get_contents($onetime_filename); 261 | } 262 | echo '
One-time commands
'; 263 | echo '

' . $content->hostname . '

'; 264 | $screenshot = str_ireplace('.log', '.png', $log); 265 | if (file_exists($screenshot)) { 266 | echo '' . $content->hostname . ' screenshot'; 267 | } 268 | unset($content->hostname); 269 | echo ''; 270 | foreach ($content as $key => $item) { 271 | echo ''; 275 | } 276 | echo '
'; 272 | echo '

' . $key . '

'; 273 | echo nl2br($item, false); 274 | echo '
'; 277 | echo ''; 278 | } 279 | echo '
'; 280 | } else { 281 | echo $error; 282 | } 283 | echo ''; 284 | } 285 | 286 | // HTTP Digest authentication parser 287 | // see: https://www.php.net/manual/en/features.http-auth.php 288 | function http_digest_parse($txt) 289 | { 290 | // protect against missing data 291 | $needed_parts = ['nonce' => 1, 'nc' => 1, 'cnonce' => 1, 'qop' => 1, 'username' => 1, 'uri' => 1, 'response' => 1]; 292 | $data = []; 293 | $keys = implode('|', array_keys($needed_parts)); 294 | preg_match_all('@(' . $keys . ')=(?:([\'"])([^\2]+?)\2|([^\s,]+))@', $txt, $matches, PREG_SET_ORDER); 295 | foreach ($matches as $m) { 296 | $data[$m[1]] = $m[3] ? $m[3] : $m[4]; 297 | unset($needed_parts[$m[1]]); 298 | } 299 | return $needed_parts ? false : $data; 300 | } 301 | 302 | function stripReceiverFromTOMLConfig($config) 303 | { 304 | $cleaned_config = ''; 305 | $config_lines = explode("\n", $config); 306 | foreach ($config_lines as $key => $line) { 307 | if (stripos($line, '#-#*+*#-#') !== false) { 308 | $offset_key = $key + 1; 309 | } 310 | if (isset($offset_key) and $key >= $offset_key) { 311 | $cleaned_config .= $line . "\n"; 312 | } 313 | } 314 | return $cleaned_config; 315 | } 316 | --------------------------------------------------------------------------------