#!/usr/bin/env python3 # Автор: Команда FiloSoft — https://filo-soft.ru """Idempotent PBX Monitor installer; preserves other Asterisk integrations.""" import argparse import base64 import grp import json import os import pwd import re import secrets import shutil import subprocess import sys import tempfile import time from pathlib import Path CONFIG_ROOT=Path('/etc/pbx-monitor') AMI_INCLUDE=Path('/etc/asterisk/manager-pbx-monitor.conf') MANAGER=Path('/etc/asterisk/manager.conf') def run(args): p=subprocess.run(args,capture_output=True,text=True,timeout=20) if p.returncode:raise RuntimeError('Installer command failed: '+str(args[0])) return p.stdout def write(path,text,mode=0o640,uid=0,gid=0): path.parent.mkdir(parents=True,exist_ok=True) fd,tmp=tempfile.mkstemp(prefix='.pbx-',dir=path.parent) try: with os.fdopen(fd,'w') as f:f.write(text);f.flush();os.fsync(f.fileno()) os.chmod(tmp,mode);os.chown(tmp,uid,gid);os.replace(tmp,path) finally: if os.path.exists(tmp):os.unlink(tmp) def manager_setting(text,name): active=False for line in text.splitlines(): header=re.match(r'^\s*\[([^\]]+)\]',line) if header:active=header[1].lower()=='general' if active: m=re.match(r'^\s*'+re.escape(name)+r'\s*=\s*([^;]+)',line,re.I) if m:return m[1].strip() return None def general_changes(text,values): lines=text.splitlines();start=None;end=None for i,line in enumerate(lines): if re.match(r'^\s*\[general\]\s*(?:;.*)?$',line,re.I):start=i;continue if start is not None and i>start and re.match(r'^\s*\[',line):end=i;break if start is None:return '[general]\n'+''.join(k+' = '+v+'\n'for k,v in values.items())+'\n'+text if end is None:end=len(lines) remaining=dict(values) for i in range(start+1,end): m=re.match(r'^\s*([a-zA-Z_]+)\s*=',lines[i]) if m and m[1].lower()in remaining: key=m[1].lower();lines[i]=key+' = '+remaining.pop(key) lines[end:end]=[key+' = '+value for key,value in remaining.items()] return '\n'.join(lines)+'\n' def install(bundle,public_key,source_ip=None): if os.geteuid()!=0:raise RuntimeError('Run this installer with sudo') if not re.fullmatch(r'(?:ssh-ed25519|ssh-rsa) [A-Za-z0-9+/=]+(?: [^\r\n]+)?',public_key.strip()):raise ValueError('Invalid monitoring SSH public key') if not Path('/usr/sbin/asterisk').exists():raise RuntimeError('Asterisk executable was not found') before=MANAGER.read_text() if MANAGER.exists() else '' if re.search(r'^\s*\[pbx-monitor\]',before,re.M):raise RuntimeError('An existing pbx-monitor user is defined directly in manager.conf') try:user=pwd.getpwnam('pbxmon') except KeyError: run(['useradd','--system','--create-home','--home-dir','/var/lib/pbx-monitor','--shell','/bin/sh','pbxmon']);user=pwd.getpwnam('pbxmon') run(['usermod','-a','-G','asterisk','pbxmon']) CONFIG_ROOT.mkdir(mode=0o750,parents=True,exist_ok=True);os.chown(CONFIG_ROOT,0,user.pw_gid) config_path=CONFIG_ROOT/'config.json' config=json.loads(config_path.read_text()) if config_path.exists() else {} ami_secret=config.get('ami_secret') or secrets.token_urlsafe(40) enabled=manager_setting(before,'enabled') listener=manager_setting(before,'bindaddr') or '0.0.0.0' port=int(manager_setting(before,'port') or 5038) after=before if enabled is None or enabled.lower()not in ['yes','true','1']: after=general_changes(after,{'enabled':'yes','bindaddr':'127.0.0.1','port':str(port)}) listener='127.0.0.1' include='#include "manager-pbx-monitor.conf"' if include not in after:after=after.rstrip()+'\n\n; PBX Monitor managed include\n'+include+'\n' ami_host='127.0.0.1' if listener in ['0.0.0.0','127.0.0.1','::'] else listener permits='permit = 127.0.0.1/255.255.255.255\n' if ami_host!='127.0.0.1': import ipaddress ip=ipaddress.ip_address(ami_host) permits+='permit = '+str(ip)+('/255.255.255.255' if ip.version==4 else '/128')+'\n' if AMI_INCLUDE.exists() and not AMI_INCLUDE.read_text().startswith('; PBX Monitor managed'):raise RuntimeError('AMI include is not owned by PBX Monitor') section='; PBX Monitor managed AMI user\n[pbx-monitor]\nsecret = '+ami_secret+'\ndeny = 0.0.0.0/0.0.0.0\n'+permits+'read = system,call,agent,reporting,cdr\nwrite = system,call,agent,reporting\ndisplayconnects = no\n' stamp=time.strftime('%Y%m%d-%H%M%S') backups=CONFIG_ROOT/'backups';backups.mkdir(mode=0o700,exist_ok=True) if before!=after and MANAGER.exists():shutil.copy2(MANAGER,backups/('manager.conf.'+stamp));os.chmod(backups/('manager.conf.'+stamp),0o600) previous_include=AMI_INCLUDE.read_text() if AMI_INCLUDE.exists() else None config.update({'ami_host':ami_host,'ami_port':port,'ami_user':'pbx-monitor','ami_secret':ami_secret,'version':'1.1.0'}) config.setdefault('cdr_root_contexts',['routing']) write(config_path,json.dumps(config,indent=2)+'\n',gid=user.pw_gid) write(AMI_INCLUDE,section,gid=grp.getgrnam('asterisk').gr_gid) write(MANAGER,after,gid=grp.getgrnam('asterisk').gr_gid) write(Path('/usr/local/bin/pbx-monitor-bridge'),bundle['bridge.py'],0o755) write(Path('/usr/local/sbin/pbx-monitor-f2b'),bundle['f2b.py'],0o755) write(Path('/usr/local/sbin/pbx-monitor-state'),bundle['state.py'],0o755) sudoers=Path('/etc/sudoers.d/pbx-monitor') write(sudoers,'pbxmon ALL=(root) NOPASSWD: /usr/local/sbin/pbx-monitor-f2b, /usr/local/sbin/pbx-monitor-state\n',0o440) run(['visudo','-cf',str(sudoers)]) ssh_dir=Path(user.pw_dir)/'.ssh';ssh_dir.mkdir(mode=0o700,parents=True,exist_ok=True);os.chown(ssh_dir,user.pw_uid,user.pw_gid) authorized=ssh_dir/'authorized_keys';old=authorized.read_text() if authorized.exists() else '' key=public_key.strip();options='restrict,command="/usr/local/bin/pbx-monitor-bridge" ' if key.split()[1] not in old:write(authorized,old.rstrip()+'\n'+options+key+'\n',0o600,user.pw_uid,user.pw_gid) try: result=run(['/usr/sbin/asterisk','-rx','manager reload']) if re.search(r'No such command|Unable to connect|Error parsing',result,re.I):raise RuntimeError('AMI reload was not confirmed') except Exception: write(MANAGER,before,gid=grp.getgrnam('asterisk').gr_gid) if previous_include is None:AMI_INCLUDE.unlink(missing_ok=True) else:write(AMI_INCLUDE,previous_include,gid=grp.getgrnam('asterisk').gr_gid) try:run(['/usr/sbin/asterisk','-rx','manager reload']) except Exception:pass raise cdr_path=Path('/etc/asterisk/cdr_manager.conf') cdr_before=cdr_path.read_text()if cdr_path.exists()else '' cdr_after=general_changes(cdr_before,{'enabled':'yes'}) if cdr_before!=cdr_after: if cdr_path.exists():shutil.copy2(cdr_path,backups/('cdr_manager.conf.'+stamp));os.chmod(backups/('cdr_manager.conf.'+stamp),0o600) write(cdr_path,cdr_after,gid=grp.getgrnam('asterisk').gr_gid) try: loaded=run(['/usr/sbin/asterisk','-rx','module show like cdr_manager.so']) run(['/usr/sbin/asterisk','-rx',('module reload 'if re.search(r'^cdr_manager\.so\s',loaded,re.M)else'module load ')+'cdr_manager.so']) except RuntimeError: if cdr_before:write(cdr_path,cdr_before,gid=grp.getgrnam('asterisk').gr_gid) else:cdr_path.unlink(missing_ok=True) return {'installed':True,'version':'1.1.0','ssh_user':'pbxmon','ami_host':ami_host,'ami_port':port,'ami_user':'pbx-monitor','manager_changed':before!=after,'backup_directory':str(backups),'asterisk_restarted':False,'source_ip':source_ip,'statistics_sources':['AMI','CDR manager','queue_log','CDR CSV with uniqueid']} def main(): parser=argparse.ArgumentParser(description='Install the restricted PBX Monitor bridge without reinstalling Asterisk') parser.add_argument('--public-key-file',required=True) args=parser.parse_args() root=Path(__file__).resolve().parent bundle={name:(root/name).read_text() for name in ['bridge.py','f2b.py','state.py']} result=install(bundle,Path(args.public_key_file).read_text()) print(json.dumps(result,ensure_ascii=False,indent=2)) if __name__=='__main__':main()