diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..25bc7aa --- /dev/null +++ b/.env.example @@ -0,0 +1,89 @@ +# Copia este archivo a .env y configura tus ajustes + +# ============================================ +# Configuración del Servidor +# ============================================ + +# Puerto en el que escuchará el servidor +# Por defecto: 6942 +PORT=6942 + +# Token de autenticación para proteger la API +# Si está vacío, la autenticación estará deshabilitada +# Los clientes deben enviar este valor en el header "Authorization" +PASSWORD=clave_ultra_segura + +# Ruta base de la API (sin barra final) +# Todas las rutas de la API comenzarán con este prefijo +# Ejemplo: /smn/v1/weather/location/10821 +BASE_PATH=/smn + +# ============================================ +# Configuración de la API del SMN +# ============================================ + +# URL base de la API del Servicio Meteorológico Nacional +# Esta es la URL upstream a la que se reenviarán las peticiones +# Se declara por un motivo de mantener el proyecto en un sistema orientado a objetos, pero no debe cambiarse +BASE_API=https://ws1.smn.gob.ar + +# URL del sitio web del SMN para extraer el token de autenticación +# Se usa con Selenium para obtener el token JWT automáticamente +# Se declara por un motivo de mantener el proyecto en un sistema orientado a objetos, pero no debe cambiarse +SMN_URL=https://www.smn.gob.ar/ + +# ============================================ +# Configuración de Caché +# ============================================ + +# Directorio donde se almacenarán los archivos de caché +# Se creará automáticamente si no existe +CACHE_DIR=cache + +# Tiempo de vida del caché en minutos +# Las respuestas cacheadas se considerarán válidas durante este tiempo +# Después de este período, se realizará una nueva petición al SMN +CACHE_TTL_MINUTES=60 + +# ============================================ +# Configuración del Token +# ============================================ + +# Archivo donde se guardará el token JWT del SMN +# Este token se renueva automáticamente cuando expira +SMN_TOKEN_FILE=token + +# ============================================ +# Configuración de Logging +# ============================================ + +# Ruta al archivo de logs (opcional) +# Si está vacío, los logs solo se mostrarán en la consola +# Ejemplo: LOG_FILE=logs/opensmn.log +LOG_FILE= + +# ============================================ +# Configuración de Selenium +# ============================================ + +# Ejecutar Selenium en modo headless (sin interfaz gráfica) +# Valores: true o false +# Recomendado: true para servidores sin entorno gráfico, realmente solo se utilizaria false en desarollo +SELENIUM_HEADLESS=true + +# Segundos de espera para que la página del SMN cargue completamente +# Aumenta este valor si tenes una conexión lenta +SELENIUM_WAIT_SECONDS=8 + +# ============================================ +# Configuración de Uvicorn (Servidor ASGI) +# ============================================ + +# Cantidad de workers para el servidor +# Se recomienda mantener en 1 para evitar problemas de sincronización, por ahora no esta preparado para escalar verticalmente +WORKERS=1 + +# Dirección IP en la que escuchará el servidor +# 0.0.0.0 = escucha en todas las interfaces de red +# 127.0.0.1 = solo escucha en localhost (conexiones locales) +HOST=0.0.0.0 diff --git a/.gitignore b/.gitignore index 6fe9248..98b133e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,4 @@ __pycache__ cache -config.ini token -opensmn.egg-info -build +.env \ No newline at end of file diff --git a/README.md b/README.md index 4f6f03b..1190da6 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,78 @@ # OpenSMN -OpenSMN es una API opensource que actúa como proxy del Servicio Meteorológico Nacional (SMN) de Argentina, ofreciendo una forma sencilla, y privada de acceder a sus datos públicos. +OpenSMN es una API opensource que actúa como proxy del Servicio Meteorológico Nacional (SMN) de Argentina, ofreciendo una forma sencilla y privada de acceder a sus datos públicos. -## Setup +## Requisitos -Clona la repo - -~~~ -git clone https://github.com/nixietab/OpenSMN - -cd OpenSMN - -~~~ - -Instala los requerimientos - -~~~ -pip install -r requirements.txt -~~~ - -y ejecuta el servicio! - -~~~ -python3 main.py -~~~ - -Despues de la primera ejecucion se va a crear un config.ini - -Una vez se tenga configurado y corriendo es altamente recomendado correrlo en un reverse proxy como nginx - -## Requierimientos - -- Python 3 +- Python 3.7+ - ChromeDriver + +## Instalación + +1. Clona el repositorio: + +```bash +git clone https://github.com/nixietab/OpenSMN +cd OpenSMN +``` + +2. Instala las dependencias: + +```bash +pip install -r requirements.txt +``` + +3. Configura las variables de entorno: + +```bash +cp .env.example .env +``` + +Edita el archivo `.env` con tu configuración, puedes ver todas las variables en el archivo `.env.example`. + +## Uso + +### Desarrollo + +Para ejecutar el servidor en modo desarrollo: + +```bash +uvicorn server:app --reload --port 6942 +``` + +### Producción + +Para ejecutar el servidor en producción, usa el script `start.sh`: + +```bash +./start.sh +``` + +El script carga automáticamente las variables de entorno desde `.env` y ejecuta la api. + +## Autenticación + +Si configuras un `PASSWORD` en tu `.env`, todas las peticiones deben incluir el header: + +``` +Authorization: clave_ultra_segura +``` + +## Despliegue con Reverse Proxy + +Para producción, es altamente recomendado usar un reverse proxy como nginx: + +```nginx +location /smn/ { + proxy_pass http://localhost:6942/smn/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +} +``` + +## Licencia + +El proyecto de openSMN es de código abierto y está licenciado bajo la licencia MIT. no obstante, interacciona con el Servicio Meteorológico Nacional (SMN) de Argentina, servicio de codigo privativo. + +No hay asociacion con este proyecto con el SMN de ninguna manera diff --git a/__init__.py b/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/healthcheck.py b/healthcheck.py deleted file mode 100644 index 5a52835..0000000 --- a/healthcheck.py +++ /dev/null @@ -1,42 +0,0 @@ -import os -import configparser - -CONFIG_FILE = "config.ini" - -DEFAULT_CONFIG = { - "server": { - "port": "6942", - "password": "debug", - "cache_dir": "cache", - "base_api": "https://ws1.smn.gob.ar", - "log_file": "", - "base_path": "/smn" - } -} - -def ensure_config_exists(): - config = configparser.ConfigParser() - - if not os.path.exists(CONFIG_FILE): - print("[CONFIG] config.ini not found - creating with default values") - config.read_dict(DEFAULT_CONFIG) - with open(CONFIG_FILE, "w") as f: - config.write(f) - else: - config.read(CONFIG_FILE) - changed = False - for section, values in DEFAULT_CONFIG.items(): - if section not in config: - config[section] = values - changed = True - else: - for key, val in values.items(): - if key not in config[section]: - config[section][key] = val - changed = True - if changed: - with open(CONFIG_FILE, "w") as f: - config.write(f) - print("[CONFIG] Missing keys added to config.ini") - - return config \ No newline at end of file diff --git a/main.py b/main.py deleted file mode 100644 index 666a11d..0000000 --- a/main.py +++ /dev/null @@ -1,103 +0,0 @@ -import argparse -import os -import sys -import json -from configparser import ConfigParser -from healthcheck import ensure_config_exists - - -def configparser_to_dict(cp: ConfigParser) -> dict: - d = {} - for section in cp.sections(): - d[section] = {} - for key, val in cp.items(section): - d[section][key] = val - return d - - -def load_config(config_path=None): - if config_path: - if not os.path.exists(config_path): - print(f"[ERROR] Config file not found: {config_path}") - sys.exit(1) - try: - with open(config_path, "r", encoding="utf-8") as f: - data = json.load(f) - return data - except json.JSONDecodeError: - cp = ConfigParser() - cp.read(config_path) - return configparser_to_dict(cp) - except Exception as e: - print(f"[ERROR] Failed to read config file: {e}") - sys.exit(1) - else: - cfg = ensure_config_exists() - if isinstance(cfg, ConfigParser): - return configparser_to_dict(cfg) - return cfg - - -def parse_args(): - parser = argparse.ArgumentParser( - prog="OpenSMN", - description="Run the SMN API with custom configuration." - ) - - parser.add_argument( - "-c", "--config", - type=str, - help="Path to a custom configuration file (INI or JSON)." - ) - - parser.add_argument( - "-p", "--port", - type=int, - help="Override the server port defined in config." - ) - - parser.add_argument( - "--no-auth", - action="store_true", - help="Disable authentication even if password is defined in config." - ) - - parser.add_argument( - "--show-config", - action="store_true", - help="Print the loaded configuration and exit." - ) - - parser.add_argument( - "--base-path", - type=str, - help="Override the base API path." - ) - - return parser.parse_args() - - -def main(): - args = parse_args() - config = load_config(args.config) - server_cfg = config.get("server", {}) - - # apply cli overrides - if args.port: - server_cfg["port"] = str(args.port) - if args.base_path: - server_cfg["base_path"] = args.base_path - if args.no_auth: - server_cfg["password"] = "" - - if args.show_config: - print(json.dumps(config, indent=2, ensure_ascii=False)) - sys.exit(0) - - # run the server - import server - server.run_from_cli(config) - - -if __name__ == "__main__": - main() diff --git a/requirements.txt b/requirements.txt index cf64cac..e557698 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ requests selenium flask +uvicorn[standard] +python-dotenv +asgiref diff --git a/server.py b/server.py index 7a8775c..f24f032 100644 --- a/server.py +++ b/server.py @@ -7,21 +7,30 @@ import sys from datetime import datetime, timedelta from flask import Flask, jsonify, Response, request from urllib.parse import urljoin +from dotenv import load_dotenv import tokenext -from healthcheck import ensure_config_exists + +# Load environment variables +load_dotenv() app = Flask(__name__) -# Global Data -PORT = 6942 -PASSWORD = "" -CACHE_DIR = "cache" -BASE_API = "https://ws1.smn.gob.ar" -LOG_FILE = "" -BASE_PATH = "/smn" -AUTH_ENABLED = False -CACHE_TTL = timedelta(minutes=60) -SMN_TOKEN_FILE = "token" +# Configuration from environment variables +PORT = int(os.getenv("PORT", "6942")) +PASSWORD = os.getenv("PASSWORD", "").strip() +CACHE_DIR = os.getenv("CACHE_DIR", "cache") +BASE_API = os.getenv("BASE_API", "https://ws1.smn.gob.ar") +LOG_FILE = os.getenv("LOG_FILE", "").strip() +BASE_PATH = os.getenv("BASE_PATH", "/smn").strip() +AUTH_ENABLED = PASSWORD != "" +CACHE_TTL = timedelta(minutes=int(os.getenv("CACHE_TTL_MINUTES", "60"))) +SMN_TOKEN_FILE = os.getenv("SMN_TOKEN_FILE", "token") + +# Ensure BASE_PATH is properly formatted +if not BASE_PATH.startswith("/"): + BASE_PATH = "/" + BASE_PATH +if BASE_PATH.endswith("/"): + BASE_PATH = BASE_PATH[:-1] def log(msg: str): if LOG_FILE: @@ -63,7 +72,9 @@ def load_smn_token(): def refresh_smn_token(): log("[TOKEN] Refreshing SMN token...") - ok = tokenext.refresh_token(output_file=SMN_TOKEN_FILE, headless=True, wait_seconds=8) + headless = os.getenv("SELENIUM_HEADLESS", "true").lower() == "true" + wait_seconds = int(os.getenv("SELENIUM_WAIT_SECONDS", "8")) + ok = tokenext.refresh_token(output_file=SMN_TOKEN_FILE, headless=headless, wait_seconds=wait_seconds) if ok: log("[TOKEN] Token refreshed successfully.") else: @@ -157,52 +168,16 @@ def handle_sigint(signum, frame): signal.signal(signal.SIGINT, handle_sigint) +# Initialize on startup +os.makedirs(CACHE_DIR, exist_ok=True) +if not os.path.exists(SMN_TOKEN_FILE): + log("[STARTUP] No token file found, generating a new one.") + refresh_smn_token() -def run_from_cli(config=None): - global PORT, PASSWORD, CACHE_DIR, BASE_API, LOG_FILE, BASE_PATH, AUTH_ENABLED +log(f"[STARTUP] Server configured on port {PORT}") +log(f"[STARTUP] Base path set to '{BASE_PATH}/'") +log(f"[STARTUP] Authentication: {'Enabled' if AUTH_ENABLED else 'Disabled'}") - if config is None: - config = ensure_config_exists() - - server_cfg = config.get("server", {}) - PORT = int(server_cfg.get("port", 6942)) - PASSWORD = server_cfg.get("password", "").strip() - CACHE_DIR = server_cfg.get("cache_dir", "cache") - BASE_API = server_cfg.get("base_api", "https://ws1.smn.gob.ar") - LOG_FILE = server_cfg.get("log_file", "").strip() - BASE_PATH = server_cfg.get("base_path", "/smn").strip() - - if not BASE_PATH.startswith("/"): - BASE_PATH = "/" + BASE_PATH - if BASE_PATH.endswith("/"): - BASE_PATH = BASE_PATH[:-1] - - AUTH_ENABLED = PASSWORD != "" - - os.makedirs(CACHE_DIR, exist_ok=True) - if not os.path.exists(SMN_TOKEN_FILE): - log("[STARTUP] No token file found, generating a new one.") - refresh_smn_token() - - log(f"[STARTUP] Server starting on port {PORT}") - log(f"[STARTUP] Base path set to '{BASE_PATH}/'") - - try: - app.run(host="0.0.0.0", port=PORT) - except KeyboardInterrupt: - log("[SHUTDOWN] Interrupted. server stopped cleanly.") - except Exception as e: - log(f"[ERROR] Unhandled exception: {e}") - finally: - log("[SHUTDOWN] Goodbye.") - -if __name__ == "__main__": - import signal - import sys - - def handle_sigint(signum, frame): - log("[SHUTDOWN] shutting down gracefully...") - sys.exit(0) - - signal.signal(signal.SIGINT, handle_sigint) - run_from_cli() \ No newline at end of file +# Wrap Flask app with ASGI adapter for uvicorn +from asgiref.wsgi import WsgiToAsgi +app = WsgiToAsgi(app) \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index f7354d1..0000000 --- a/setup.py +++ /dev/null @@ -1,36 +0,0 @@ -from setuptools import setup - -with open("README.md", "r", encoding="utf-8") as f: - long_description = f.read() - -setup( - name="opensmn", - version="1.0.0", - author="nix", - description="OpenSMN: Proxy API for Argentina's Servicio Meteorológico Nacional (SMN)", - url="https://github.com/nixietab/OpenSMN", - license="MIT", - py_modules=[ - "main", - "server", - "healthcheck", - "tokenext", - ], - install_requires=[ - "flask", - "requests", - "selenium", - ], - entry_points={ - "console_scripts": [ - "opensmn=main:main", - ], - }, - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Framework :: Flask", - ], - python_requires=">=3.8", -) diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..31fdfc4 --- /dev/null +++ b/start.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Production startup script for OpenSMN + +# Load environment variables from .env file +if [ ! -f .env ]; then + echo "Error: .env file not found!" + echo "Please copy .env.example to .env and configure your settings." + exit 1 +fi + +# Export environment variables +set -a +source .env +set +a + +# Default values if not set in .env +PORT=${PORT:-6942} +WORKERS=${WORKERS:-1} # This can change on the future, but for now, 1 is enough +HOST=${HOST:-0.0.0.0} + +echo "Starting OpenSMN..." +echo "Host: $HOST" +echo "Port: $PORT" +echo "Workers: $WORKERS" + +# Start uvicorn with production settings +exec uvicorn server:app \ + --host "$HOST" \ + --port "$PORT" \ + --workers "$WORKERS" \ + --log-level info \ + --access-log diff --git a/tokenext.py b/tokenext.py index 055faca..5648f50 100644 --- a/tokenext.py +++ b/tokenext.py @@ -1,4 +1,4 @@ -# tokenext.py +import os import re import time from typing import Optional @@ -8,7 +8,7 @@ from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC -DEFAULT_URL = "https://www.smn.gob.ar/" +DEFAULT_URL = os.getenv("SMN_URL", "https://www.smn.gob.ar/") DEFAULT_OUTPUT_FILE = "token"