├── .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 '';
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 |
--------------------------------------------------------------------------------