1: <?php
2: declare(strict_types=1);
3: /**
4: * +------------------------------------------------------------+
5: * | apnscp |
6: * +------------------------------------------------------------+
7: * | Copyright (c) Apis Networks |
8: * +------------------------------------------------------------+
9: * | Licensed under Artistic License 2.0 |
10: * +------------------------------------------------------------+
11: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
12: * +------------------------------------------------------------+
13: */
14:
15: /**
16: * Logfile manipulation and management
17: *
18: * @package core
19: */
20: class Logs_Module extends Module_Skeleton
21: {
22: const DEPENDENCY_MAP = [
23: 'apache'
24: ];
25:
26: protected $exportedFunctions = [
27: '*' => PRIVILEGE_SITE
28: ];
29:
30: /**
31: * Get webserver log usage
32: *
33: * @return int|null size in KB
34: */
35: public function get_webserver_log_usage(): ?int
36: {
37: // v7.5 platforms lock /var/log/httpd access from other
38: if (!IS_CLI) {
39: return $this->query('logs_get_webserver_log_usage');
40: }
41:
42: $logHandler = \Log\Apache::bindTo($this->domain_fs_path());
43: if (!file_exists($logHandler->path())) {
44: return 0;
45: }
46:
47: if (false === ($dh = opendir($logHandler->path()))) {
48: return nerror('failed to open %s', $logHandler->path());
49: }
50: $size = 0;
51: while (($file = readdir($dh)) !== false) {
52: if ($file == '.' || $file == '..') {
53: continue;
54: }
55: $path = $logHandler->path($file);
56: if (!$this->file_exists($path)) {
57: continue;
58: }
59: $size += filesize($path) / 1024;
60: }
61: closedir($dh);
62:
63: return (int)$size;
64: }
65:
66: /**
67: * array list_logfiles()
68: *
69: * @return array
70: */
71: public function list_logfiles(): array
72: {
73: $logs = array();
74: $path = $this->web_site_config_dir() . '/custom_logs';
75: if (!file_exists($path)) {
76: $logs['*']['*'] = 'access_log';
77:
78: return $logs;
79: }
80: $logdata = file_get_contents($path);
81: $logs = $this->render_log_data_as_array($logdata);
82:
83: return $logs;
84: }
85:
86: private function render_log_data_as_array(string $data): array
87: {
88: $logs = $envmap = array();
89: $lines = explode("\n", $data);
90: $domains = $this->web_list_domains();
91: for ($i = 0, $n = sizeof($lines); $i < $n; $i++) {
92: $line = $lines[$i];
93: $tok = strtok($line, ' ');
94: if (!$tok) {
95: continue;
96: }
97: $directive = strtolower($tok);
98:
99: if ($directive == 'setenvifnocase') {
100: preg_match('/^\s*SetEnvIfNoCase\s+Host\s+\(?(\.?[^\.]+)\.\)?\??([\S]+)\s+(.+)$/i', $line,
101: $lineCapture);
102: $subdomain = str_replace(array('.*', '\\'), array('*', ''), $lineCapture[1]);
103: $domain = str_replace(array('.*', '\\'), array('*', ''), $lineCapture[2]);
104: $env = $lineCapture[3];
105: $envmap[$env] = array('subdomain' => $subdomain, 'domain' => $domain);
106: } else {
107: if ($directive == 'customlog') {
108: $logpath = strtok(' ');
109: $logfile = substr($logpath, strrpos($logpath, '/') + 1);
110: $logtype = strtok(' ');
111: $env = strtok(' ');
112: if (!$env) {
113: $logs['*'] = array('*' => $logfile);
114: continue;
115: }
116: $pos = strpos($env, '=');
117: if ($pos !== false) {
118: $env = substr($env, $pos + 1);
119: }
120: if (isset($envmap[$env])) {
121: $subdomain = $envmap[$env]['subdomain'];
122: $domain = $envmap[$env]['domain'];
123: } else {
124: if (substr($env, 0, 2) == 'L-') {
125:
126: $subdomain = str_replace('_', '.', substr($env, 2));
127: if ($subdomain[0] == '.' || strpos($subdomain, '.') !== false) {
128: // domain fall-through or local subdomain
129:
130: $components = $this->web_split_host(ltrim($subdomain, '.'));
131: $domain = $components['domain'];
132: if ($subdomain[0] == '.') {
133: // domain fall-through
134: $subdomain = '*';
135: } else {
136: // local subdomain
137: if ($components) {
138: $subdomain = $components['subdomain'];
139: } else {
140: $domain = substr($subdomain, strpos($subdomain, '.') + 1);
141: $subdomain = substr($subdomain, 0, strpos($subdomain, '.'));
142: }
143: }
144: } else {
145: // global subdomain
146: $domain = '*';
147: }
148: } else {
149: error("Unknown log identifier `$env'");
150: continue;
151: }
152: }
153: if (!isset($logs[$domain])) {
154: $logs[$domain] = array();
155: }
156: $logs[$domain][$subdomain] = $logfile;
157: }
158: }
159: }
160:
161: return $logs;
162: }
163:
164: /**
165: * bool add_logfile(string, string, string)
166: *
167: * @param string $domain
168: * @param string $subdomain
169: * @param string $file
170: * @return bool
171: */
172: public function add_logfile(string $domain, string $subdomain, string $file): bool
173: {
174: if (!IS_CLI) {
175: return $this->query('logs_add_logfile', $domain, $subdomain, $file);
176: }
177: if ($domain != '*' && !preg_match(Regex::HTTP_HOST, $domain)) {
178: return error($domain . ': invalid domain');
179: } else {
180: if ($subdomain && $subdomain != '*' && !preg_match(Regex::SUBDOMAIN, $subdomain)) {
181: return error($subdomain . ': invalid subdomain');
182: } else {
183: if (!preg_match(Regex::HTTP_LOG_FILE, $file)) {
184: return error($file . ': Invalid logfile');
185: }
186: }
187: }
188:
189: $data = array();
190: $path = $this->web_site_config_dir() . '/custom_logs';
191: if (!file_exists($path)) {
192: $data['*']['*'] = 'access_log';
193: } else {
194: $logdata = file_get_contents($path);
195: $data = $this->render_log_data_as_array($logdata);
196: }
197: if (isset($data[$domain]) && isset($data[$domain][$subdomain])) {
198: // @BUG warn generates error on pb when going from backend to gui
199: return warn('profile for ' . $subdomain . ($subdomain ? '.' : '') . $domain . ' exists');
200: }
201: $data[$domain][$subdomain] = $file;
202:
203: $logPath = \Log\Apache::bindTo($this->domain_fs_path())->path($file);
204:
205: return file_put_contents($logdata, $this->render_array_as_log_data($data), LOCK_EX) &&
206: touch($logPath) &&
207: \Opcenter\Filesystem::chogp($logPath, $this->user_id, $this->group_id) &&
208: \Opcenter\Filesystem::chogp($this->domain_fs_path('/etc/logrotate.d/apache')) &&
209: $this->add_log_rotation_profile($this->file_unmake_path($logPath), 'apache');
210: }
211:
212: /**
213: * The expected format is as follows:
214: * Numerically indexed array, which gives log position, each
215: * element is an array itself with the indexes subdomain, domain, and file
216: */
217: private function render_array_as_log_data(array $data): string
218: {
219: /**
220: * logfile is just created, once we do this we lose the wildcard
221: * piped logging feature, so make a case to catch the rest
222: */
223: $txt = '<IfDefine !SLAVE>' . "\n";
224:
225: $logHandler = \Log\Apache::bindTo($this->domain_fs_path());
226: foreach ($data as $domain => $logs) {
227: foreach ($logs as $subdomain => $file) {
228: /**
229: * SetEnvIfNoCase Host <subdomain>.<domain> <subdomain>_<domain>
230: * Substitute [*.] with _ so the env variable name doesn't puke
231: */
232: $env = 'env=L-';
233: list($subdomain, $domain) = str_replace('.', '_', array($subdomain, $domain));
234: if ($subdomain == '*') {
235: if ($domain == '*') {
236: $env = '';
237: } else {
238: $env .= '_' . $domain;
239: }
240: } else {
241: if ($subdomain) {
242: $env .= $subdomain . '.';
243: }
244: if ($domain != '*') {
245: $env .= $domain;
246: }
247: }
248:
249: $env = str_replace(
250: array('*', '.'),
251: '_',
252: $env
253: );
254: $txt .= 'CustomLog ' . $logHandler->path($file) . ' combined ' . $env . "\n";
255: }
256: }
257:
258: return $txt . 'ErrorLog ' . $logHandler->path('error_log') . "\n</IfDefine>";
259: }
260:
261: /**
262: * bool add_log_rotation_profile(string)
263: *
264: * @param string $mLog log name, relative to /var/log/httpd/
265: * @return bool
266: */
267: public function add_log_rotation_profile(string $log, string $profile = 'apache'): bool
268: {
269: if (!IS_CLI) {
270: return $this->query('logs_add_log_rotation_profile', $log, $profile);
271: }
272: $log = str_replace('..', '', $log);
273: if (!preg_match(Regex::HTTP_LOG_FILE, $log)) {
274: return error("Invalid logfile `$log'");
275: } else {
276: if (!preg_match('/^[A-Z0-9_]+$/i', $profile) ||
277: !file_exists($this->domain_fs_path() . '/etc/logrotate.d/' . $profile)
278: ) {
279: return error("Invalid service `$profile'");
280: }
281: }
282:
283: $data = file_get_contents($this->domain_fs_path() . '/etc/logrotate.d/' . $profile);
284: if (preg_match('!\s*' . $log . '\s*(?:\s|{)!', $data)) {
285: return true;
286: }
287:
288: // TODO: Raise a warning instead if duplicate log rotation profile exists
289: // return new FileError("Rotation profile for ".$log." already exists");
290: $data = rtrim($data) . "\n" . $log . " {\n\tmissingok\n}";
291: file_put_contents($this->domain_fs_path() . '/etc/logrotate.d/' . $profile, $data, LOCK_EX);
292:
293: return true;
294:
295: }
296:
297: /**
298: * bool remove_logfile(string, string)
299: *
300: * @param string $domain
301: * @param string $subdomain
302: * @return bool
303: */
304: public function remove_logfile(string $domain, string $subdomain): bool
305: {
306: if (!IS_CLI) {
307: return $this->query('logs_remove_logfile', $domain, $subdomain);
308: }
309: $path = $this->web_site_config_dir() . '/custom_logs';
310: $data = file_get_contents($path);
311: $data = $this->render_log_data_as_array($data);
312:
313: if (!isset($data[$domain]) && !isset($data[$domain][$subdomain])) {
314: return warn('Log profile not found for ' . $subdomain . '.' . $domain);
315: }
316: $log_file = \Log\Apache::bindTo($this->domain_fs_path())->path($data[$domain][$subdomain]);
317:
318: unset($data[$domain][$subdomain]);
319: // no more logs left on the domain
320: if (sizeof($data[$domain]) == 0) {
321: unset($data[$domain]);
322: }
323: file_put_contents($path, $this->render_array_as_log_data($data), LOCK_EX);
324:
325: $this->remove_log_rotation_profile($this->file_unmake_path($log_file), 'apache');
326:
327: foreach (glob($log_file . '{,.gz,.[1-4],.[1-4].gz}', GLOB_BRACE|GLOB_NOSORT) as $log) {
328: unlink($log);
329: }
330:
331: return true;
332: }
333:
334: /**
335: * bool remove_log_rotation_profile(string)
336: *
337: * @param string $mLog log name, relative to /var/log/httpd/
338: * @return bool
339: */
340: public function remove_log_rotation_profile(string $log, string $profile = 'apache'): bool
341: {
342: if (!IS_CLI) {
343: return $this->query('logs_remove_log_rotation_profile', $log, $profile);
344: }
345: $log = str_replace('..', '', $log);
346: if (!preg_match(Regex::HTTP_LOG_FILE, $log)) {
347: return error('Invalid logfile');
348: } else {
349: if (!preg_match('/^[A-Z0-9_]+$/Di', $profile) ||
350: !file_exists($this->domain_fs_path() . '/etc/logrotate.d/' . $profile)
351: ) {
352: return error('Invalid service type');
353: }
354: }
355:
356: $data = file_get_contents($this->domain_fs_path() . '/etc/logrotate.d/' . $profile);
357: $data_new = preg_replace('!^\s*' . $log . '\s*{[^}]+[\r\n]+}$!m', '', $data);
358: if ($data == $data_new) {
359: return warn('no such log `' . basename($log) . "' found for service " . $profile);
360: }
361: file_put_contents($this->domain_fs_path() . '/etc/logrotate.d/' . $profile,
362: $data_new,
363: LOCK_EX);
364:
365: return true;
366:
367: }
368:
369: /**
370: * Set global logrotation parameters
371: *
372: * @param array|string $params
373: * @param mixed|null $value
374: * @return bool
375: */
376: public function set_logrotate(array|string $params, mixed $value = null): bool
377: {
378: if (!IS_CLI) {
379: return $this->query('logs_set_logrotate', $params, $value);
380: }
381:
382: $handler = \Opcenter\Logging\Rotation::bindTo($this->domain_fs_path())->read();
383: $old = (string)$handler;
384:
385: $ret = $handler->set($params, $value)->save();
386:
387: if (!$ret || !$this->validate_config()) {
388: Opcenter\Filesystem::atomicWrite($handler->path(), $old);
389: return false;
390: }
391:
392: return true;
393: }
394:
395: /**
396: * Validate logrotate configuration
397: *
398: * @return bool
399: */
400: public function validate_config(): bool
401: {
402: if (!IS_CLI) {
403: return $this->query('logs_validate_config');
404: }
405: $proc = new Util_Process_Chroot($this->domain_fs_path());
406: $ret = $proc->run('/usr/sbin/logrotate %s %s', ['-d', '/etc/logrotate.conf']);
407: /**
408: * additional non-fatal markup can appear in logrotate config, logrotate -d
409: * returns 0 irrespective on v6 platforms, 1 on error on v6.5+ platforms...
410: * including case below; parse debug output
411: */
412: $errs = array();
413: foreach (explode("\n", $ret['stderr']) as $line) {
414: if (0 !== strncmp($line, "error:", 6)) {
415: continue;
416: } else if (0 === strncmp($line, "error: error opening /", 22)) {
417: /**
418: * even if missingok is set, logrotate will complain
419: * if a file to be removed is missing in dry-run mode
420: */
421: continue;
422: }
423: $errs[] = $line;
424: warn($line);
425: }
426:
427: return count($errs) === 0;
428: }
429: }