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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pluginserver
3
- Version: 0.8.1
3
+ Version: 0.9.0
4
4
  Summary: Plugin-driven API server
5
5
  Home-page: https://github.com/nicciniamh/pluginserver
6
6
  Author: Nicole Stevens
@@ -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
- if not isinstance(response,web.Response):
88
- response_obj = web.json_response(response,status=code)
89
- return response_obj
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
- def _load_module(self, filepath: str) -> types.ModuleType:
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
- print(f"Loading plugins from {self.plugin_dir}: {plugin_files}")
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
- instance = cls(**kwargs)
59
- print(f"Loaded plugin {cls.__name__}: {instance}")
60
- self.plugins[instance._get_plugin_id()] = instance
61
-
62
- def remove_plugin(self, plugin_id: str):
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
- print(f"No plugin with ID {plugin_id}")
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
- print(f"Exception {type(e)} Unloading plugin - terminate_plugin threw {e}")
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
- print(f"Removing plugin {plugin_id} from module {module_name}")
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
- print(f"No such plugin to reload: {plugin_id}")
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pluginserver
3
- Version: 0.8.1
3
+ Version: 0.9.0
4
4
  Summary: Plugin-driven API server
5
5
  Home-page: https://github.com/nicciniamh/pluginserver
6
6
  Author: Nicole Stevens
@@ -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/cors.py
7
+ plugincore/logjam.py
9
8
  plugincore/pluginmanager.py
10
9
  plugincore/pserv.py
11
10
  pluginserver.egg-info/PKG-INFO
@@ -4,7 +4,7 @@ from setuptools import setup
4
4
 
5
5
  setup(
6
6
  name='pluginserver',
7
- version='0.8.1',
7
+ version='0.9.0',
8
8
  packages=['plugincore'],
9
9
  include_package_data=True,
10
10
  description='Plugin-driven API server',
@@ -1,3 +0,0 @@
1
- # MANIFEST.in
2
- exclude examples/*
3
-
@@ -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