Silicon Valley - Admin commited on
Commit
3f3db0e
1 Parent(s): 362d092

Refactor Dockerfile, hypercorn.toml, and server.py for improved security, static file handling, and session management

Browse files

- Updated Dockerfile to enhance security by creating a non-root user and adjusting file permissions.
- Improved static file handling by copying necessary files and updating the structure for better organization.
- Enhanced hypercorn.toml with server binding options and logging configurations.
- Refactored server.py to streamline session management, including custom exceptions and improved logging.
- Added new WebSocket endpoints for session handling and refined API response structures for better clarity.

Files changed (4) hide show
  1. Dockerfile +11 -6
  2. hypercorn.toml +5 -5
  3. poetry.toml +0 -1
  4. server.py +169 -106
Dockerfile CHANGED
@@ -1,6 +1,7 @@
1
  # Dockerfile optimizado para Hugging Face Spaces
2
  FROM python:3.11-slim
3
 
 
4
  WORKDIR /code
5
 
6
  # Instalar dependencias del sistema
@@ -8,7 +9,7 @@ RUN apt-get update && apt-get install -y \
8
  build-essential \
9
  && rm -rf /var/lib/apt/lists/*
10
 
11
- # Crear usuario no root
12
  RUN adduser --disabled-password --gecos '' appuser && \
13
  mkdir -p /code/static && \
14
  chown -R appuser:appuser /code
@@ -20,12 +21,16 @@ COPY --chown=appuser:appuser requirements.txt .
20
  RUN pip install --no-cache-dir --upgrade pip && \
21
  pip install --no-cache-dir -r requirements.txt
22
 
23
- # Copiar archivos est谩ticos primero
24
  COPY --chown=appuser:appuser static/swagger.html /code/static/
25
  COPY --chown=appuser:appuser openapi.yaml /code/
26
 
27
- # Copiar el resto de los archivos
28
- COPY --chown=appuser:appuser . .
 
 
 
 
29
 
30
  # Variables de entorno para Hugging Face Spaces
31
  ENV PYTHONUNBUFFERED=1
@@ -33,11 +38,11 @@ ENV PYTHONPATH=/code
33
  ENV PORT=7860
34
  ENV PYTHONDONTWRITEBYTECODE=1
35
 
36
- # Cambiar al usuario no root
37
  USER appuser
38
 
39
  # Exponer el puerto que Hugging Face Spaces espera
40
  EXPOSE 7860
41
 
42
- # Comando para ejecutar la aplicaci贸n
43
  CMD ["hypercorn", "--config", "hypercorn.toml", "server:app"]
 
1
  # Dockerfile optimizado para Hugging Face Spaces
2
  FROM python:3.11-slim
3
 
4
+ # Establecer el directorio de trabajo
5
  WORKDIR /code
6
 
7
  # Instalar dependencias del sistema
 
9
  build-essential \
10
  && rm -rf /var/lib/apt/lists/*
11
 
12
+ # Crear un usuario no root para mejorar la seguridad
13
  RUN adduser --disabled-password --gecos '' appuser && \
14
  mkdir -p /code/static && \
15
  chown -R appuser:appuser /code
 
21
  RUN pip install --no-cache-dir --upgrade pip && \
22
  pip install --no-cache-dir -r requirements.txt
23
 
24
+ # Copiar archivos est谩ticos y de configuraci贸n primero
25
  COPY --chown=appuser:appuser static/swagger.html /code/static/
26
  COPY --chown=appuser:appuser openapi.yaml /code/
27
 
28
+ # Copiar el resto de los archivos del proyecto
29
+ COPY --chown=appuser:appuser server.py ./
30
+ COPY --chown=appuser:appuser hypercorn.toml ./
31
+ # Si tienes otros archivos necesarios, c贸pialos aqu铆
32
+ # Por ejemplo:
33
+ # COPY --chown=appuser:appuser other_module.py ./
34
 
35
  # Variables de entorno para Hugging Face Spaces
36
  ENV PYTHONUNBUFFERED=1
 
38
  ENV PORT=7860
39
  ENV PYTHONDONTWRITEBYTECODE=1
40
 
41
+ # Cambiar al usuario no root para evitar privilegios elevados
42
  USER appuser
43
 
44
  # Exponer el puerto que Hugging Face Spaces espera
45
  EXPOSE 7860
46
 
47
+ # Comando para ejecutar la aplicaci贸n usando Hypercorn
48
  CMD ["hypercorn", "--config", "hypercorn.toml", "server:app"]
hypercorn.toml CHANGED
@@ -1,14 +1,14 @@
1
- # Hypercorn configuration file
2
 
3
- # Server binding options
4
  bind = "0.0.0.0:7860"
5
  workers = 1
6
 
7
- # Websocket settings
8
  websocket_ping_interval = 20
9
  websocket_max_message_size = 16777216
10
 
11
- # Logging configuration
12
  accesslog = "-"
13
  errorlog = "-"
14
  loglevel = "INFO"
@@ -20,4 +20,4 @@ graceful_timeout = 10
20
 
21
  # Configuraciones de rendimiento
22
  worker_class = "asyncio"
23
- backlog = 2048
 
1
+ # hypercorn.toml
2
 
3
+ # Opciones de binding del servidor
4
  bind = "0.0.0.0:7860"
5
  workers = 1
6
 
7
+ # Configuraci贸n de WebSockets
8
  websocket_ping_interval = 20
9
  websocket_max_message_size = 16777216
10
 
11
+ # Configuraci贸n de logging
12
  accesslog = "-"
13
  errorlog = "-"
14
  loglevel = "INFO"
 
20
 
21
  # Configuraciones de rendimiento
22
  worker_class = "asyncio"
23
+ backlog = 2048
poetry.toml CHANGED
@@ -1,2 +1 @@
1
- [virtualenvs]
2
  in-project = true
 
 
1
  in-project = true
server.py CHANGED
@@ -1,50 +1,58 @@
1
  # server.py
2
- from dataclasses import dataclass, asdict
3
- import secrets
4
- import logging
5
  import asyncio
 
6
  import json
7
- import yaml
8
- from pathlib import Path
9
- from typing import Tuple
 
 
10
 
11
- from quart import Quart, websocket, request, send_from_directory, redirect
12
  from quart_schema import QuartSchema, validate_request, validate_response
13
- from quart_cors import cors
14
- from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
15
-
16
- from broker import SessionBroker, SessionDoesNotExist, ClientRequest, ClientResponse, ClientError
17
 
18
- # Configuraci贸n
19
- VERSION = "1.0.0" # Versi贸n de la API
20
  TIMEOUT: int = 40
21
  LOG_LEVEL: int = logging.DEBUG
22
- TRUSTED_HOSTS: list[str] = ["127.0.0.1", "10.16.38.136", "10.16.3.13", "10.16.13.73"]
23
 
24
- # Create app
25
  app = Quart(__name__)
26
- app = cors(app,
27
- allow_origin=["https://*.hf.space", "https://*.huggingface.co"],
28
- allow_methods=["GET", "POST", "OPTIONS"],
29
- allow_headers=["Content-Type"],
30
- max_age=3600
31
- )
32
-
33
- # Cargar OpenAPI spec
34
- OPENAPI_PATH = Path(__file__).parent / "openapi.yaml"
35
- with open(OPENAPI_PATH) as f:
36
- openapi_spec = yaml.safe_load(f)
37
-
38
- # Configurar Quart Schema con la especificaci贸n OpenAPI
39
- schema = QuartSchema(app)
40
- schema.update_openapi(openapi_spec)
41
-
42
  app.asgi_app = ProxyHeadersMiddleware(app.asgi_app, trusted_hosts=TRUSTED_HOSTS)
43
  app.logger.setLevel(LOG_LEVEL)
44
 
45
- broker = SessionBroker()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
- # Modelos de datos
48
  @dataclass
49
  class Status:
50
  status: str
@@ -88,124 +96,179 @@ class WriteResponse:
88
  class ErrorResponse:
89
  error: str
90
 
91
- # Rutas API
92
- @app.route('/')
93
- async def root():
94
- return await send_from_directory('static', 'swagger.html')
95
 
96
- @app.route('/docs')
97
- async def docs():
98
- return redirect('/')
 
 
 
99
 
100
- @app.get("/status")
101
- @validate_response(Status)
102
- async def status() -> Status:
103
- return Status(status="OK", version=VERSION)
104
 
105
- @app.websocket('/session')
106
- async def session_handler():
107
- try:
108
- session_id = secrets.token_hex()
109
- app.logger.info(f"{websocket.remote_addr} - NEW SESSION - {session_id}")
110
- await websocket.send_as(Session(session_id=session_id), Session)
111
 
112
- task = None
113
  try:
114
- task = asyncio.ensure_future(_receive(session_id))
115
- async for request in broker.subscribe(session_id):
116
- app.logger.info(f"{websocket.remote_addr} - REQUEST - {session_id} - {json.dumps(asdict(request))}")
117
- await websocket.send_as(request, ClientRequest)
118
- except websockets.exceptions.ConnectionClosed:
119
- app.logger.warning(f"{websocket.remote_addr} - CONNECTION CLOSED - {session_id}")
120
- except Exception as e:
121
- app.logger.error(f"{websocket.remote_addr} - ERROR - {session_id} - {str(e)}")
122
  raise
123
  finally:
124
- if task is not None:
125
- task.cancel()
126
- try:
127
- await task
128
- except asyncio.CancelledError:
129
- pass
130
- except Exception as e:
131
- app.logger.error(f"Error in session handler: {str(e)}")
132
- raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
  async def _receive(session_id: str) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  try:
136
- while True:
137
- response = await websocket.receive_as(ClientResponse)
138
- app.logger.info(f"{websocket.remote_addr} - RESPONSE - {session_id} - {json.dumps(asdict(response))}")
139
- await broker.receive_response(session_id, response)
140
- except websockets.exceptions.ConnectionClosed:
141
- app.logger.warning(f"{websocket.remote_addr} - RECEIVE CONNECTION CLOSED - {session_id}")
142
- except Exception as e:
143
- app.logger.error(f"{websocket.remote_addr} - RECEIVE ERROR - {session_id} - {str(e)}")
144
- raise
145
-
146
- @app.post('/command')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  @validate_request(Command)
148
  @validate_response(CommandResponse, 200)
149
  @validate_response(ErrorResponse, 500)
150
- async def command(data: Command) -> Tuple[CommandResponse | ErrorResponse, int]:
151
  try:
152
- response = CommandResponse(**await broker.send_request(
153
  data.session_id,
154
  {'action': 'command', 'command': data.command},
155
- timeout=TIMEOUT))
 
 
156
  return response, 200
157
  except SessionDoesNotExist:
158
  app.logger.warning(f"{request.remote_addr} - INVALID SESSION ID - {repr(data.session_id)}")
159
- return ErrorResponse('Session does not exist.'), 500
160
  except ClientError as e:
161
- return ErrorResponse(e.message), 500
162
  except asyncio.TimeoutError:
163
- return ErrorResponse('Timeout when waiting for client.'), 500
164
 
165
- @app.post('/read')
166
  @validate_request(Read)
167
  @validate_response(ReadResponse, 200)
168
  @validate_response(ErrorResponse, 500)
169
- async def read(data: Read) -> Tuple[ReadResponse | ErrorResponse, int]:
170
  try:
171
- response = ReadResponse(**await broker.send_request(
172
  data.session_id,
173
  {'action': 'read', 'path': data.path},
174
- timeout=TIMEOUT))
 
 
175
  return response, 200
176
  except SessionDoesNotExist:
177
  app.logger.warning(f"{request.remote_addr} - INVALID SESSION ID - {repr(data.session_id)}")
178
- return ErrorResponse('Session does not exist.'), 500
179
  except ClientError as e:
180
- return ErrorResponse(e.message), 500
181
  except asyncio.TimeoutError:
182
- return ErrorResponse('Timeout when waiting for client.'), 500
183
 
184
- @app.post('/write')
185
  @validate_request(Write)
186
  @validate_response(WriteResponse, 200)
187
  @validate_response(ErrorResponse, 500)
188
- async def write(data: Write) -> Tuple[WriteResponse | ErrorResponse, int]:
189
  try:
190
- response = WriteResponse(**await broker.send_request(
191
  data.session_id,
192
  {'action': 'write', 'path': data.path, 'content': data.content},
193
- timeout=TIMEOUT))
 
 
194
  return response, 200
195
  except SessionDoesNotExist:
196
  app.logger.warning(f"{request.remote_addr} - INVALID SESSION ID - {repr(data.session_id)}")
197
- return ErrorResponse('Session does not exist.'), 500
198
  except ClientError as e:
199
- return ErrorResponse(e.message), 500
200
  except asyncio.TimeoutError:
201
- return ErrorResponse('Timeout when waiting for client.'), 500
202
 
203
- @app.route("/health")
204
- async def health_check():
205
- return {"status": "healthy"}
 
206
 
207
- def run():
208
- app.run(host='0.0.0.0', port=7860)
 
209
 
210
- if __name__ == "__main__":
211
- run()
 
1
  # server.py
2
+
 
 
3
  import asyncio
4
+ import importlib.metadata
5
  import json
6
+ import logging
7
+ import secrets
8
+ import uuid
9
+ from dataclasses import dataclass, asdict
10
+ from typing import Any, AsyncGenerator, Dict, Tuple, Union
11
 
12
+ from quart import Quart, websocket, request, send_from_directory
13
  from quart_schema import QuartSchema, validate_request, validate_response
14
+ from hypercorn.middleware.proxy_headers import ProxyHeadersMiddleware
 
 
 
15
 
16
+ # Configuraciones
 
17
  TIMEOUT: int = 40
18
  LOG_LEVEL: int = logging.DEBUG
19
+ TRUSTED_HOSTS: list[str] = ["127.0.0.1", "172.18.0.3"]
20
 
21
+ # Inicializaci贸n de la aplicaci贸n Quart
22
  app = Quart(__name__)
23
+ QuartSchema(app)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  app.asgi_app = ProxyHeadersMiddleware(app.asgi_app, trusted_hosts=TRUSTED_HOSTS)
25
  app.logger.setLevel(LOG_LEVEL)
26
 
27
+ # Excepciones personalizadas
28
+
29
+ class SessionDoesNotExist(Exception):
30
+ """Error al solicitar un ID de sesi贸n que no existe."""
31
+ pass
32
+
33
+ class SessionAlreadyExists(Exception):
34
+ """Error al crear una sesi贸n con un ID que ya existe."""
35
+ pass
36
+
37
+ class ClientError(Exception):
38
+ """Error cuando el cliente devuelve un error."""
39
+ def __init__(self, message: str):
40
+ super().__init__(message)
41
+ self.message = message
42
+
43
+ # Clases de datos para solicitudes y respuestas
44
+
45
+ @dataclass
46
+ class ClientRequest:
47
+ request_id: str
48
+ data: Any
49
+
50
+ @dataclass
51
+ class ClientResponse:
52
+ request_id: str
53
+ error: bool
54
+ data: Any
55
 
 
56
  @dataclass
57
  class Status:
58
  status: str
 
96
  class ErrorResponse:
97
  error: str
98
 
99
+ # Broker para manejar sesiones y comunicaciones
 
 
 
100
 
101
+ class SessionBroker:
102
+ def __init__(self):
103
+ """Diccionario de session_id -> cola de mensajes pendientes por enviar al cliente"""
104
+ self.sessions: Dict[str, asyncio.Queue] = {}
105
+ """Diccionario de (session_id, request_id) -> futuro de la respuesta del cliente"""
106
+ self.pending_responses: Dict[Tuple[str, str], asyncio.Future] = {}
107
 
108
+ async def send_request(self, session_id: str, data: Any, timeout: int = 60) -> Any:
109
+ if session_id not in self.sessions:
110
+ raise SessionDoesNotExist()
 
111
 
112
+ request_id = str(uuid.uuid4())
113
+ loop = asyncio.get_event_loop()
114
+ future = loop.create_future()
115
+ self.pending_responses[(session_id, request_id)] = future
116
+
117
+ await self.sessions[session_id].put(ClientRequest(request_id=request_id, data=data))
118
 
 
119
  try:
120
+ return await asyncio.wait_for(future, timeout)
121
+ except asyncio.TimeoutError:
 
 
 
 
 
 
122
  raise
123
  finally:
124
+ self.pending_responses.pop((session_id, request_id), None)
125
+
126
+ async def receive_response(self, session_id: str, response: ClientResponse) -> None:
127
+ key = (session_id, response.request_id)
128
+ future = self.pending_responses.pop(key, None)
129
+ if future and not future.done():
130
+ if response.error:
131
+ future.set_exception(ClientError(message=response.data))
132
+ else:
133
+ future.set_result(response.data)
134
+
135
+ async def subscribe(self, session_id: str) -> AsyncGenerator[ClientRequest, None]:
136
+ if session_id in self.sessions:
137
+ raise SessionAlreadyExists()
138
+
139
+ queue = asyncio.Queue()
140
+ self.sessions[session_id] = queue
141
+
142
+ try:
143
+ while True:
144
+ yield await queue.get()
145
+ finally:
146
+ del self.sessions[session_id]
147
+ # Eliminar todas las respuestas pendientes de esta sesi贸n
148
+ keys_to_remove = [key for key in self.pending_responses if key[0] == session_id]
149
+ for key in keys_to_remove:
150
+ future = self.pending_responses.pop(key)
151
+ if not future.done():
152
+ future.set_exception(SessionDoesNotExist())
153
+
154
+ # Instanciaci贸n del broker
155
+ broker = SessionBroker()
156
+
157
+ # Funciones y rutas de la API
158
 
159
  async def _receive(session_id: str) -> None:
160
+ while True:
161
+ try:
162
+ message = await websocket.receive()
163
+ response_data = json.loads(message)
164
+ client_response = ClientResponse(**response_data)
165
+ app.logger.info(f"{websocket.remote_addr} - RESPONSE - {session_id} - {json.dumps(asdict(client_response))}")
166
+ await broker.receive_response(session_id, client_response)
167
+ except Exception as e:
168
+ app.logger.error(f"Error al recibir respuesta: {e}")
169
+ break
170
+
171
+ @app.get("/wss/status")
172
+ @validate_response(Status)
173
+ async def status() -> Status:
174
  try:
175
+ version = importlib.metadata.version('serverwitch-api')
176
+ except importlib.metadata.PackageNotFoundError:
177
+ version = "unknown"
178
+ return Status(status="OK", version=version)
179
+
180
+ @app.websocket('/wss/session')
181
+ async def session_handler():
182
+ session_id = secrets.token_hex()
183
+ app.logger.info(f"{websocket.remote_addr} - NEW SESSION - {session_id}")
184
+ session_message = Session(session_id=session_id)
185
+ await websocket.send(json.dumps(asdict(session_message)))
186
+
187
+ task = asyncio.create_task(_receive(session_id))
188
+ try:
189
+ async for request_data in broker.subscribe(session_id):
190
+ app.logger.info(f"{websocket.remote_addr} - REQUEST - {session_id} - {json.dumps(asdict(request_data))}")
191
+ await websocket.send(json.dumps(asdict(request_data)))
192
+ except SessionAlreadyExists:
193
+ error_response = ErrorResponse(error="Session already exists.")
194
+ await websocket.send(json.dumps(asdict(error_response)))
195
+ finally:
196
+ task.cancel()
197
+ try:
198
+ await task
199
+ except asyncio.CancelledError:
200
+ pass
201
+
202
+ @app.post('/wss/command')
203
  @validate_request(Command)
204
  @validate_response(CommandResponse, 200)
205
  @validate_response(ErrorResponse, 500)
206
+ async def command(data: Command) -> Tuple[Union[CommandResponse, ErrorResponse], int]:
207
  try:
208
+ response_data = await broker.send_request(
209
  data.session_id,
210
  {'action': 'command', 'command': data.command},
211
+ timeout=TIMEOUT
212
+ )
213
+ response = CommandResponse(**response_data)
214
  return response, 200
215
  except SessionDoesNotExist:
216
  app.logger.warning(f"{request.remote_addr} - INVALID SESSION ID - {repr(data.session_id)}")
217
+ return ErrorResponse(error='Session does not exist.'), 500
218
  except ClientError as e:
219
+ return ErrorResponse(error=e.message), 500
220
  except asyncio.TimeoutError:
221
+ return ErrorResponse(error='Timeout when waiting for client.'), 500
222
 
223
+ @app.post('/wss/read')
224
  @validate_request(Read)
225
  @validate_response(ReadResponse, 200)
226
  @validate_response(ErrorResponse, 500)
227
+ async def read(data: Read) -> Tuple[Union[ReadResponse, ErrorResponse], int]:
228
  try:
229
+ response_data = await broker.send_request(
230
  data.session_id,
231
  {'action': 'read', 'path': data.path},
232
+ timeout=TIMEOUT
233
+ )
234
+ response = ReadResponse(**response_data)
235
  return response, 200
236
  except SessionDoesNotExist:
237
  app.logger.warning(f"{request.remote_addr} - INVALID SESSION ID - {repr(data.session_id)}")
238
+ return ErrorResponse(error='Session does not exist.'), 500
239
  except ClientError as e:
240
+ return ErrorResponse(error=e.message), 500
241
  except asyncio.TimeoutError:
242
+ return ErrorResponse(error='Timeout when waiting for client.'), 500
243
 
244
+ @app.post('/wss/write')
245
  @validate_request(Write)
246
  @validate_response(WriteResponse, 200)
247
  @validate_response(ErrorResponse, 500)
248
+ async def write(data: Write) -> Tuple[Union[WriteResponse, ErrorResponse], int]:
249
  try:
250
+ response_data = await broker.send_request(
251
  data.session_id,
252
  {'action': 'write', 'path': data.path, 'content': data.content},
253
+ timeout=TIMEOUT
254
+ )
255
+ response = WriteResponse(**response_data)
256
  return response, 200
257
  except SessionDoesNotExist:
258
  app.logger.warning(f"{request.remote_addr} - INVALID SESSION ID - {repr(data.session_id)}")
259
+ return ErrorResponse(error='Session does not exist.'), 500
260
  except ClientError as e:
261
+ return ErrorResponse(error=e.message), 500
262
  except asyncio.TimeoutError:
263
+ return ErrorResponse(error='Timeout when waiting for client.'), 500
264
 
265
+ # Rutas para servir archivos est谩ticos y OpenAPI
266
+ @app.route('/static/<path:path>')
267
+ async def send_static(path):
268
+ return await send_from_directory('static', path)
269
 
270
+ @app.route('/openapi.yaml')
271
+ async def openapi_spec():
272
+ return await send_from_directory('.', 'openapi.yaml')
273
 
274
+ # No se necesita ejecutar nada aqu铆, ya que Hypercorn se encargar谩 de iniciar la aplicaci贸n