pluginserver 0.8.1__tar.gz → 0.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pluginserver-0.8.1/pluginserver.egg-info → pluginserver-0.9.0}/PKG-INFO +1 -1
- {pluginserver-0.8.1 → pluginserver-0.9.0}/plugincore/baseplugin.py +32 -3
- pluginserver-0.9.0/plugincore/logjam.py +76 -0
- {pluginserver-0.8.1 → pluginserver-0.9.0}/plugincore/pluginmanager.py +46 -21
- pluginserver-0.9.0/plugincore/pserv.py +467 -0
- {pluginserver-0.8.1 → pluginserver-0.9.0/pluginserver.egg-info}/PKG-INFO +1 -1
- {pluginserver-0.8.1 → pluginserver-0.9.0}/pluginserver.egg-info/SOURCES.txt +1 -2
- {pluginserver-0.8.1 → pluginserver-0.9.0}/setup.py +1 -1
- pluginserver-0.8.1/MANIFEST.in +0 -3
- pluginserver-0.8.1/plugincore/cors.py +0 -92
- pluginserver-0.8.1/plugincore/pserv.py +0 -259
- {pluginserver-0.8.1 → pluginserver-0.9.0}/LICENSE.txt +0 -0
- {pluginserver-0.8.1 → pluginserver-0.9.0}/README.md +0 -0
- {pluginserver-0.8.1 → pluginserver-0.9.0}/plugincore/__init__.py +0 -0
- {pluginserver-0.8.1 → pluginserver-0.9.0}/plugincore/configfile.py +0 -0
- {pluginserver-0.8.1 → pluginserver-0.9.0}/pluginserver.egg-info/dependency_links.txt +0 -0
- {pluginserver-0.8.1 → pluginserver-0.9.0}/pluginserver.egg-info/entry_points.txt +0 -0
- {pluginserver-0.8.1 → pluginserver-0.9.0}/pluginserver.egg-info/requires.txt +0 -0
- {pluginserver-0.8.1 → pluginserver-0.9.0}/pluginserver.egg-info/top_level.txt +0 -0
- {pluginserver-0.8.1 → pluginserver-0.9.0}/setup.cfg +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
from aiohttp import web
|
|
3
3
|
import inspect
|
|
4
|
+
from plugincore import logjam
|
|
4
5
|
|
|
5
6
|
class BasePlugin:
|
|
6
7
|
"""
|
|
@@ -14,6 +15,9 @@ class BasePlugin:
|
|
|
14
15
|
self.config = kwargs.get('config')
|
|
15
16
|
self._plugin_id = kwargs.get('route_path',self.__class__.__name__.lower())
|
|
16
17
|
auth = kwargs.get('auth_type')
|
|
18
|
+
args = kwargs.get('prog_args')
|
|
19
|
+
if not args:
|
|
20
|
+
raise ValueError(f"no args were passed")
|
|
17
21
|
if auth:
|
|
18
22
|
auth = auth.lower()
|
|
19
23
|
if auth:
|
|
@@ -28,8 +32,23 @@ class BasePlugin:
|
|
|
28
32
|
if not self._apikey:
|
|
29
33
|
raise ValueError('Auth is plugin but no plugin apikey')
|
|
30
34
|
self._auth_type = 'plugin'
|
|
35
|
+
if args.log:
|
|
36
|
+
self.log = logjam.LogJam(file=args.log, name=self._plugin_id,level=args.level)
|
|
37
|
+
else:
|
|
38
|
+
self.log = logjam.LogJam(name=self._plugin_id,level=args.level)
|
|
39
|
+
kwargs['log'] = self.log
|
|
31
40
|
self.args = dict(kwargs)
|
|
32
41
|
|
|
42
|
+
def _get_client_ip(self,request):
|
|
43
|
+
# Check proxy headers first
|
|
44
|
+
forwarded_for = request.headers.get('X-Forwarded-For')
|
|
45
|
+
if forwarded_for:
|
|
46
|
+
return forwarded_for.split(',')[0].strip()
|
|
47
|
+
|
|
48
|
+
# Fall back to transport address
|
|
49
|
+
peername = request.transport.get_extra_info('peername')
|
|
50
|
+
return peername[0] if peername else 'unknown'
|
|
51
|
+
|
|
33
52
|
def terminate_plugin(self):
|
|
34
53
|
pass
|
|
35
54
|
|
|
@@ -77,13 +96,23 @@ class BasePlugin:
|
|
|
77
96
|
return self._plugin_id
|
|
78
97
|
|
|
79
98
|
async def handle_request(self, **data):
|
|
99
|
+
request = data.get('request',{})
|
|
80
100
|
auth_check = self._check_auth(data)
|
|
101
|
+
data['client_ip'] = self._get_client_ip(data.get('request'))
|
|
81
102
|
if auth_check:
|
|
82
103
|
result = self.request_handler(**data)
|
|
83
104
|
code, response = await result if inspect.isawaitable(result) else result
|
|
84
105
|
#print(f"Got {code} - {response}")
|
|
85
106
|
else:
|
|
107
|
+
self.log.error(f"{data['client_ip']} - request for {self._plugin_id} - Not authorized")
|
|
86
108
|
code, response = 403, {'error': 'unauthorized'}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
109
|
+
|
|
110
|
+
if isinstance(response, web.Response):
|
|
111
|
+
pass
|
|
112
|
+
elif isinstance(response, dict):
|
|
113
|
+
response = web.json_response(response)
|
|
114
|
+
elif isinstance(response, str):
|
|
115
|
+
response = web.Response(text=response, content_type='text/html')
|
|
116
|
+
else:
|
|
117
|
+
response = web.json_response({'result': str(response)})
|
|
118
|
+
return response
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
import io
|
|
4
|
+
import sys
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
class LogJam:
|
|
8
|
+
def __init__(self, **kwargs):
|
|
9
|
+
self.log_level = kwargs.get('level', logging.DEBUG)
|
|
10
|
+
self.log_name = kwargs.get('name', 'NONAME')
|
|
11
|
+
self.log_file = kwargs.get('file', None)
|
|
12
|
+
|
|
13
|
+
# Convert string log level to logging constant if needed
|
|
14
|
+
if isinstance(self.log_level, str):
|
|
15
|
+
self.log_level = getattr(logging, self.log_level.upper(), logging.DEBUG)
|
|
16
|
+
|
|
17
|
+
self.logger = logging.getLogger(self.log_name)
|
|
18
|
+
self.logger.setLevel(self.log_level)
|
|
19
|
+
|
|
20
|
+
# Prevent duplicate handlers if logger already exists
|
|
21
|
+
if not self.logger.handlers:
|
|
22
|
+
if os.isatty(sys.stdout.fileno()):
|
|
23
|
+
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
24
|
+
else:
|
|
25
|
+
formatter = logging.Formatter('%(name)s - %(levelname)s - %(message)s')
|
|
26
|
+
|
|
27
|
+
# Console (stdout) handler
|
|
28
|
+
ch = logging.StreamHandler()
|
|
29
|
+
ch.setLevel(self.log_level)
|
|
30
|
+
ch.setFormatter(formatter)
|
|
31
|
+
self.logger.addHandler(ch)
|
|
32
|
+
|
|
33
|
+
# Optional file handler
|
|
34
|
+
if self.log_file:
|
|
35
|
+
fh = logging.FileHandler(self.log_file)
|
|
36
|
+
fh.setLevel(self.log_level)
|
|
37
|
+
fh.setFormatter(formatter)
|
|
38
|
+
self.logger.addHandler(fh)
|
|
39
|
+
|
|
40
|
+
def __call__(self,*args):
|
|
41
|
+
self.info(*args)
|
|
42
|
+
|
|
43
|
+
def common_log(self,**kwargs):
|
|
44
|
+
#ip, user_ident, user_auth, timestamp, method, path, protocol, status, size,file=None):
|
|
45
|
+
user_ident = kwargs.get('user_ident','-')
|
|
46
|
+
user_auth = kwargs.get('user_auth','-')
|
|
47
|
+
timestamp = kwargs.get('timestamp',time.time())
|
|
48
|
+
bad_keys = []
|
|
49
|
+
for k in ['ip','method','protocol','path','status','size']:
|
|
50
|
+
kv = kwargs.get(k) or None
|
|
51
|
+
if not kv:
|
|
52
|
+
bad_keys.append(k)
|
|
53
|
+
if len(bad_keys):
|
|
54
|
+
raise AttributeError(f"No value(s) given for {' '.join(bad_keys)}")
|
|
55
|
+
ip = kwargs.get('ip')
|
|
56
|
+
method = kwargs.get('method')
|
|
57
|
+
path = kwargs.get('path')
|
|
58
|
+
protocol = kwargs.get('protocol')
|
|
59
|
+
status = kwargs.get('status')
|
|
60
|
+
size = kwargs.get('size')
|
|
61
|
+
file = kwargs.get('file')
|
|
62
|
+
time_str = time.strftime("%d/%b/%Y:%H:%M:%S %z",time.localtime(timestamp))
|
|
63
|
+
lstr = f'{ip} {user_ident} {user_auth} [{time_str}] "{method} {path} {protocol}" {status} {size}'
|
|
64
|
+
if type(file) is str:
|
|
65
|
+
with open(file,'a') as f:
|
|
66
|
+
print(lstr,file=f)
|
|
67
|
+
elif isinstance(file,io.IOBase):
|
|
68
|
+
print(lstr,file=file)
|
|
69
|
+
return lstr
|
|
70
|
+
|
|
71
|
+
def debug(self, *args): self.logger.debug(*args)
|
|
72
|
+
def info(self, *args): self.logger.info(*args)
|
|
73
|
+
def warning(self, *args): self.logger.warning(*args)
|
|
74
|
+
def error(self, *args): self.logger.error(*args)
|
|
75
|
+
def critical(self, *args): self.logger.critical(*args)
|
|
76
|
+
def exception(self, *args): self.logger.exception(*args)
|
|
@@ -7,6 +7,7 @@ import glob
|
|
|
7
7
|
from typing import Dict, List, Union
|
|
8
8
|
from plugincore import baseplugin
|
|
9
9
|
from urllib.parse import parse_qs
|
|
10
|
+
import json
|
|
10
11
|
|
|
11
12
|
def parse_parameter_string(s):
|
|
12
13
|
return {key: value[0] for key, value in parse_qs(s).items()}
|
|
@@ -17,9 +18,18 @@ class PluginManager:
|
|
|
17
18
|
self.plugins: Dict[str, baseplugin.BasePlugin] = {}
|
|
18
19
|
self.modules: Dict[str, types.ModuleType] = {}
|
|
19
20
|
self.config = kwargs.get('config')
|
|
21
|
+
self.task_callback = kwargs.get('task_callback')
|
|
22
|
+
self.log = kwargs.get('log')
|
|
23
|
+
self.prog_args = kwargs.get('args')
|
|
24
|
+
if not self.prog_args:
|
|
25
|
+
raise ValueError('PluginManager: no args object passed')
|
|
20
26
|
self.kwargs = dict(kwargs)
|
|
21
27
|
|
|
22
|
-
|
|
28
|
+
|
|
29
|
+
def reset_config(self,config):
|
|
30
|
+
self.config = config
|
|
31
|
+
|
|
32
|
+
async def _load_module(self, filepath: str) -> types.ModuleType:
|
|
23
33
|
mod_name = os.path.basename(filepath).replace(".py", "")
|
|
24
34
|
spec = importlib.util.spec_from_file_location(mod_name, filepath)
|
|
25
35
|
if spec is None or spec.loader is None:
|
|
@@ -35,63 +45,78 @@ class PluginManager:
|
|
|
35
45
|
classes.append(cls)
|
|
36
46
|
return classes
|
|
37
47
|
|
|
38
|
-
def load_plugins(self):
|
|
48
|
+
async def load_plugins(self):
|
|
39
49
|
plugin_files = glob.glob(os.path.join(self.plugin_dir, '*.py'))
|
|
40
|
-
|
|
50
|
+
self.log(f"Loading plugins from {self.plugin_dir}")
|
|
41
51
|
for path in plugin_files:
|
|
42
|
-
self.load_plugin(path)
|
|
52
|
+
await self.load_plugin(path)
|
|
43
53
|
|
|
44
|
-
def load_plugin(self, path: str):
|
|
54
|
+
async def load_plugin(self, path: str):
|
|
45
55
|
plugin_module = os.path.splitext(os.path.basename(path))[0] # strip .py
|
|
46
|
-
mod = self._load_module(path)
|
|
56
|
+
mod = await self._load_module(path)
|
|
47
57
|
self.modules[plugin_module] = mod
|
|
48
58
|
|
|
49
59
|
for cls in self._get_plugin_classes(mod):
|
|
50
|
-
adict = {}
|
|
60
|
+
adict = {'task_callback': self.task_callback}
|
|
51
61
|
try:
|
|
52
62
|
adict = parse_parameter_string(self.config.plugin_parms[plugin_module])
|
|
53
63
|
except (AttributeError, KeyError):
|
|
54
64
|
pass
|
|
65
|
+
adict['prog_args'] = self.prog_args
|
|
55
66
|
kwargs = self.kwargs.copy()
|
|
56
67
|
kwargs.update(adict)
|
|
57
68
|
kwargs['config'] = self.config
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
69
|
+
if 'json' in adict:
|
|
70
|
+
jfilename= os.path.join(self.config.paths.plugins, adict['json'])
|
|
71
|
+
if os.path.exists(jfilename):
|
|
72
|
+
try:
|
|
73
|
+
with open(jfilename) as f:
|
|
74
|
+
kwargs.update(json.load(f))
|
|
75
|
+
except Exception as e:
|
|
76
|
+
self.log.warning(f"Plugin for {cls.__name__} could not load JSON settings from {jfilename}: {e}")
|
|
77
|
+
try:
|
|
78
|
+
instance = cls(**kwargs)
|
|
79
|
+
initialize = getattr(instance,'initialize',None)
|
|
80
|
+
if initialize and callable(initialize):
|
|
81
|
+
await instance.initialize(**kwargs)
|
|
82
|
+
self.log(f"Loaded plugin {cls.__name__}")
|
|
83
|
+
self.plugins[instance._get_plugin_id()] = instance
|
|
84
|
+
except Exception as e:
|
|
85
|
+
self.log.exception(f"Exception loading plugin from {path}: {e}")
|
|
86
|
+
|
|
87
|
+
async def remove_plugin(self, plugin_id: str):
|
|
63
88
|
plugin = self.plugins.pop(plugin_id, None)
|
|
64
89
|
if not plugin:
|
|
65
|
-
|
|
90
|
+
self.log(f"No plugin with ID {plugin_id}")
|
|
66
91
|
return
|
|
67
92
|
|
|
68
93
|
# Try to remove the module
|
|
69
94
|
try:
|
|
70
95
|
plugin.terminate_plugin()
|
|
71
96
|
except Exception as e:
|
|
72
|
-
|
|
97
|
+
self.log.exception(f"Exception {type(e)} Unloading plugin - terminate_plugin threw {e}")
|
|
73
98
|
module_name = plugin.__class__.__module__
|
|
74
99
|
module_file = os.path.basename(module_name + ".py")
|
|
75
|
-
|
|
100
|
+
self.log(f"Removing plugin {plugin_id} from module {module_name}")
|
|
76
101
|
|
|
77
102
|
self.modules.pop(module_file, None)
|
|
78
103
|
sys.modules.pop(module_name, None)
|
|
79
104
|
|
|
80
|
-
def reload_plugin(self, plugin_id: str):
|
|
105
|
+
async def reload_plugin(self, plugin_id: str):
|
|
81
106
|
if plugin_id not in self.plugins:
|
|
82
|
-
|
|
107
|
+
self.log(f"No such plugin to reload: {plugin_id}")
|
|
83
108
|
return
|
|
84
109
|
plugin = self.plugins[plugin_id]
|
|
85
110
|
module_name = plugin.__class__.__module__
|
|
86
111
|
module_file = os.path.basename(module_name + ".py")
|
|
87
112
|
full_path = os.path.join(self.plugin_dir, module_file)
|
|
88
113
|
|
|
89
|
-
self.remove_plugin(plugin_id)
|
|
90
|
-
self.load_plugin(full_path)
|
|
114
|
+
await self.remove_plugin(plugin_id)
|
|
115
|
+
await self.load_plugin(full_path)
|
|
91
116
|
|
|
92
|
-
def get_plugin(self, plugin_id: str):
|
|
117
|
+
async def get_plugin(self, plugin_id: str):
|
|
93
118
|
return self.plugins.get(plugin_id)
|
|
94
119
|
|
|
95
|
-
def all_plugins(self):
|
|
120
|
+
async def all_plugins(self):
|
|
96
121
|
return self.plugins
|
|
97
122
|
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
import inspect
|
|
4
|
+
import asyncio
|
|
5
|
+
import ssl
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import signal
|
|
9
|
+
import aiohttp_cors
|
|
10
|
+
from aiohttp import web
|
|
11
|
+
from plugincore import pluginmanager
|
|
12
|
+
from plugincore import configfile
|
|
13
|
+
from plugincore import logjam
|
|
14
|
+
|
|
15
|
+
routes = web.RouteTableDef()
|
|
16
|
+
manager = None
|
|
17
|
+
globalCfg = None
|
|
18
|
+
config_file = None
|
|
19
|
+
|
|
20
|
+
async_tasks = []
|
|
21
|
+
|
|
22
|
+
log = print
|
|
23
|
+
|
|
24
|
+
globals()['_signal_exit_code'] = 0
|
|
25
|
+
|
|
26
|
+
def cors_setup(app):
|
|
27
|
+
global globalCfg
|
|
28
|
+
global log
|
|
29
|
+
if 'cors' in globalCfg and 'enabled' in globalCfg['cors']:
|
|
30
|
+
rules = aiohttp_cors.ResourceOptions(
|
|
31
|
+
allow_credentials=True,
|
|
32
|
+
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
33
|
+
allow_headers=("Content-Type", "Authorization", "X-Requested-With", "Accept"),
|
|
34
|
+
expose_headers=("X-Custom-Header", "Content-Length"),
|
|
35
|
+
max_age=3600
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
settings = {}
|
|
39
|
+
for origin in globalCfg.cors.origin_url:
|
|
40
|
+
settings[origin] = rules
|
|
41
|
+
|
|
42
|
+
cors_configuration = aiohttp_cors.setup(app, defaults=settings)
|
|
43
|
+
|
|
44
|
+
for route in list(app.router.routes()):
|
|
45
|
+
cors_configuration.add(route)
|
|
46
|
+
|
|
47
|
+
log.info('CORS middleware enabled')
|
|
48
|
+
|
|
49
|
+
def register_async_task(task):
|
|
50
|
+
global async_tasks
|
|
51
|
+
async_tasks.append(task)
|
|
52
|
+
|
|
53
|
+
def get_signal_name(signal_number):
|
|
54
|
+
for name in dir(signal):
|
|
55
|
+
if name.startswith("SIG") and getattr(signal, name) == signal_number:
|
|
56
|
+
return name
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
on_shutdown_entered = False
|
|
60
|
+
async def on_shutdown(*args):
|
|
61
|
+
global on_shutdown_entered
|
|
62
|
+
global manager
|
|
63
|
+
global async_tasks
|
|
64
|
+
global log
|
|
65
|
+
global globalCfg
|
|
66
|
+
if 'pidfile' in globalCfg.paths:
|
|
67
|
+
try:
|
|
68
|
+
os.unlink(globalCfg.paths.pidfile)
|
|
69
|
+
except:
|
|
70
|
+
pass
|
|
71
|
+
if on_shutdown_entered:
|
|
72
|
+
return
|
|
73
|
+
on_shutdown_entered = True
|
|
74
|
+
log(("Sending plugins the terminate signal"))
|
|
75
|
+
for id, plugin in manager.plugins.items():
|
|
76
|
+
try:
|
|
77
|
+
log(f"Terminating plugin {id}")
|
|
78
|
+
plugin.terminate_plugin()
|
|
79
|
+
await asyncio.sleep(1)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
log(f"{type(e).__name__} Exception unloading plugin id {id}: {e}")
|
|
82
|
+
else:
|
|
83
|
+
log("Plugin manager or plugins not available for shutdown.")
|
|
84
|
+
|
|
85
|
+
#await asyncio.sleep(max(len(all_current_tasks)+1,5))
|
|
86
|
+
log("Waiting for terminate_plugins to complete.")
|
|
87
|
+
all_current_tasks = asyncio.all_tasks()
|
|
88
|
+
log.info(f"Ensuring tasks are ended")
|
|
89
|
+
current_shutdown_task = asyncio.current_task()
|
|
90
|
+
|
|
91
|
+
tasks_to_cancel = []
|
|
92
|
+
for task in all_current_tasks:
|
|
93
|
+
if task is not current_shutdown_task:
|
|
94
|
+
if task.get_name() != '::main::':
|
|
95
|
+
tasks_to_cancel.append(task)
|
|
96
|
+
|
|
97
|
+
if tasks_to_cancel:
|
|
98
|
+
for task_to_cancel_item in tasks_to_cancel:
|
|
99
|
+
if not task_to_cancel_item.done():
|
|
100
|
+
log(f"terminating task {task_to_cancel_item.get_name()}")
|
|
101
|
+
task_to_cancel_item.cancel()
|
|
102
|
+
log("Waiting for all other async tasks to complete cancellation...")
|
|
103
|
+
results = await asyncio.gather(*tasks_to_cancel, return_exceptions=True)
|
|
104
|
+
for i, result in enumerate(results):
|
|
105
|
+
task = tasks_to_cancel[i]
|
|
106
|
+
task_name = task.get_name() if hasattr(task, 'get_name') else f"Task-{id(task)}"
|
|
107
|
+
if isinstance(result, asyncio.CancelledError):
|
|
108
|
+
log(f"{task_name} was successfully cancelled.")
|
|
109
|
+
elif isinstance(result, Exception):
|
|
110
|
+
log(f"{task_name} finished with an exception: {type(result).__name__} - {result}")
|
|
111
|
+
else:
|
|
112
|
+
log(f"{task_name} finished. Result: {result}")
|
|
113
|
+
else:
|
|
114
|
+
log("No other async tasks found to cancel.")
|
|
115
|
+
|
|
116
|
+
async def _sh_then_act(action_func, *action_args):
|
|
117
|
+
global log
|
|
118
|
+
try:
|
|
119
|
+
await on_shutdown()
|
|
120
|
+
except Exception as e:
|
|
121
|
+
current_log_func = log if callable(getattr(log, 'exception', None)) else lambda msg: print(msg, file=sys.stderr)
|
|
122
|
+
current_log_func(f"Exception during on_shutdown from signal: {type(e).__name__} - {e}")
|
|
123
|
+
finally:
|
|
124
|
+
if callable(action_func):
|
|
125
|
+
action_func(*action_args)
|
|
126
|
+
|
|
127
|
+
def _sched_sh(async_wrapper_func, sync_action_func, *sync_action_args):
|
|
128
|
+
global log
|
|
129
|
+
current_log_func = log if callable(getattr(log, 'info', None)) else print
|
|
130
|
+
try:
|
|
131
|
+
loop = asyncio.get_running_loop()
|
|
132
|
+
if loop.is_running():
|
|
133
|
+
asyncio.create_task(async_wrapper_func(sync_action_func, *sync_action_args))
|
|
134
|
+
else:
|
|
135
|
+
if callable(sync_action_func):
|
|
136
|
+
sync_action_func(*sync_action_args)
|
|
137
|
+
except RuntimeError:
|
|
138
|
+
if callable(sync_action_func):
|
|
139
|
+
sync_action_func(*sync_action_args)
|
|
140
|
+
except Exception as e:
|
|
141
|
+
current_log_func(f"Error in _sched_sh: {type(e).__name__} - {e}")
|
|
142
|
+
if callable(sync_action_func):
|
|
143
|
+
sync_action_func(*sync_action_args)
|
|
144
|
+
|
|
145
|
+
def _act_execl():
|
|
146
|
+
global log
|
|
147
|
+
sys.stdout.flush()
|
|
148
|
+
sys.stderr.flush()
|
|
149
|
+
os.execl(sys.executable, sys.executable, *sys.argv)
|
|
150
|
+
|
|
151
|
+
def _act_exit(exit_code):
|
|
152
|
+
globals()['_signal_exit_code'] = exit_code
|
|
153
|
+
try:
|
|
154
|
+
asyncio.get_running_loop().stop()
|
|
155
|
+
except RuntimeError:
|
|
156
|
+
sys.exit(exit_code)
|
|
157
|
+
|
|
158
|
+
async def pserve_main(args):
|
|
159
|
+
global log
|
|
160
|
+
global manager
|
|
161
|
+
global globalCfg
|
|
162
|
+
global config_file
|
|
163
|
+
global routes
|
|
164
|
+
global on_shutdown
|
|
165
|
+
asyncio.current_task().set_name("::main::")
|
|
166
|
+
|
|
167
|
+
we_are = os.path.splitext(os.path.basename(sys.argv[0]))[0]
|
|
168
|
+
|
|
169
|
+
config_file = args.ini_file
|
|
170
|
+
if args.log:
|
|
171
|
+
log = logjam.LogJam(file=args.log, name=we_are,level=args.level)
|
|
172
|
+
else:
|
|
173
|
+
log = logjam.LogJam(name=we_are)
|
|
174
|
+
|
|
175
|
+
signal.signal(signal.SIGUSR1, reload)
|
|
176
|
+
log(f"{we_are}({os.getpid()}): Installed SIGUSR1 handler for reload.")
|
|
177
|
+
for sig_val_item in []:
|
|
178
|
+
try:
|
|
179
|
+
signal.signal(sig_val_item, lambda signum_val, frame_val: terminate(signum_val, frame_val, sig_to_exit_code=signum_val))
|
|
180
|
+
log(f"Installed signal handler for {get_signal_name(sig_val_item)} to terminate wrapper")
|
|
181
|
+
except Exception as e:
|
|
182
|
+
log(f"{type(e).__name__} setting signal handler for {sig_val_item}: {e}")
|
|
183
|
+
|
|
184
|
+
globalCfg = configfile.Config(file=config_file)
|
|
185
|
+
if 'pidfile' in globalCfg.paths:
|
|
186
|
+
with open(globalCfg.paths.pidfile,'w') as f:
|
|
187
|
+
print(f"{os.getpid()}",file=f)
|
|
188
|
+
ssl_ctx = None
|
|
189
|
+
ssl_cert, ssl_key = (None, None)
|
|
190
|
+
enabled = False
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
ssl_key = globalCfg.SSL.keyfile
|
|
194
|
+
ssl_cert = globalCfg.SSL.certfile
|
|
195
|
+
enabled = globalCfg.SSL.enabled
|
|
196
|
+
except AttributeError:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
if ssl_key and ssl_cert and enabled:
|
|
200
|
+
ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
|
201
|
+
log("======== SSL Configuration ========")
|
|
202
|
+
log(f"SSL key {ssl_key}")
|
|
203
|
+
log(f"SSL certificate: {ssl_cert}")
|
|
204
|
+
try:
|
|
205
|
+
ssl_ctx.load_cert_chain(ssl_cert, ssl_key, None)
|
|
206
|
+
log(f"SSL context loaded with cert: {ssl_cert}, key: {ssl_key}")
|
|
207
|
+
except FileNotFoundError as e:
|
|
208
|
+
log(f"FileNotFoundError loading SSL cert/key: {e}. Check paths: Cert='{ssl_cert}', Key='{ssl_key}'")
|
|
209
|
+
ssl_ctx = None
|
|
210
|
+
except ssl.SSLError as e:
|
|
211
|
+
log(f"ssl.SSLError loading SSL cert/key: {e}. Plese check your key and certfiles.")
|
|
212
|
+
ssl_ctx = None
|
|
213
|
+
except Exception as e:
|
|
214
|
+
log(f"Exception({type(e).__name__}): Error loading ssl_cert_chain: {e}")
|
|
215
|
+
ssl_ctx = None
|
|
216
|
+
log("End of SSL configuration.")
|
|
217
|
+
elif enabled:
|
|
218
|
+
log("SSL is enabled in config, but certfile or keyfile is missing/not specified.")
|
|
219
|
+
|
|
220
|
+
if not 'paths' in globalCfg or not 'plugins' in globalCfg.paths:
|
|
221
|
+
log(f"Configuration error: 'paths.plugins' not found in '{config_file}'. Cannot load plugins.")
|
|
222
|
+
sys.exit(1)
|
|
223
|
+
log("======== Loading plugin modules ========")
|
|
224
|
+
manager = pluginmanager.PluginManager(globalCfg.paths.plugins, config=globalCfg, log=log, task_callback=register_async_task, args=args)
|
|
225
|
+
await manager.load_plugins()
|
|
226
|
+
|
|
227
|
+
for plugin_id, instance in manager.plugins.items():
|
|
228
|
+
register_plugin_route(plugin_id, instance, globalCfg)
|
|
229
|
+
|
|
230
|
+
register_control_routes(globalCfg)
|
|
231
|
+
|
|
232
|
+
app = web.Application()
|
|
233
|
+
app.add_routes(routes)
|
|
234
|
+
app.on_shutdown.append(on_shutdown)
|
|
235
|
+
cors_setup(app)
|
|
236
|
+
runner = web.AppRunner(app)
|
|
237
|
+
await runner.setup()
|
|
238
|
+
site = web.TCPSite(runner, host=globalCfg.network.bindto, port=globalCfg.network.port, ssl_context=ssl_ctx)
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
await site.start()
|
|
242
|
+
log(f"Server started on {globalCfg.network.bindto}:{globalCfg.network.port}")
|
|
243
|
+
await asyncio.Event().wait()
|
|
244
|
+
except OSError as e:
|
|
245
|
+
log.error(e)
|
|
246
|
+
sys.exit(1)
|
|
247
|
+
except (KeyboardInterrupt, asyncio.CancelledError) as e:
|
|
248
|
+
log(f"Exception({type(e).__name__}). Initiating shutdown.")
|
|
249
|
+
pass
|
|
250
|
+
except Exception as e:
|
|
251
|
+
log.exception(f"{type(e)}: Unexpected error in pserve_main server loop: {e}")
|
|
252
|
+
finally:
|
|
253
|
+
await runner.cleanup()
|
|
254
|
+
|
|
255
|
+
def check_auth(data, config):
|
|
256
|
+
toktype = 'Undefined'
|
|
257
|
+
def get_token(data):
|
|
258
|
+
nonlocal toktype
|
|
259
|
+
headers = data.get('request_headers', {})
|
|
260
|
+
auth_header = headers.get('Authorization')
|
|
261
|
+
if auth_header and auth_header.startswith('Bearer '):
|
|
262
|
+
toktype = 'token'
|
|
263
|
+
return auth_header.split(' ', 1)[1].strip()
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
def get_custom_header_token(data):
|
|
267
|
+
nonlocal toktype
|
|
268
|
+
headers = data.get('request_headers', {})
|
|
269
|
+
custom_header = headers.get('X-Custom-Auth')
|
|
270
|
+
toktype = 'custom'
|
|
271
|
+
if custom_header:
|
|
272
|
+
return custom_header.strip()
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
def get_user_token(data):
|
|
276
|
+
nonlocal toktype
|
|
277
|
+
token = data.get('apikey')
|
|
278
|
+
if token:
|
|
279
|
+
toktype='userdata'
|
|
280
|
+
return token
|
|
281
|
+
try:
|
|
282
|
+
expected = config.auth.apikey
|
|
283
|
+
except AttributeError:
|
|
284
|
+
return True
|
|
285
|
+
if not expected:
|
|
286
|
+
return True
|
|
287
|
+
|
|
288
|
+
provided = get_token(data) or get_custom_header_token(data) or get_user_token(data)
|
|
289
|
+
if not provided:
|
|
290
|
+
return False
|
|
291
|
+
auth_ok = expected == provided
|
|
292
|
+
return auth_ok
|
|
293
|
+
|
|
294
|
+
def register_plugin_route(plugin_id, instance, config):
|
|
295
|
+
global log
|
|
296
|
+
global manager
|
|
297
|
+
global routes
|
|
298
|
+
|
|
299
|
+
log(f"Registering route: /{plugin_id}")
|
|
300
|
+
|
|
301
|
+
@routes.route('GET', f'/{plugin_id}')
|
|
302
|
+
@routes.route('GET', f'/{plugin_id}/{{tail:.*}}')
|
|
303
|
+
@routes.route('POST', f'/{plugin_id}')
|
|
304
|
+
@routes.route('POST', f'/{plugin_id}/{{tail:.*}}')
|
|
305
|
+
async def handle(request, inst=instance, pid=plugin_id, cfg=config):
|
|
306
|
+
plugin = await manager.get_plugin(pid)
|
|
307
|
+
if plugin is None:
|
|
308
|
+
log(f"Plugin {pid} not found for request.")
|
|
309
|
+
return web.json_response({"error": f"Plugin {pid} not found"}, status=404)
|
|
310
|
+
|
|
311
|
+
data = {'log': log, 'request_headers': dict(request.headers), 'request': request}
|
|
312
|
+
if request.method == 'POST' and request.can_read_body:
|
|
313
|
+
try:
|
|
314
|
+
rqj = await request.json()
|
|
315
|
+
data.update(rqj)
|
|
316
|
+
except Exception as e:
|
|
317
|
+
log.exception(f"Cannot get request body for plugin {pid}: {e}")
|
|
318
|
+
data.update(request.query)
|
|
319
|
+
try:
|
|
320
|
+
data['subpath'] = request.match_info.get('tail')
|
|
321
|
+
except KeyError:
|
|
322
|
+
data['subpath'] = None
|
|
323
|
+
|
|
324
|
+
response_data = await maybe_async(inst.handle_request(**data))
|
|
325
|
+
|
|
326
|
+
if not isinstance(response_data, web.StreamResponse):
|
|
327
|
+
if isinstance(response_data, (dict, list)):
|
|
328
|
+
response = web.json_response(response_data)
|
|
329
|
+
elif isinstance(response_data, str):
|
|
330
|
+
response = web.Response(text=response_data)
|
|
331
|
+
else:
|
|
332
|
+
log(f"Plugin {pid} returned unexpected response type: {type(response_data)}")
|
|
333
|
+
response = web.json_response({"error": "Internal server error from plugin response"}, status=500)
|
|
334
|
+
else:
|
|
335
|
+
response = response_data
|
|
336
|
+
return response
|
|
337
|
+
|
|
338
|
+
def register_control_routes(config):
|
|
339
|
+
global log
|
|
340
|
+
global manager
|
|
341
|
+
global globalCfg
|
|
342
|
+
global config_file
|
|
343
|
+
global routes
|
|
344
|
+
|
|
345
|
+
log("Registering Control Routes")
|
|
346
|
+
@routes.route('GET','/plugins')
|
|
347
|
+
@routes.route('POS','/plugins')
|
|
348
|
+
async def plugin_list(request):
|
|
349
|
+
data = {}
|
|
350
|
+
if request.method == 'POST' and request.can_read_body:
|
|
351
|
+
try:
|
|
352
|
+
data.update(await request.json())
|
|
353
|
+
except Exception: pass
|
|
354
|
+
data.update(request.query)
|
|
355
|
+
data['request_headers'] = dict(request.headers)
|
|
356
|
+
if not check_auth(data, config):
|
|
357
|
+
return web.json_response({'error': 'unauthorized'}, status=403)
|
|
358
|
+
|
|
359
|
+
loaded_plugins = list(manager.plugins.keys()) if manager and manager.plugins else []
|
|
360
|
+
return web.json_response({'loaded_plugins': loaded_plugins})
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@routes.route('GET','/reload/{plugin_id}')
|
|
364
|
+
@routes.route('POST','/reload/{plugin_id}')
|
|
365
|
+
async def reload_plugin(request):
|
|
366
|
+
current_config_for_auth = globalCfg
|
|
367
|
+
|
|
368
|
+
data = {}
|
|
369
|
+
if request.method == 'POST' and request.can_read_body:
|
|
370
|
+
try: data.update(await request.json())
|
|
371
|
+
except Exception: pass
|
|
372
|
+
data.update(request.query)
|
|
373
|
+
data['request_headers'] = dict(request.headers)
|
|
374
|
+
if not check_auth(data, current_config_for_auth):
|
|
375
|
+
return web.json_response({'error': 'unauthorized'}, status=403)
|
|
376
|
+
|
|
377
|
+
pid = request.match_info['plugin_id']
|
|
378
|
+
if manager and pid in manager.plugins:
|
|
379
|
+
try:
|
|
380
|
+
reloaded_cfg = configfile.Config(file=config_file)
|
|
381
|
+
globals()['globalCfg'] = reloaded_cfg
|
|
382
|
+
manager.reset_config(reloaded_cfg)
|
|
383
|
+
success = await manager.reload_plugin(pid)
|
|
384
|
+
web.json_response({'reloaded': pid, 'success': success})
|
|
385
|
+
except Exception as e:
|
|
386
|
+
log.exception(f"Error reloading plugin {pid}: {e}")
|
|
387
|
+
return web.json_response({'error': f'Failed to reload plugin {pid}'}, status=500)
|
|
388
|
+
web.json_response({'error': f'Plugin "{pid}" not found'}, status=404)
|
|
389
|
+
|
|
390
|
+
@routes.route('GET', '/reload/all')
|
|
391
|
+
@routes.route('POST', '/reload/all')
|
|
392
|
+
async def reload_all(request):
|
|
393
|
+
current_config_for_auth = globalCfg
|
|
394
|
+
|
|
395
|
+
data = {}
|
|
396
|
+
if request.method == 'POST' and request.can_read_body:
|
|
397
|
+
try: data.update(await request.json())
|
|
398
|
+
except Exception: pass
|
|
399
|
+
data.update(request.query)
|
|
400
|
+
data['request_headers'] = dict(request.headers)
|
|
401
|
+
if not check_auth(data, current_config_for_auth):
|
|
402
|
+
return web.json_response({'error': 'unauthorized'}, status=403)
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
reloaded_cfg = configfile.Config(file=config_file)
|
|
406
|
+
globals()['globalCfg'] = reloaded_cfg
|
|
407
|
+
if manager:
|
|
408
|
+
manager.reset_config(reloaded_cfg)
|
|
409
|
+
await manager.load_plugins()
|
|
410
|
+
return web.json_response({'status': 'All plugins reloaded', 'loaded_plugins': list(manager.plugins.keys())})
|
|
411
|
+
else:
|
|
412
|
+
return web.json_response({'error': 'Plugin manager not available'}, status=500)
|
|
413
|
+
except Exception as e:
|
|
414
|
+
log.exception(f"Error reloading all plugins: {e}")
|
|
415
|
+
return web.json_response({'error': 'Failed to reload all plugins'}, status=500)
|
|
416
|
+
|
|
417
|
+
async def maybe_async(value):
|
|
418
|
+
return await value if inspect.isawaitable(value) else value
|
|
419
|
+
|
|
420
|
+
def reload(signum, frame):
|
|
421
|
+
global log
|
|
422
|
+
log(f"Received {get_signal_name(signum)} - Terminating plugins")
|
|
423
|
+
_sched_sh(_sh_then_act, _act_execl)
|
|
424
|
+
|
|
425
|
+
def terminate(signum, frame, sig_to_exit_code=None):
|
|
426
|
+
global log
|
|
427
|
+
actual_exit_code = sig_to_exit_code if sig_to_exit_code is not None else signum
|
|
428
|
+
log(f"Received {get_signal_name(signum)} - Terminating plugins")
|
|
429
|
+
_sched_sh(_sh_then_act, _act_exit, actual_exit_code)
|
|
430
|
+
|
|
431
|
+
def main():
|
|
432
|
+
global log
|
|
433
|
+
global _signal_exit_code
|
|
434
|
+
|
|
435
|
+
we_are = os.path.splitext(os.path.basename(sys.argv[0]))[0]
|
|
436
|
+
|
|
437
|
+
parser = argparse.ArgumentParser(
|
|
438
|
+
description="Plugin Server - create a RESTapi using simple plugins",
|
|
439
|
+
epilog="Nicole Stevens/2025"
|
|
440
|
+
)
|
|
441
|
+
parser.add_argument('-i','--ini-file',default=f"{we_are}.ini",type=str, metavar='ini-file',help='Use an alternate config file')
|
|
442
|
+
parser.add_argument('-l','--log',default=None,type=str,metavar='file',help='Set a log file')
|
|
443
|
+
parser.add_argument('-v','--level',default='DEBUG',type=str,help="Logging level, INFO DEBUG ERROR CRITICAL", metavar='level')
|
|
444
|
+
args = parser.parse_args()
|
|
445
|
+
|
|
446
|
+
exit_code = 0
|
|
447
|
+
_signal_exit_code = 0
|
|
448
|
+
|
|
449
|
+
try:
|
|
450
|
+
asyncio.run(pserve_main(args))
|
|
451
|
+
exit_code = _signal_exit_code
|
|
452
|
+
except KeyboardInterrupt:
|
|
453
|
+
log.info("Application terminated by KeyboardInterrupt (caught in main).")
|
|
454
|
+
exit_code = getattr(signal.SIGINT, 'value', 2)
|
|
455
|
+
except SystemExit as e:
|
|
456
|
+
log.info(f"SystemExit caught in main with code: {e.code}")
|
|
457
|
+
exit_code = e.code if e.code is not None else 0
|
|
458
|
+
except Exception as e:
|
|
459
|
+
log.exception(f"Unhandled exception in main: {e}")
|
|
460
|
+
exit_code = 1
|
|
461
|
+
finally:
|
|
462
|
+
log.info(f"Application exiting with code {exit_code}.")
|
|
463
|
+
|
|
464
|
+
sys.exit(exit_code)
|
|
465
|
+
|
|
466
|
+
if __name__ == "__main__":
|
|
467
|
+
main()
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
LICENSE.txt
|
|
2
|
-
MANIFEST.in
|
|
3
2
|
README.md
|
|
4
3
|
setup.py
|
|
5
4
|
plugincore/__init__.py
|
|
6
5
|
plugincore/baseplugin.py
|
|
7
6
|
plugincore/configfile.py
|
|
8
|
-
plugincore/
|
|
7
|
+
plugincore/logjam.py
|
|
9
8
|
plugincore/pluginmanager.py
|
|
10
9
|
plugincore/pserv.py
|
|
11
10
|
pluginserver.egg-info/PKG-INFO
|
pluginserver-0.8.1/MANIFEST.in
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
from aiohttp import web
|
|
2
|
-
import aiohttp_cors
|
|
3
|
-
import aiohttp_cors
|
|
4
|
-
from aiohttp import web
|
|
5
|
-
import re
|
|
6
|
-
from urllib.parse import urlparse
|
|
7
|
-
|
|
8
|
-
class CORS:
|
|
9
|
-
def setup(self, app, globalCfg):
|
|
10
|
-
self.origins = []
|
|
11
|
-
self.acl = []
|
|
12
|
-
self.config = globalCfg
|
|
13
|
-
self.cors_enabled = False
|
|
14
|
-
|
|
15
|
-
# Check if CORS is enabled in the config
|
|
16
|
-
if 'cors' in self.config:
|
|
17
|
-
if 'enabled' in self.config.cors:
|
|
18
|
-
self.cors_enabled = self.config.cors.enabled # Assuming this is a boolean
|
|
19
|
-
if self.cors_enabled:
|
|
20
|
-
# If enabled, fetch the allowed origins
|
|
21
|
-
if 'origin_url' in self.config.cors:
|
|
22
|
-
self.origins = self.config.cors.origin_url # List of allowed origins
|
|
23
|
-
else:
|
|
24
|
-
# If origin_url is not set, disable CORS
|
|
25
|
-
self.cors_enabled = False
|
|
26
|
-
if self.cors_enabled:
|
|
27
|
-
if 'acl' in self.config.cors:
|
|
28
|
-
self.acl = self.config.cors.acl
|
|
29
|
-
print(f"CORS setup, acl {self.acl}")
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
# If CORS is enabled, set it up with aiohttp_cors
|
|
33
|
-
if self.cors_enabled:
|
|
34
|
-
print(f"CORS Setup - ORIGIN URLs: {self.origins}")
|
|
35
|
-
cors = aiohttp_cors.setup(app)
|
|
36
|
-
for route in list(app.router.routes()):
|
|
37
|
-
cors.add(route, {
|
|
38
|
-
'origins': self.origins,
|
|
39
|
-
'allow_credentials': True,
|
|
40
|
-
'expose_headers': "*",
|
|
41
|
-
'allow_headers': "*",
|
|
42
|
-
'allow_methods': ["GET", "POST", "OPTIONS"]
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
return self
|
|
46
|
-
|
|
47
|
-
def _add_header(self, response, request):
|
|
48
|
-
"""Add the CORS headers to the response."""
|
|
49
|
-
request_origin = request.headers.get('Origin')
|
|
50
|
-
if request_origin == 'null':
|
|
51
|
-
request_origin = None
|
|
52
|
-
if not request_origin:
|
|
53
|
-
return response
|
|
54
|
-
host_name = urlparse(request_origin).hostname
|
|
55
|
-
|
|
56
|
-
request_ok = True
|
|
57
|
-
if self.cors_enabled and request_origin:
|
|
58
|
-
if request_origin in self.origins:
|
|
59
|
-
response.headers['Access-Control-Allow-Origin'] = request_origin
|
|
60
|
-
else:
|
|
61
|
-
request_ok = False
|
|
62
|
-
if self.acl:
|
|
63
|
-
for a in self.acl:
|
|
64
|
-
pattern = r'^[a-zA-Z0-9.-]*\.' + re.escape(a) + r'$|^' + re.escape(a) + r'$'
|
|
65
|
-
if re.match(pattern,urlparse(request_origin).hostname):
|
|
66
|
-
request_ok = True
|
|
67
|
-
break
|
|
68
|
-
if not request_ok:
|
|
69
|
-
return web.HTTPForbidden(text="CORS origin not allowed")
|
|
70
|
-
|
|
71
|
-
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS'
|
|
72
|
-
response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
|
|
73
|
-
response.headers['Vary'] = 'Origin'
|
|
74
|
-
return response
|
|
75
|
-
|
|
76
|
-
def apply_headers(self, response, request):
|
|
77
|
-
"""Apply CORS headers based on the response type."""
|
|
78
|
-
# Skip the CORS logic if CORS is disabled
|
|
79
|
-
if not self.cors_enabled:
|
|
80
|
-
return response # No CORS logic applied
|
|
81
|
-
|
|
82
|
-
if isinstance(response, web.Response):
|
|
83
|
-
response = self._add_header(response, request)
|
|
84
|
-
elif isinstance(response, dict):
|
|
85
|
-
response = self._add_header(web.json_response(response), request)
|
|
86
|
-
elif isinstance(response, str):
|
|
87
|
-
response = self._add_header(web.Response(text=response, content_type='text/html'), request)
|
|
88
|
-
else:
|
|
89
|
-
response = self._add_header(web.json_response({'result': str(response)}), request)
|
|
90
|
-
|
|
91
|
-
response.headers['X-Content-Type-Options'] = 'nosniff' # Security header
|
|
92
|
-
return response
|
|
@@ -1,259 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
import argparse
|
|
3
|
-
import inspect
|
|
4
|
-
import asyncio
|
|
5
|
-
import ssl
|
|
6
|
-
import os
|
|
7
|
-
import sys
|
|
8
|
-
import signal
|
|
9
|
-
from aiohttp import web
|
|
10
|
-
from plugincore import pluginmanager
|
|
11
|
-
from plugincore import configfile
|
|
12
|
-
import aiohttp_cors
|
|
13
|
-
from plugincore.cors import CORS
|
|
14
|
-
routes = web.RouteTableDef()
|
|
15
|
-
manager = None # PluginManager reference
|
|
16
|
-
globalCfg = None
|
|
17
|
-
import aiohttp_cors
|
|
18
|
-
from aiohttp import web
|
|
19
|
-
|
|
20
|
-
corsobj = CORS()
|
|
21
|
-
|
|
22
|
-
async def on_shutdown(*args):
|
|
23
|
-
"""
|
|
24
|
-
call terminate_plugin for active plugins
|
|
25
|
-
"""
|
|
26
|
-
global manager
|
|
27
|
-
print(("Sending plugins the terminate signal"))
|
|
28
|
-
for id, plugin in manager.plugins.items():
|
|
29
|
-
try:
|
|
30
|
-
plugin.terminate_plugin()
|
|
31
|
-
except Exception as e:
|
|
32
|
-
print(f"{type(e)} Exception unloading plugin id {id}")
|
|
33
|
-
print("Waiting for tasks")
|
|
34
|
-
await asyncio.sleep(3)
|
|
35
|
-
|
|
36
|
-
def main():
|
|
37
|
-
global manager
|
|
38
|
-
global globalCfg
|
|
39
|
-
|
|
40
|
-
we_are = os.path.splitext(os.path.basename(sys.argv[0]))[0]
|
|
41
|
-
|
|
42
|
-
parser = argparse.ArgumentParser(
|
|
43
|
-
description="Plugin Server - create a RESTapi using simple plugins",
|
|
44
|
-
epilog="Nicole Stevens/2025"
|
|
45
|
-
)
|
|
46
|
-
parser.add_argument('-i','--ini-file',default=f"{we_are}.ini",type=str, metavar='ini-file',help='Use an alternate config file')
|
|
47
|
-
args = parser.parse_args()
|
|
48
|
-
|
|
49
|
-
signal.signal(signal.SIGHUP, reload)
|
|
50
|
-
print(f"{we_are}({os.getpid()}): Installed SIGHUP handler for reload.")
|
|
51
|
-
|
|
52
|
-
globalCfg = configfile.Config(file=args.ini_file)
|
|
53
|
-
|
|
54
|
-
ssl_ctx = None
|
|
55
|
-
ssl_cert, ssl_key = (None, None)
|
|
56
|
-
enabled = False
|
|
57
|
-
|
|
58
|
-
# SSL setup if enabled
|
|
59
|
-
try:
|
|
60
|
-
ssl_key = globalCfg.SSL.keyfile
|
|
61
|
-
ssl_cert = globalCfg.SSL.certfile
|
|
62
|
-
enabled = globalCfg.SSL.enabled
|
|
63
|
-
except AttributeError:
|
|
64
|
-
pass
|
|
65
|
-
if ssl_key and ssl_cert and enabled:
|
|
66
|
-
ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
|
67
|
-
print("======== SSL Configuration ========")
|
|
68
|
-
print(f"SSL key {ssl_key}")
|
|
69
|
-
print(f"SSL certificate: {ssl_cert}")
|
|
70
|
-
print(f"SSL context {ssl_ctx}")
|
|
71
|
-
print("Loading SSL cert chain")
|
|
72
|
-
try:
|
|
73
|
-
ssl_ctx.load_cert_chain(ssl_cert, ssl_key, None)
|
|
74
|
-
except Exception as e:
|
|
75
|
-
print(f"Exception({type(e)}): Error loading ssl_cert_chain({ssl_cert},{ssl_key})")
|
|
76
|
-
for p in [ssl_cert, ssl_key]:
|
|
77
|
-
if not os.path.exists(p):
|
|
78
|
-
print(f"Path: {p} not found.")
|
|
79
|
-
ssl_ctx = None
|
|
80
|
-
print("End of SSL configuration.")
|
|
81
|
-
|
|
82
|
-
if not 'paths' in globalCfg:
|
|
83
|
-
print(f"no paths in {globalCfg}")
|
|
84
|
-
sys.exit(1)
|
|
85
|
-
print("======== Loading plugin modules ========")
|
|
86
|
-
manager = pluginmanager.PluginManager(globalCfg.paths.plugins, config=globalCfg)
|
|
87
|
-
manager.load_plugins()
|
|
88
|
-
|
|
89
|
-
# Register plugin routes
|
|
90
|
-
for plugin_id, instance in manager.plugins.items():
|
|
91
|
-
register_plugin_route(plugin_id, instance, globalCfg)
|
|
92
|
-
|
|
93
|
-
# Setup event loop for file watcher
|
|
94
|
-
|
|
95
|
-
# Management endpoints
|
|
96
|
-
register_control_routes(globalCfg)
|
|
97
|
-
|
|
98
|
-
app = web.Application()
|
|
99
|
-
|
|
100
|
-
# CORS setup
|
|
101
|
-
corsobj.setup(app,globalCfg)
|
|
102
|
-
|
|
103
|
-
app.add_routes(routes)
|
|
104
|
-
app.on_shutdown.append(on_shutdown)
|
|
105
|
-
if 'documents' in globalCfg.paths:
|
|
106
|
-
"""
|
|
107
|
-
if configured set up static page service
|
|
108
|
-
"""
|
|
109
|
-
if ':' in globalCfg.paths.documents:
|
|
110
|
-
dname, dpath = globalCfg.paths.documents.split(':')
|
|
111
|
-
else:
|
|
112
|
-
dname, dpath = 'docs', globalCfg.paths.documents
|
|
113
|
-
|
|
114
|
-
if 'indexfile' in globalCfg.paths:
|
|
115
|
-
indexfile = globalCfg.paths.indexfile
|
|
116
|
-
print(f"indexfile for static service is {indexfile}")
|
|
117
|
-
# Route to handle /docs/ (with or without trailing slash)
|
|
118
|
-
async def default_doc_handler(request):
|
|
119
|
-
return web.FileResponse(os.path.join(dpath, indexfile))
|
|
120
|
-
|
|
121
|
-
app.router.add_get(f'/{dname}', default_doc_handler)
|
|
122
|
-
app.router.add_get(f'/{dname}/', default_doc_handler)
|
|
123
|
-
|
|
124
|
-
print(f"Setting up static page delivery for {dname} from {dpath}")
|
|
125
|
-
app.router.add_static(f"/{dname}/", path=dpath, name=dname)
|
|
126
|
-
|
|
127
|
-
try:
|
|
128
|
-
web.run_app(app, host=globalCfg.network.bindto, port=globalCfg.network.port, ssl_context=ssl_ctx)
|
|
129
|
-
except KeyboardInterrupt:
|
|
130
|
-
on_shutdown()
|
|
131
|
-
|
|
132
|
-
# --- Auth Helper ---
|
|
133
|
-
def check_auth(data, config):
|
|
134
|
-
toktype = 'Undefined'
|
|
135
|
-
def get_token(data):
|
|
136
|
-
nonlocal toktype
|
|
137
|
-
headers = data.get('request_headers', {})
|
|
138
|
-
auth_header = headers.get('Authorization')
|
|
139
|
-
if auth_header and auth_header.startswith('Bearer '):
|
|
140
|
-
toktype = 'token'
|
|
141
|
-
return auth_header.split(' ', 1)[1].strip()
|
|
142
|
-
return None
|
|
143
|
-
|
|
144
|
-
def get_custom_header_token(data):
|
|
145
|
-
nonlocal toktype
|
|
146
|
-
headers = data.get('request_headers', {})
|
|
147
|
-
custom_header = headers.get('X-Custom-Auth')
|
|
148
|
-
toktype = 'custom'
|
|
149
|
-
if custom_header:
|
|
150
|
-
return custom_header.strip()
|
|
151
|
-
return None
|
|
152
|
-
|
|
153
|
-
def get_user_token(data):
|
|
154
|
-
nonlocal toktype
|
|
155
|
-
token = data.get('apikey')
|
|
156
|
-
if token:
|
|
157
|
-
toktype='userdata'
|
|
158
|
-
return token
|
|
159
|
-
try:
|
|
160
|
-
expected = config.auth.apikey
|
|
161
|
-
except AttributeError:
|
|
162
|
-
return True
|
|
163
|
-
provided = get_token(data) or get_custom_header_token(data) or get_user_token(data)
|
|
164
|
-
#print(f"pserv:check_auth: provided/expected: {provided}/{expected}")
|
|
165
|
-
if not provided:
|
|
166
|
-
print("Returning false")
|
|
167
|
-
return False
|
|
168
|
-
auth_ok = expected == provided
|
|
169
|
-
#print("Returning {auth_ok}")
|
|
170
|
-
return auth_ok
|
|
171
|
-
|
|
172
|
-
# --- Plugin Request Handler ---
|
|
173
|
-
def register_plugin_route(plugin_id, instance, config):
|
|
174
|
-
print(f"Registering route: /{plugin_id} to {instance}")
|
|
175
|
-
|
|
176
|
-
@routes.route('*', f'/{plugin_id}')
|
|
177
|
-
@routes.route('*', f'/{plugin_id}/{{tail:.*}}')
|
|
178
|
-
async def handle(request, inst=instance, pid=plugin_id, cfg=config):
|
|
179
|
-
#print(request.remote, '- request -', pid)
|
|
180
|
-
plugin = manager.get_plugin(pid)
|
|
181
|
-
data = {}
|
|
182
|
-
if request.method == 'POST' and request.can_read_body:
|
|
183
|
-
try:
|
|
184
|
-
data.update(await request.json())
|
|
185
|
-
except Exception:
|
|
186
|
-
pass
|
|
187
|
-
data.update(request.query)
|
|
188
|
-
data['request_headers'] = dict(request.headers)
|
|
189
|
-
# You can also capture `tail` if you want to use the subpath
|
|
190
|
-
try:
|
|
191
|
-
data['subpath'] = request.match_info['tail']
|
|
192
|
-
except KeyError:
|
|
193
|
-
data['subpath'] = None
|
|
194
|
-
response = await maybe_async(plugin.handle_request(**data))
|
|
195
|
-
response = corsobj.apply_headers(response, request)
|
|
196
|
-
return response
|
|
197
|
-
|
|
198
|
-
# --- Control Routes ---
|
|
199
|
-
def register_control_routes(config):
|
|
200
|
-
print("Registering Control Routes")
|
|
201
|
-
@routes.route('*','/plugins')
|
|
202
|
-
async def plugin_list(request):
|
|
203
|
-
data = {}
|
|
204
|
-
if request.method == 'POST' and request.can_read_body:
|
|
205
|
-
try:
|
|
206
|
-
data.update(await request.json())
|
|
207
|
-
except Exception:
|
|
208
|
-
pass
|
|
209
|
-
data.update(request.query)
|
|
210
|
-
data['request_headers'] = dict(request.headers)
|
|
211
|
-
if not check_auth(data, config):
|
|
212
|
-
return web.json_response({'error': 'unauthorized'}, status=403)
|
|
213
|
-
return corsobj.apply_headers(web.json_response({'loaded_plugins': list(manager.plugins.keys())}),request)
|
|
214
|
-
|
|
215
|
-
@routes.route('*','/reload/{plugin_id}')
|
|
216
|
-
async def reload_plugin(request):
|
|
217
|
-
data = {}
|
|
218
|
-
if request.method == 'POST' and request.can_read_body:
|
|
219
|
-
try:
|
|
220
|
-
data.update(await request.json())
|
|
221
|
-
except Exception:
|
|
222
|
-
pass
|
|
223
|
-
data.update(request.query)
|
|
224
|
-
data['request_headers'] = dict(request.headers)
|
|
225
|
-
if not check_auth(data, config):
|
|
226
|
-
return corsobj.apply_headers(web.json_response({'error': 'unauthorized'}, status=403),request)
|
|
227
|
-
|
|
228
|
-
pid = request.match_info['plugin_id']
|
|
229
|
-
if pid in manager.plugins:
|
|
230
|
-
success = manager.reload_plugin(pid)
|
|
231
|
-
return corsobj.apply_headers(web.json_response({'reloaded': pid, 'success': success}),request)
|
|
232
|
-
return corsobj.apply_headers(web.json_response({'error': f'Plugin "{pid}" not found'}, status=404),request)
|
|
233
|
-
|
|
234
|
-
@routes.route('*', '/reload/all')
|
|
235
|
-
async def reload_all(request):
|
|
236
|
-
data = {}
|
|
237
|
-
if request.method == 'POST' and request.can_read_body:
|
|
238
|
-
try:
|
|
239
|
-
data.update(await request.json())
|
|
240
|
-
except Exception:
|
|
241
|
-
pass
|
|
242
|
-
data.update(request.query)
|
|
243
|
-
data['request_headers'] = dict(request.headers)
|
|
244
|
-
if not check_auth(data, config):
|
|
245
|
-
return corsobj.apply_headers(web.json_response({'error': 'unauthorized'}, status=403),request)
|
|
246
|
-
manager.load_plugins()
|
|
247
|
-
return corsobj.apply_headers(web.json_response({'status': 'All plugins reloaded', 'loaded_plugins': list(manager.plugins.keys())}),request)
|
|
248
|
-
|
|
249
|
-
# --- Coroutine Await Helper ---
|
|
250
|
-
async def maybe_async(value):
|
|
251
|
-
return await value if inspect.isawaitable(value) else value
|
|
252
|
-
|
|
253
|
-
# ---- Reload handler ----
|
|
254
|
-
def reload(signum, frame):
|
|
255
|
-
print("Received SIGHUP, restarting...")
|
|
256
|
-
os.execl(sys.executable, sys.executable, *sys.argv)
|
|
257
|
-
|
|
258
|
-
if __name__ == "__main__":
|
|
259
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|