"""Root-owned task store and native systemd schedules; no third-party modules.""" from contextlib import contextmanager import datetime as dt import hashlib import json import os from pathlib import Path, PurePosixPath import re import shlex import sqlite3 import subprocess import time import uuid BASE=Path(os.environ.get('HARDWARE_TASK_DATA','/var/lib/hardware-taskd')) LOGS=Path(os.environ.get('HARDWARE_TASK_LOGS','/var/log/hardware-taskd')) UNITS=Path(os.environ.get('HARDWARE_TASK_UNITS','/etc/systemd/system')) APP=Path(__file__).resolve().parent TZ=dt.timezone(dt.timedelta(hours=8)) WEEKDAYS=['Mon','Tue','Wed','Thu','Fri','Sat','Sun'] class TaskError(Exception): def __init__(self,message,status=400): self.status=status super().__init__(message) def iso(value=None): return dt.datetime.fromtimestamp(time.time() if value is None else value,TZ).isoformat(timespec='seconds') @contextmanager def db(): con=sqlite3.connect(BASE/'tasks.sqlite3',timeout=10) con.row_factory=sqlite3.Row try: yield con;con.commit() except Exception: con.rollback();raise finally: con.close() def init_store(): BASE.mkdir(parents=True,exist_ok=True,mode=0o700) LOGS.mkdir(parents=True,exist_ok=True,mode=0o700) (BASE/'scripts').mkdir(exist_ok=True,mode=0o700) with db() as con: con.execute('PRAGMA journal_mode=WAL') con.executescript(''' CREATE TABLE IF NOT EXISTS tasks(id TEXT PRIMARY KEY,name TEXT,definition TEXT, enabled INTEGER,deleted INTEGER DEFAULT 0,created REAL,updated REAL,revision INTEGER); CREATE TABLE IF NOT EXISTS runs(id TEXT PRIMARY KEY,task_id TEXT,task_name TEXT, started REAL,finished REAL,state TEXT,exit_code INTEGER,trigger TEXT,boot TEXT, script_hash TEXT,log_bytes INTEGER DEFAULT 0,truncated INTEGER DEFAULT 0,revision INTEGER,duration REAL, log_purged INTEGER DEFAULT 0); CREATE INDEX IF NOT EXISTS run_task ON runs(task_id,started DESC); CREATE TABLE IF NOT EXISTS audit(id INTEGER PRIMARY KEY AUTOINCREMENT,at REAL, action TEXT,task_id TEXT,actor TEXT,client TEXT,detail TEXT); CREATE TABLE IF NOT EXISTS requests(task_id TEXT PRIMARY KEY,trigger TEXT,expires REAL); ''') def atom_write(path,text,mode=0o600): temporary=Path(str(path)+'.tmp') temporary.write_text(text,encoding='utf-8');os.chmod(temporary,mode);os.replace(temporary,path) def validate_id(value): if not isinstance(value,str) or not re.fullmatch('[0-9a-f]{32}',value): raise TaskError('任务编号不正确。') return value def execute(args,timeout=15,check=True): try: p=subprocess.run(args,capture_output=True,text=True,timeout=timeout,env={**os.environ,'LC_ALL':'C','TZ':'Asia/Shanghai'}) except (OSError,subprocess.TimeoutExpired) as exc: raise TaskError('系统调度操作未完成:'+str(exc),503) if check and p.returncode: raise TaskError('系统调度操作失败:'+(p.stderr or p.stdout)[-1200:],503) return p def number(value,minimum,maximum,label): try: if isinstance(value,bool):raise ValueError() n=int(str(value)) except (ValueError,TypeError): raise TaskError(label+'必须是整数。') if not minimum<=n<=maximum: raise TaskError(f'{label}必须在 {minimum} 至 {maximum} 之间。') return n def clock(value): if not isinstance(value,str) or not re.fullmatch(r'\d{2}:\d{2}',value): raise TaskError('执行时间格式应为 03:00。') h,m=map(int,value.split(':')) if h>23 or m>59:raise TaskError('执行时间不正确。') return value+':00' def cron_field(value,low,high,names=None): value=value.upper() if names: value=re.sub('[A-Z]{3}',lambda m:str(names[m[0]]) if m[0] in names else m[0],value) result=set() for part in value.split(','): pieces=part.split('/') if len(pieces)>2:raise TaskError('Cron 的步长格式不正确。') step=number(pieces[1],1,high-low+1,'Cron 步长') if len(pieces)==2 else 1 base=pieces[0] if base=='*':start,end=low,high elif re.fullmatch(r'\d+-\d+',base):start,end=map(int,base.split('-')) elif base.isdigit():start=int(base);end=high if len(pieces)==2 else start else:raise TaskError('Cron 仅支持数字、*、范围、逗号和步长;不支持 L、W、# 或 ?。') if not low<=start<=end<=high:raise TaskError('Cron 字段数值超出允许范围。') result.update(range(start,end+1,step)) return sorted(result) def cron_calendars(expression): parts=expression.split() if len(parts)!=5:raise TaskError('Cron 需要五段:分 时 日 月 周,例如 0 3 * * *。') minute=cron_field(parts[0],0,59);hour=cron_field(parts[1],0,23) day=cron_field(parts[2],1,31) month=cron_field(parts[3],1,12,{x:i+1 for i,x in enumerate(['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'])}) dow=cron_field(parts[4],0,7,{'SUN':0,'MON':1,'TUE':2,'WED':3,'THU':4,'FRI':5,'SAT':6}) join=lambda values:','.join(map(str,values)) months='*' if parts[3]=='*' else join(month) hours='*' if parts[1]=='*' else join(hour) minutes='*' if parts[0]=='*' else join(minute) dates='*' if parts[2]=='*' else join(day) days=','.join(['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][v] for v in sorted({v%7 for v in dow})) timepart=f'{hours}:{minutes}:00 Asia/Shanghai' if parts[4]=='*':return [f'*-{months}-{dates} {timepart}'] if parts[2]=='*':return [f'{days} *-{months}-* {timepart}'] # Traditional cron: restricted day-of-month and weekday are joined with OR. return [f'*-{months}-{dates} {timepart}',f'{days} *-{months}-* {timepart}'] def schedule_definition(schedule): if not isinstance(schedule,dict):raise TaskError('请设置执行周期。') kind=schedule.get('kind','daily') if kind=='daily': value=clock(schedule.get('time','03:00')) return [f'OnCalendar=*-*-* {value} Asia/Shanghai'],f'每天 {value[:5]}' if kind=='weekly': chosen=schedule.get('weekdays',[0]) if not isinstance(chosen,list) or not chosen:raise TaskError('至少选择一个星期。') selected=sorted({number(v,0,6,'星期') for v in chosen}) value=clock(schedule.get('time','03:00')) days=','.join(WEEKDAYS[v] for v in selected) label='、'.join('周'+['一','二','三','四','五','六','日'][v] for v in selected) return [f'OnCalendar={days} *-*-* {value} Asia/Shanghai'],f'每{label} {value[:5]}' if kind=='monthly': day=number(schedule.get('day',1),1,31,'每月日期');value=clock(schedule.get('time','03:00')) return [f'OnCalendar=*-*-{day} {value} Asia/Shanghai'],f'每月 {day} 日 {value[:5]}'+('(当月没有该日则跳过)' if day>28 else '') if kind in ('minutes','hours'): every=number(schedule.get('every',1),1,1440 if kind=='minutes' else 168,'间隔') seconds=every*(60 if kind=='minutes' else 3600) return [f'OnActiveSec={seconds}s',f'OnUnitInactiveSec={seconds}s'],f'每 {every} '+('分钟' if kind=='minutes' else '小时')+'(上次执行结束后重新计时)' if kind=='once': try: at=dt.datetime.fromisoformat(schedule.get('at','')) if at.tzinfo is None:at=at.replace(tzinfo=TZ) at=at.replace(second=0,microsecond=0) except (ValueError,TypeError):raise TaskError('请填写正确的指定日期与时间。') if at.timestamp()150:raise TaskError('Cron 表达式过长。') return ['OnCalendar='+x for x in cron_calendars(expression)],'Cron:'+expression raise TaskError('不支持的执行周期。') def validate_definition(payload,check_paths=True): name=str(payload.get('name','')).strip() if not 1<=len(name)<=80 or any(ord(c)<32 for c in name):raise TaskError('任务名称需要 1 至 80 个字符,不能包含换行。') mode=payload.get('mode','command');interpreter=payload.get('interpreter','bash') if mode not in ('command','script','file'):raise TaskError('请选择命令、脚本内容或服务器脚本文件。') if interpreter not in ('bash','python3','auto'):raise TaskError('脚本语言不正确。') body=str(payload.get('body','')).replace('\r\n','\n').replace('\r','\n').lstrip('\ufeff').strip() path=str(payload.get('path','')).strip() arguments=str(payload.get('arguments','')).strip() if mode in ('command','script') and not body:raise TaskError('请输入要执行的命令或脚本。') if len(body.encode())>65536:raise TaskError('脚本内容不能超过 64 KiB。') if mode=='script' and interpreter=='auto':raise TaskError('脚本内容请选择 Bash 或 Python 3。') cwd=str(payload.get('cwd','/root')).strip() or '/root' if not PurePosixPath(cwd).is_absolute() or '\x00' in cwd:raise TaskError('工作目录必须是绝对路径。') if check_paths and not Path(cwd).is_dir():raise TaskError('工作目录不存在。') if mode=='file': if not PurePosixPath(path).is_absolute() or '\x00' in path:raise TaskError('脚本文件需要填写服务器上的绝对路径。') if check_paths and not Path(path).is_file():raise TaskError('服务器上找不到这个脚本文件。') if check_paths and interpreter=='auto' and not os.access(path,os.X_OK):raise TaskError('该文件没有执行权限,请选择 Bash/Python 3 解释器,或先设置文件执行权限。') try:shlex.split(arguments) except ValueError:raise TaskError('脚本参数的引号不匹配。') lines,label=schedule_definition(payload.get('schedule',{})) notes=str(payload.get('notes','')).strip()[:400] timeout=number(payload.get('timeout',1800),10,86400,'最长执行秒数') if not isinstance(payload.get('enabled',False),bool):raise TaskError('启用状态必须为是或否。') dangerous=bool(re.search(r'\b(reboot|shutdown|poweroff|halt)\b|\binit\s+[06]\b',body+' '+path,re.I)) return {'name':name,'mode':mode,'interpreter':interpreter,'body':body,'path':path,'arguments':arguments, 'cwd':cwd,'schedule':payload.get('schedule',{}),'schedule_lines':lines,'schedule_label':label, 'timeout':timeout,'notes':notes,'enabled':bool(payload.get('enabled',False)), 'dangerous':dangerous,'template':str(payload.get('template','custom'))[:40]} def calendar_preview(definition): result=[] for line in definition['schedule_lines']: if line.startswith('OnCalendar='): p=execute(['systemd-analyze','calendar','--iterations=1',line.split('=',1)[1]],timeout=5,check=False) if p.returncode:raise TaskError('执行周期无效:'+(p.stderr or p.stdout)[-600:]) result.append(p.stdout.strip()) if result and all(re.search(r'Next elapse:\s*n/a',output) for output in result): raise TaskError('这个周期没有可以执行的将来时间,请检查日期或 Cron 表达式。') return result def get_task(task_id,include_deleted=False): validate_id(task_id) with db() as con: row=con.execute('SELECT * FROM tasks WHERE id=?'+('' if include_deleted else ' AND deleted=0'),(task_id,)).fetchone() if not row:raise TaskError('找不到这个任务。',404) item=dict(row);item['definition']=json.loads(item['definition']);return item def unit_name(task_id):return 'hwjob-'+validate_id(task_id) def running(task_id): p=execute(['systemctl','is-active',unit_name(task_id)+'.service'],check=False,timeout=5) return p.stdout.strip() in ('active','activating','deactivating') def render_units(task_id,definition): name=unit_name(task_id) service=f'''[Unit] Description=Dashboard scheduled task {task_id} After=network-online.target Wants=network-online.target [Service] Type=simple User=root ExecStart=/usr/bin/python3 {APP}/task_exec.py {task_id} RuntimeMaxSec={definition['timeout']+10} TimeoutStopSec=15 KillMode=mixed Restart=no UMask=0077 Environment=TZ=Asia/Shanghai ''' timer=f'''[Unit] Description=Dashboard task schedule {task_id} [Timer] {chr(10).join(definition['schedule_lines'])} AccuracySec=1s Persistent=false Unit={name}.service [Install] WantedBy=timers.target ''' return service,timer def install_units(task_id,definition): service,timer=render_units(task_id,definition) name=unit_name(task_id) atom_write(UNITS/(name+'.service'),service,0o644) atom_write(UNITS/(name+'.timer'),timer,0o644) execute(['systemctl','daemon-reload']) def audit(action,task_id='',actor='admin',client='',detail=''): with db() as con: con.execute('INSERT INTO audit(at,action,task_id,actor,client,detail) VALUES(?,?,?,?,?,?)', (time.time(),action,task_id,actor,client,detail[:2000])) def save_task(payload,client=''): task_id=payload.get('id') old=get_task(task_id) if task_id else None if old and running(task_id):raise TaskError('任务正在执行,请等待结束后再修改。',409) definition=validate_definition(payload);calendar_preview(definition) if not old: with db() as con: if con.execute('SELECT COUNT(*) FROM tasks WHERE deleted=0').fetchone()[0]>=100:raise TaskError('最多管理 100 个任务。') task_id=uuid.uuid4().hex if old: execute(['systemctl','disable','--now',unit_name(task_id)+'.timer'],check=False) if running(task_id): if old['enabled']:execute(['systemctl','enable','--now',unit_name(task_id)+'.timer'],check=False) raise TaskError('任务刚刚开始执行,请等待完成后再修改。',409) now=time.time();revision=old['revision']+1 if old else 1 try: install_units(task_id,definition) with db() as con: con.execute('''INSERT INTO tasks VALUES(?,?,?,?,0,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,definition=excluded.definition,enabled=excluded.enabled, updated=excluded.updated,revision=excluded.revision''', (task_id,definition['name'],json.dumps(definition,ensure_ascii=False),int(definition['enabled']), old['created'] if old else now,now,revision)) if definition['enabled']:execute(['systemctl','enable','--now',unit_name(task_id)+'.timer']) except Exception: if old: install_units(task_id,old['definition']) with db() as con: con.execute('UPDATE tasks SET name=?,definition=?,enabled=?,revision=? WHERE id=?', (old['name'],json.dumps(old['definition'],ensure_ascii=False),old['enabled'],old['revision'],task_id)) if old['enabled']:execute(['systemctl','enable','--now',unit_name(task_id)+'.timer'],check=False) else: execute(['systemctl','disable','--now',unit_name(task_id)+'.timer'],check=False) with db() as con:con.execute('DELETE FROM tasks WHERE id=?',(task_id,)) for suffix in ('.service','.timer'):(UNITS/(unit_name(task_id)+suffix)).unlink(missing_ok=True) execute(['systemctl','daemon-reload'],check=False) raise audit('update' if old else 'create',task_id,client=client,detail='revision '+str(revision)) return {'id':task_id,'message':'任务已保存。'+('已启用,将按计划执行。' if definition['enabled'] else '当前停用,不会自动执行。')} def set_enabled(task_id,enabled,client=''): task=get_task(task_id) if enabled: schedule_definition(task['definition']['schedule']) calendar_preview(task['definition']) execute(['systemctl','enable' if enabled else 'disable','--now',unit_name(task_id)+'.timer']) with db() as con: definition=task['definition'];definition['enabled']=bool(enabled) con.execute('UPDATE tasks SET enabled=?,definition=?,updated=? WHERE id=?', (int(bool(enabled)),json.dumps(definition,ensure_ascii=False),time.time(),task_id)) audit('enable' if enabled else 'disable',task_id,client=client) return {'message':'已启用。' if enabled else '已停用后续调度;已经开始的执行不会被强制中断。'} def delete_task(task_id,client=''): get_task(task_id) if running(task_id):raise TaskError('任务正在执行,请先等待完成或停止本次执行。',409) execute(['systemctl','disable','--now',unit_name(task_id)+'.timer']) for suffix in ('.service','.timer'): (UNITS/(unit_name(task_id)+suffix)).unlink(missing_ok=True) execute(['systemctl','daemon-reload']) with db() as con:con.execute('UPDATE tasks SET deleted=1,enabled=0,updated=? WHERE id=?',(time.time(),task_id)) audit('delete',task_id,client=client) return {'message':'任务已删除,已有执行记录仍保留在服务器。'} def run_now(task_id,client=''): task=get_task(task_id) if running(task_id):raise TaskError('这个任务正在执行,不会重复启动。',409) with db() as con: con.execute('INSERT OR REPLACE INTO requests VALUES(?,?,?)',(task_id,'manual',time.time()+30)) execute(['systemctl','reset-failed',unit_name(task_id)+'.service'],check=False) execute(['systemctl','start','--no-block',unit_name(task_id)+'.service']) audit('run_now',task_id,client=client,detail='revision '+str(task['revision'])) return {'message':'已提交执行,请在执行日志中查看结果。'} def stop_task(task_id,client=''): get_task(task_id) execute(['systemctl','stop',unit_name(task_id)+'.service']) audit('stop_run',task_id,client=client) return {'message':'已请求停止本次执行。'} def list_tasks(): p=execute(['systemctl','list-timers','--all','--output=json','--no-pager','hwjob-*.timer'],check=False) try:timers={x['unit']:x for x in json.loads(p.stdout or '[]')} except (ValueError,TypeError):timers={} p=execute(['systemctl','list-units','--all','--type=service','--output=json','--no-pager','hwjob-*.service'],check=False) try:active={x['unit']:x for x in json.loads(p.stdout or '[]')} except (ValueError,TypeError):active={} with db() as con: tasks=con.execute('SELECT * FROM tasks WHERE deleted=0 ORDER BY created DESC').fetchall() result=[] for row in tasks: item=dict(row);definition=json.loads(item.pop('definition')) timer=timers.get(unit_name(item['id'])+'.timer',{}) next_us=timer.get('next') item['next_run']=next_us/1e6 if isinstance(next_us,(int,float)) and 0131072}