hardware-dashboard/taskd.py

194 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/python3
"""Authenticated local task broker. Only the web service UID may connect to its socket."""
import argparse
import getpass
import hashlib
import hmac
import json
import os
from pathlib import Path
try:import pwd
except ImportError:pwd=None
import secrets
import socket
import socketserver
import struct
import re
import datetime as dt
import threading
import time
import task_core as core
ADMIN=Path('/etc/hardware-task-admin.json')
SOCKET=Path('/run/hardware-taskd/control.sock')
SESSIONS={};ATTEMPTS={};GUARD=threading.RLock();MUTATE=threading.RLock()
ALLOWED_UID=None
def password_record(password):
salt=secrets.token_hex(16)
return {'username':'admin','salt':salt,'hash':hashlib.pbkdf2_hmac('sha256',password.encode(),bytes.fromhex(salt),240000).hex()}
def verify(password):
try:
record=json.loads(ADMIN.read_text())
hashed=hashlib.pbkdf2_hmac('sha256',password.encode(),bytes.fromhex(record['salt']),240000).hex()
return hmac.compare_digest(record['hash'],hashed)
except (OSError,ValueError,KeyError):return False
def create_admin():
if ADMIN.exists():return
password=secrets.token_urlsafe(18)
core.atom_write(ADMIN,json.dumps(password_record(password)))
core.atom_write(Path('/root/hardware-task-admin-login.txt'),
'定时任务管理员登录信息\n\n网页:http://192.168.1.37:8090/#tasks\n账号:admin\n初始密码:'+password+
'\n\n此账号可以管理以 root 身份执行的服务器任务,请妥善保存。登录后可修改管理密码。\nSSH root 密码未被修改,也没有作为网页管理密码保存。\n')
print('初始登录信息已保存到 /root/hardware-task-admin-login.txt(仅 root 可读)。')
def session(token,csrf=None,write=False):
with GUARD:
record=SESSIONS.get(token)
if not record or record['expires']<time.monotonic():
SESSIONS.pop(token,None);raise core.TaskError('请先登录定时任务管理。',401)
if write and (not isinstance(csrf,str) or not hmac.compare_digest(record['csrf'],csrf)):
raise core.TaskError('操作验证已失效,请重新登录后再试。',403)
return record
def login(payload,client):
now=time.monotonic()
with GUARD:
recent=[x for x in ATTEMPTS.get(client,[]) if now-x<300]
if len(recent)>=6:raise core.TaskError('登录失败次数较多,请五分钟后重试。',429)
if payload.get('username')!='admin' or not verify(str(payload.get('password',''))):
ATTEMPTS[client]=recent+[now]
core.audit('login_failed',client=client)
raise core.TaskError('账号或密码不正确。',401)
ATTEMPTS.pop(client,None)
if len(SESSIONS)>200:
for key in list(SESSIONS):
if SESSIONS[key]['expires']<now:SESSIONS.pop(key,None)
token=secrets.token_urlsafe(32);csrf=secrets.token_urlsafe(24)
SESSIONS[token]={'username':'admin','csrf':csrf,'expires':now+8*3600}
core.audit('login',client=client)
return {'token':token,'csrf':csrf,'logged_in':True,'username':'admin','max_age':8*3600}
def dispatch(request):
action=request.get('action','');payload=request.get('payload') or {};token=request.get('token','');client=str(request.get('client','local'))[:100]
if not isinstance(payload,dict):raise core.TaskError('请求格式不正确。')
if action=='auth.login':return login(payload,client)
if action=='auth.status':
try:record=session(token)
except core.TaskError:return {'logged_in':False,'configured':ADMIN.exists()}
return {'logged_in':True,'username':record['username'],'csrf':record['csrf']}
write=action not in ('tasks.list','tasks.get','tasks.runs','tasks.runlog','tasks.history')
session(token,request.get('csrf'),write)
if action=='auth.logout':
with GUARD:SESSIONS.pop(token,None)
return {'message':'已退出任务管理。'}
if action=='auth.password':
new=str(payload.get('new_password',''))
if not 12<=len(new)<=200:raise core.TaskError('新密码长度需要 12 至 200 个字符。')
if not verify(str(payload.get('old_password',''))):raise core.TaskError('原管理密码不正确。',400)
core.atom_write(ADMIN,json.dumps(password_record(new)))
with GUARD:SESSIONS.clear()
core.audit('password_changed',client=client)
return {'message':'管理密码已修改,请重新登录。'}
if action=='tasks.list':return core.list_tasks()
if action=='tasks.get':return core.get_task(payload.get('id'))
if action=='tasks.runs':return core.runs_for(payload.get('id'))
if action=='tasks.runlog':return core.read_run(payload.get('run_id'))
if action=='tasks.history':
with core.db() as con:rows=con.execute('SELECT * FROM runs ORDER BY started DESC LIMIT 80').fetchall()
return {'runs':[dict(row) for row in rows]}
if action=='tasks.preview':
definition=core.validate_definition(payload)
calendars=core.calendar_preview(definition)
next_times=[]
for output in calendars:
match=re.search(r'Next elapse:.*?(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})',output)
if match:next_times.append(dt.datetime.strptime(match[1],'%Y-%m-%d %H:%M:%S').replace(tzinfo=core.TZ).timestamp())
if not calendars:
next_times=[time.time()+int(definition['schedule_lines'][0].split('=')[1][:-1])]
return {'schedule_label':definition['schedule_label'],'next_run':min(next_times) if next_times else None,'dangerous':definition['dangerous']}
with MUTATE:
if action=='tasks.save':return core.save_task(payload,client)
if action=='tasks.enable':
if not isinstance(payload.get('enabled'),bool):raise core.TaskError('启用状态格式不正确。')
return core.set_enabled(payload.get('id'),payload['enabled'],client)
if action=='tasks.delete':return core.delete_task(payload.get('id'),client)
if action=='tasks.run':return core.run_now(payload.get('id'),client)
if action=='tasks.stop':return core.stop_task(payload.get('id'),client)
raise core.TaskError('不支持这个任务操作。',404)
class Handler(socketserver.StreamRequestHandler):
def handle(self):
self.request.settimeout(25)
try:
_,uid,_=struct.unpack('3i',self.request.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
if uid not in (0,ALLOWED_UID):raise core.TaskError('不允许连接任务服务。',403)
raw=self.rfile.readline(131073)
if len(raw)>131072:raise core.TaskError('请求内容过大。',413)
request=json.loads(raw)
response={'ok':True,'data':dispatch(request)}
except core.TaskError as exc:response={'ok':False,'error':str(exc),'status':exc.status}
except Exception as exc:
print('task broker error:',type(exc).__name__,str(exc),flush=True)
response={'ok':False,'error':'任务操作未完成,请查看任务管理服务日志。','status':500}
try:self.wfile.write(json.dumps(response,ensure_ascii=False).encode()+b'\n')
except (BrokenPipeError,ConnectionResetError):pass
class Server(getattr(socketserver,'ThreadingUnixStreamServer',socketserver.ThreadingTCPServer)):
daemon_threads=True
def maintenance():
while True:
try:
with core.db() as con:
rows=con.execute("SELECT * FROM runs WHERE state='running'").fetchall()
boot=Path('/proc/sys/kernel/random/boot_id').read_text().strip()
for row in rows:
if row['boot']!=boot or (time.time()-row['started']>30 and not core.running(row['task_id'])):
with core.db() as con:
con.execute("UPDATE runs SET state='interrupted',finished=? WHERE id=? AND state='running'",(time.time(),row['id']))
with (core.LOGS/(row['id']+'.log')).open('ab') as f:
f.write(('\n[执行被系统重启或进程中断,没有取得完整退出结果。]\n').encode())
cutoff=time.time()-90*86400
with core.db() as con:
old=con.execute('SELECT id FROM runs WHERE started<? AND state!=?',(cutoff,'running')).fetchall()
for row in old:
(core.LOGS/(row['id']+'.log')).unlink(missing_ok=True)
for suffix in ('.sh','.py'):(core.BASE/'scripts'/(row['id']+suffix)).unlink(missing_ok=True)
con.execute("DELETE FROM runs WHERE started<? AND state!='running'",(cutoff,))
con.execute('DELETE FROM audit WHERE at<?',(cutoff,))
protected={x['id'] for x in con.execute("SELECT id FROM runs WHERE state='running'")}
files=[p for folder in (core.LOGS,core.BASE/'scripts') for p in folder.iterdir()
if p.is_file() and re.fullmatch(r'[0-9a-f]{32}\.(log|sh|py)',p.name)]
files.sort(key=lambda p:p.stat().st_mtime)
total=sum(p.stat().st_size for p in files)
for path in files:
if total<=256*1024*1024:break
if path.stem in protected:continue
total-=path.stat().st_size;path.unlink()
if path.suffix=='.log':con.execute('UPDATE runs SET log_purged=1 WHERE id=?',(path.stem,))
except Exception as exc:print('task maintenance:',type(exc).__name__,str(exc),flush=True)
time.sleep(60)
def main():
global ALLOWED_UID
parser=argparse.ArgumentParser();parser.add_argument('mode',choices=['serve','init-admin','password'],default='serve',nargs='?');args=parser.parse_args()
core.init_store()
if args.mode=='init-admin':create_admin();return
if args.mode=='password':
password=getpass.getpass('新管理密码(至少12字符):')
if len(password)<12 or password!=getpass.getpass('再次输入:'):raise SystemExit('密码太短或两次输入不一致。')
core.atom_write(ADMIN,json.dumps(password_record(password)));print('密码已重置;请重启 hardware-taskd 服务使旧登录失效。');return
if not ADMIN.exists():raise SystemExit('请先运行 taskd.py init-admin。')
ALLOWED_UID=pwd.getpwnam('hardware-dashboard').pw_uid
SOCKET.parent.mkdir(parents=True,exist_ok=True)
SOCKET.unlink(missing_ok=True)
server=Server(str(SOCKET),Handler);os.chmod(SOCKET,0o660)
threading.Thread(target=maintenance,daemon=True).start()
print('Task broker ready on local Unix socket.',flush=True)
server.serve_forever()
if __name__=='__main__':main()