Suen

The Blue Pill - Automation Service v1.0 - Deployment Manual

Version: 1.0 (Production Hardened Release) Objective: To deploy a robust, secure, and production-ready automated attendance service. This guide incorporates critical fixes for concurrency, session management, timezone consistency, and security hardening.

v1.0 Key Features


Part 1: Initial Server Setup

Connect to your Ubuntu 24.04 LTS VPS via SSH with sudo privileges.

1.1. Update System

1
sudo apt update && sudo apt upgrade -y

1.2. Install Core Dependencies

1
sudo apt install -y python3-pip python3.12-venv nginx git sqlite3

1.3. Configure Firewall (UFW)

1
2
3
sudo ufw allow 'OpenSSH'
sudo ufw allow 'Nginx Full'
sudo ufw enable

When prompted, press y and Enter.


Part 2: Backend Application Deployment

2.1. Create Project Directory Structure

1
2
sudo mkdir -p /var/www/seiue-app/{backend,frontend}
cd /var/www/seiue-app/backend

2.2. Create requirements.txt

1
sudo nano requirements.txt

Copy and paste the following content:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Flask
Flask-Cors
gunicorn
requests
pytz
urllib3
APScheduler
SQLAlchemy
cryptography
python-dotenv

2.3. Create .env Configuration File

1
sudo nano .env

Copy and paste the following. You must generate and replace the APP_SECRET_KEY value.

# Generate a new key with: python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
APP_SECRET_KEY=REPLACE_THIS_WITH_YOUR_GENERATED_KEY

# Application Configuration
APP_DB_PATH=/var/www/seiue-app/backend/automation.db
APP_TIMEZONE=Asia/Shanghai

# Attendance Time Windows
AM_WINDOW_START=08:00
AM_WINDOW_END=12:30
PM_WINDOW_START=13:00
PM_WINDOW_END=20:30

Set secure permissions for this file:

1
sudo chmod 600 /var/www/seiue-app/backend/.env

2.4. Create Backend Python Modules (with All Patches Applied)

A. seiue_engine.py (The Core Engine) This is your original, unmodified core logic file, renamed for use as a library.

1
sudo nano seiue_engine.py

Paste the complete, unmodified code below:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
# /var/www/seiue-app/backend/seiue_engine.py
from flask import Flask
import logging
import time
import pytz
import requests
from datetime import datetime, timedelta
from collections import defaultdict
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

# --- Dummy Flask App for maintaining the original code structure ---
# The app object here will not be run by Gunicorn but is used by app.logger.* calls inside the client.
app = Flask(__name__)

# --- SeiueAPIClient and process_day (Your original code) ---
class SeiueAPIClient:
    def __init__(self, username, password):
        self.username, self.password = username, password
        self.session = requests.Session()
        self.BEIJING_TZ = pytz.timezone("Asia/Shanghai")
        retries = Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504])
        self.session.mount("https://", HTTPAdapter(max_retries=retries))
        self.session.headers.update({"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36", "Accept": "application/json, text/plain, */*"})
        self.login_url = "https://passport.seiue.com/login?school_id=3"
        self.authorize_url = "https://passport.seiue.com/authorize"
        self.events_url_template = "https://api.seiue.com/chalk/calendar/personals/{}/events"
        self.students_url_template = "https://api.seiue.com/scms/class/classes/{}/group-members?expand=reflection&member_type=student"
        self.attendance_submit_url_template = "https://api.seiue.com/sams/attendance/class/{}/records/sync"
        self.verification_url = "https://api.seiue.com/sams/attendance/attendances-info"
        self.bearer_token, self.reflection_id = None, None
    def _auth_flow_with_username(self, uname: str) -> bool:
        try:
            self.session.get(self.login_url, headers={"Accept": "text/html"}, timeout=20)
        except requests.RequestException as e: app.logger.debug(f"Preflight GET failed (continuing): {e}")
        try:
            login_resp = self.session.post(self.login_url, headers={"Referer": self.login_url, "Origin": "https://passport.seiue.com"}, data={"email": uname, "password": self.password}, timeout=30, allow_redirects=True)
            app.logger.info(f"Login POST for '{uname}' completed with status {login_resp.status_code}.")
            auth_resp = self.session.post(self.authorize_url, headers={"Referer": "https://chalk-c3.seiue.com/", "Origin": "https://chalk-c3.seiue.com", "X-Requested-With": "XMLHttpRequest"}, data={'client_id': 'GpxvnjhVKt56qTmnPWH1sA', 'response_type': 'token'}, timeout=30)
            if auth_resp.status_code not in (200, 201): app.logger.warning(f"Authorize step for '{uname}' failed with status {auth_resp.status_code}."); return False
            auth_data = auth_resp.json()
            self.bearer_token, self.reflection_id = auth_data.get("access_token"), auth_data.get("active_reflection_id")
            if not (self.bearer_token and self.reflection_id): app.logger.error("Token/Reflection ID missing in auth response."); return False
            self.session.headers.update({"Authorization": f"Bearer {self.bearer_token}", "x-school-id": "3", "x-role": "teacher", "x-reflection-id": str(self.reflection_id)})
            app.logger.info(f"Authentication successful for '{uname}'."); return True
        except (requests.RequestException, Exception) as e: app.logger.error(f"Auth flow for '{uname}' failed with exception: {e}"); return False
    def login_and_get_token(self) -> bool:
        app.logger.info("--- Starting Authentication Flow ---")
        for candidate in {self.username, self.username.upper(), self.username.lower()}:
            if self._auth_flow_with_username(candidate): return True
        app.logger.error("All authentication attempts failed."); return False
    def _with_refresh(self, request_fn):
        resp = request_fn()
        if getattr(resp, "status_code", None) in (401, 403):
            if self.login_and_get_token(): return request_fn()
        return resp
    def get_scheduled_lessons(self, target_date):
        start = target_date.astimezone(self.BEIJING_TZ).strftime('%Y-%m-%d 00:00:00'); end = target_date.astimezone(self.BEIJING_TZ).strftime('%Y-%m-%d 23:59:59')
        resp = self._with_refresh(lambda: self.session.get(self.events_url_template.format(self.reflection_id), params={"start_time": start, "end_time": end, "expand": "address,initiators"}, timeout=30))
        resp.raise_for_status(); lessons = [e for e in (resp.json() or []) if e.get('type') == 'lesson']
        app.logger.info(f"Found {len(lessons)} lessons for {target_date.strftime('%Y-%m-%d')}."); return lessons
    def get_checked_attendance_time_ids(self, lessons):
        if not lessons: return set()
        time_ids, biz_ids = set(), set(); [ (time_ids.add(str(int(l["custom"]["id"]))), biz_ids.add(str(int(l["subject"]["id"])))) for l in lessons if l.get("custom", {}).get("id") and l.get("subject", {}).get("id") ]
        if not time_ids or not biz_ids: return set()
        params = {"attendance_time_id_in": ",".join(sorted(time_ids)), "biz_id_in": ",".join(sorted(biz_ids)), "biz_type_in": "class", "expand": "checked_attendance_time_ids", "paginated": "0"}
        resp = self._with_refresh(lambda: self.session.get(self.verification_url, params=params, timeout=30)); resp.raise_for_status()
        checked = {int(i) for item in (resp.json() or []) for i in item.get("checked_attendance_time_ids", [])}
        app.logger.info(f"Found {len(checked)} already checked IDs."); return checked
    def submit_attendance_for_lesson_group(self, lesson_group):
        if not lesson_group: return True
        course, class_id = lesson_group[0].get('title'), lesson_group[0].get("subject", {}).get("id")
        if not class_id: app.logger.error(f"Missing class_id for '{course}'"); return False
        resp_s = self._with_refresh(lambda: self.session.get(self.students_url_template.format(class_id), timeout=20)); resp_s.raise_for_status()
        students = resp_s.json() or []
        if not students: app.logger.warning(f"No students for class {class_id}"); return True
        records, seen = [], set()
        for l in lesson_group:
            tid = int(l['custom']['id'])
            for s in students:
                oid = int(s['reflection']['id']); key = (tid, oid)
                if key not in seen: records.append({"tag": "正常", "attendance_time_id": tid, "owner_id": oid, "source": "web"}); seen.add(key)
        if not records: app.logger.error(f"No records generated for '{course}'"); return False
        resp_sub = self._with_refresh(lambda: self.session.put(self.attendance_submit_url_template.format(class_id), json={"abnormal_notice_roles": [], "attendance_records": records}, timeout=40))
        if resp_sub.status_code in (409, 422): app.logger.warning(f"Submission for '{course}' rejected ({resp_sub.status_code})"); return "WINDOW_CLOSED"
        resp_sub.raise_for_status(); app.logger.info(f"Submitted {len(records)} records for '{course}'."); return True

def process_day(client, current_date):
    if not client.login_and_get_token():
        return "LOGIN_FAILED", "Authentication failed"
    lessons = client.get_scheduled_lessons(current_date)
    if lessons is None: return "API_ERROR", "Failed to fetch lessons"
    if not lessons: return "NO_CLASS", "No classes scheduled"
    checked = client.get_checked_attendance_time_ids(lessons)
    to_submit = [l for l in lessons if l.get("custom", {}).get("id") and int(l["custom"]["id"]) not in checked]
    if not to_submit: return "NO_ACTION_NEEDED", "All classes already checked"
    groups = defaultdict(list); [groups[l.get("subject",{}).get("id")].append(l) for l in to_submit]
    
    for grp in groups.values(): 
        result = client.submit_attendance_for_lesson_group(grp)
        if result == "WINDOW_CLOSED":
            return "WINDOW_CLOSED", "Submission rejected by API, likely time window is not open."
            
    attempted_ids = {int(l['custom']['id']) for grp in groups.values() for l in grp}
    final_checked = set()
    for _ in range(3):
        time.sleep(5)
        current_checked = client.get_checked_attendance_time_ids(lessons)
        if all(tid in current_checked for tid in attempted_ids): final_checked = current_checked; break
    if all(tid in (final_checked or current_checked) for tid in attempted_ids): return "SUCCESS", "Successfully submitted and verified."
    else: pending = len([tid for tid in attempted_ids if tid not in (final_checked or current_checked)]); return "VERIFY_FAILED", f"{pending} lessons failed final verification."

B. crypto.py (Encryption Utility) This file is new to the architecture but essential for security.

1
sudo nano crypto.py

Paste the complete code below:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# /var/www/seiue-app/backend/crypto.py
import os
from cryptography.fernet import Fernet

_key = os.environ.get("APP_SECRET_KEY")
if not _key:
    raise ValueError("APP_SECRET_KEY not set in .env file")
_fernet = Fernet(_key.encode())

def encrypt(s: str) -> bytes:
    return _fernet.encrypt(s.encode('utf-8'))

def decrypt(b: bytes) -> str:
    return _fernet.decrypt(b).decode('utf-8')

C. models.py (Database Models - Hardened) This version includes fixes for SQLite concurrency (WAL) and correct session management (scoped_session).

1
sudo nano models.py

Paste the complete code below:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# /var/www/seiue-app/backend/models.py
import os
from datetime import datetime, date
from sqlalchemy import (create_engine, event, Column, Integer, String, Boolean, DateTime,
                        LargeBinary, Date)
from sqlalchemy.orm import declarative_base, sessionmaker, scoped_session

DB_PATH = os.environ.get("APP_DB_PATH", "automation.db")

# Create the engine with a busy timeout for concurrency
engine = create_engine(
    f"sqlite:///{DB_PATH}",
    connect_args={"check_same_thread": False, "timeout": 30},
)

# Set PRAGMA for WAL mode on each connection to improve concurrency
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
    cursor = dbapi_connection.cursor()
    cursor.execute("PRAGMA journal_mode=WAL;")
    cursor.execute("PRAGMA synchronous=NORMAL;")
    cursor.close()

# Use a scoped_session for thread-safe session management in the web app
SessionLocal = scoped_session(sessionmaker(bind=engine, autoflush=False, autocommit=False))

Base = declarative_base()

class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    username = Column(String(128), unique=True, nullable=False)
    encrypted_password = Column(LargeBinary, nullable=False)
    is_active = Column(Boolean, default=True)
    created_at = Column(DateTime, default=datetime.utcnow)
    last_am_success_date = Column(Date)
    last_pm_success_date = Column(Date)

class RunLog(Base):
    __tablename__ = "run_logs"
    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, nullable=False)
    period = Column(String(2))
    run_at = Column(DateTime, default=datetime.utcnow)
    status = Column(String(32))
    message = Column(String(2048))

def init_db():
    Base.metadata.create_all(engine)

D. app.py (Flask API - Hardened) This version includes the CORS hardening fix and now works correctly with scoped_session.

1
sudo nano app.py

Paste the complete code below:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# /var/www/seiue-app/backend/app.py
import os
from datetime import datetime
from flask import Flask, request, jsonify
from flask_cors import CORS
from dotenv import load_dotenv
from models import SessionLocal, init_db, User
from crypto import encrypt, decrypt

load_dotenv()

app = Flask(__name__)
# Hardened CORS policy - replace with your actual domain
CORS(app, resources={r"/api/*": {"origins": ["https://seiue.bdfz.net"]}})

@app.before_first_request
def setup():
    init_db()

# This teardown function now works correctly with scoped_session
@app.teardown_appcontext
def shutdown_session(exception=None):
    SessionLocal.remove()

@app.route("/api/enroll", methods=['POST'])
def enroll():
    data = request.get_json(force=True)
    username = data.get("username","").strip()
    password = data.get("password","")
    if not username or not password:
        return jsonify({"ok": False, "error":"Operator ID and Construct Key cannot be empty"}), 400
    
    db_session = SessionLocal()
    try:
        user = db_session.query(User).filter_by(username=username).one_or_none()
        if user:
            user.encrypted_password = encrypt(password)
            user.is_active = True
        else:
            user = User(username=username, encrypted_password=encrypt(password))
            db_session.add(user)
        db_session.commit()
    except Exception as e:
        db_session.rollback()
        app.logger.error(f"Error during enrollment: {e}")
        return jsonify({"ok": False, "error": "An internal database error occurred."}), 500
    finally:
        SessionLocal.remove()
        
    return jsonify({"ok": True, "message": "Enrollment/Update successful! Automation protocol is now active."})

@app.route("/api/delete", methods=['POST'])
def delete_user():
    data = request.get_json(force=True)
    username = data.get("username","").strip()
    password = data.get("password", "")
    if not username or not password:
        return jsonify({"ok": False, "error": "Both ID and Key are required for verification"}), 400

    db_session = SessionLocal()
    try:
        user = db_session.query(User).filter_by(username=username).one_or_none()
        if not user:
            return jsonify({"ok": False, "error": "User not enrolled"}), 404
        
        if decrypt(user.encrypted_password) != password:
            return jsonify({"ok": False, "error": "Construct Key verification failed"}), 401
        
        db_session.delete(user)
        db_session.commit()
    except Exception as e:
        db_session.rollback()
        app.logger.error(f"Error during deletion: {e}")
        return jsonify({"ok": False, "error": "An internal error occurred during deletion."}), 500
    finally:
        SessionLocal.remove()
        
    return jsonify({"ok": True, "message": "User successfully removed. Automation protocol deactivated."})

E. worker.py (Background Worker - Hardened) This version includes fixes for timezone correctness and smarter success semantics.

1
sudo nano worker.py

Paste the complete code below:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
# /var/www/seiue-app/backend/worker.py
import os
import time
import pytz
import random
import logging
from datetime import datetime, date, time as dt_time
from dotenv import load_dotenv
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy.orm import Session
from models import SessionLocal, User, RunLog, init_db
from crypto import decrypt
from seiue_engine import SeiueAPIClient, process_day

load_dotenv()
TZ = pytz.timezone(os.getenv("APP_TIMEZONE", "Asia/Shanghai"))

AM_START = dt_time.fromisoformat(os.getenv("AM_WINDOW_START","08:00"))
AM_END = dt_time.fromisoformat(os.getenv("AM_WINDOW_END","12:30"))
PM_START = dt_time.fromisoformat(os.getenv("PM_WINDOW_START","13:00"))
PM_END = dt_time.fromisoformat(os.getenv("PM_WINDOW_END","20:30"))

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
log = logging.getLogger(__name__)

def is_within_window(start_time: dt_time, end_time: dt_time, now_time: dt_time) -> bool:
    return start_time <= now_time <= end_time

def execute_task_for_user(user: User, period: str, session: Session):
    log.info(f"--- Preparing to run {period} task for user: {user.username} ---")
    
    try:
        password = decrypt(user.encrypted_password)
    except Exception as e:
        log.error(f"Could not decrypt password for user {user.username}: {e}")
        # Log failure and stop for this user
        log_entry = RunLog(user_id=user.id, period=period, status="DECRYPT_FAIL", message=str(e))
        session.add(log_entry)
        session.commit()
        return

    client = SeiueAPIClient(username=user.username, password=password)
    
    beijing_now = datetime.now(TZ)
    beijing_today = beijing_now.date()
    
    status, msg = process_day(client, beijing_now)
    
    log.info(f"Task for {user.username} finished with status: [{status}] - {msg}")
    
    log_entry = RunLog(user_id=user.id, period=period, status=status, message=msg)
    session.add(log_entry)
    
    # Smarter success semantics: treat NO_CLASS/NO_ACTION as success for the day
    if status in ("SUCCESS", "NO_CLASS", "NO_ACTION_NEEDED"):
        if period == "AM":
            user.last_am_success_date = beijing_today
        else:
            user.last_pm_success_date = beijing_today
    
    session.commit()

def main_tick():
    log.info(f"--- Worker tick at {datetime.now(TZ).isoformat()} ---")
    beijing_now = datetime.now(TZ)
    now_time = beijing_now.time()
    today = beijing_now.date()
    
    is_am_window = is_within_window(AM_START, AM_END, now_time)
    is_pm_window = is_within_window(PM_START, PM_END, now_time)

    if not is_am_window and not is_pm_window:
        log.info("Outside of any active window. Sleeping.")
        return

    # Use a plain session for the worker, as it's single-threaded
    with SessionLocal.session_factory() as session:
        active_users = session.query(User).filter_by(is_active=True).all()
        log.info(f"Found {len(active_users)} active users to process.")
        
        for user in active_users:
            try:
                if is_am_window and user.last_am_success_date != today:
                    execute_task_for_user(user, "AM", session)
                
                if is_pm_window and user.last_pm_success_date != today:
                    execute_task_for_user(user, "PM", session)
            except Exception as e:
                log.error(f"An unexpected error occurred while processing user {user.username}: {e}", exc_info=True)
                session.rollback()

            time.sleep(random.uniform(1, 5))

if __name__ == "__main__":
    log.info("Initializing database for worker...")
    init_db()
    log.info("Starting scheduler...")
    scheduler = BlockingScheduler(timezone=TZ)
    scheduler.add_job(main_tick, IntervalTrigger(minutes=10), id='main_tick_job', name='Main Tick Job')
    try:
        scheduler.start()
    except (KeyboardInterrupt, SystemExit):
        log.info("Scheduler stopped.")

2.5. Install Dependencies & Set Permissions

1
2
3
4
5
6
7
cd /var/www/seiue-app/backend
sudo python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
deactivate
sudo chown -R www-data:www-data /var/www/seiue-app

Part 3: Systemd Service Persistence (Hardened)

3.1. API Service (Hardened)

1
sudo nano /etc/systemd/system/seiue-api.service

Paste the complete configuration below:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[Unit]
Description=Seiue v1.0 API Service (Enrollment)
After=network-online.target
Wants=network-online.target

[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/seiue-app/backend
EnvironmentFile=/var/www/seiue-app/backend/.env
ExecStart=/var/www/seiue-app/backend/venv/bin/gunicorn --workers 2 --bind 127.0.0.1:8077 app:app
Restart=always
RestartSec=5

# Hardening Options
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
NoNewPrivileges=true
ReadWritePaths=/var/www/seiue-app/backend

[Install]
WantedBy=multi-user.target

3.2. Worker Service (Hardened)

1
sudo nano /etc/systemd/system/seiue-worker.service

Paste the complete configuration below:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[Unit]
Description=Seiue v1.0 Worker Service (Automation)
After=network-online.target seiue-api.service
Wants=network-online.target

[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/seiue-app/backend
EnvironmentFile=/var/www/seiue-app/backend/.env
ExecStart=/var/www/seiue-app/backend/venv/bin/python worker.py
Restart=always
RestartSec=10

# Hardening Options
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
NoNewPrivileges=true
ReadWritePaths=/var/www/seiue-app/backend

[Install]
WantedBy=multi-user.target

3.3. Enable and Start Services

1
2
3
4
5
sudo systemctl daemon-reload
sudo systemctl enable seiue-api.service
sudo systemctl enable seiue-worker.service
sudo systemctl restart seiue-api.service
sudo systemctl restart seiue-worker.service

Part 4: Frontend & Nginx Configuration

4.1. Create Frontend Enrollment Page

1
sudo nano /var/www/seiue-app/frontend/index.html

Paste the complete HTML code below:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>The Blue Pill - Automation Protocol v1.0</title>
    <link rel="icon" type="image/webp" href="https://img.bdfz.net/20250503004.webp">
    <style>
        :root { --matrix-green: #00ff41; --matrix-green-dark: #008f11; --matrix-bg: #000000; --text-color: #eee; --glow-color: rgba(0, 255, 65, 0.75); }
        body { background-color: var(--matrix-bg); color: var(--text-color); font-family: 'Courier New', Courier, monospace; margin: 0; overflow: hidden; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
        canvas { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: -1; }
        .container { max-width: 500px; width: 90%; background: rgba(10, 25, 10, 0.8); padding: 2.5rem; border-radius: 4px; border: 1px solid var(--matrix-green-dark); box-shadow: 0 0 20px var(--glow-color); z-index: 1; text-align: center; }
        h1 { color: var(--matrix-green); text-shadow: 0 0 10px var(--glow-color); margin-bottom: 0.5rem; font-size: 1.8rem; letter-spacing: 2px; }
        p.subtitle { color: var(--text-color); margin-top: 0; margin-bottom: 2.5rem; font-size: 0.9rem; }
        .form-group { text-align: left; margin-bottom: 1.5rem; }
        label { display: block; font-weight: 600; margin-bottom: 0.5rem; color: var(--matrix-green); }
        input[type="text"], input[type="password"] { width: 100%; padding: 0.8rem; border: 1px solid var(--matrix-green-dark); border-radius: 2px; box-sizing: border-box; font-size: 1rem; background-color: #080808; color: var(--matrix-green); font-family: inherit; }
        input:focus { outline: 1px solid var(--matrix-green); box-shadow: 0 0 10px var(--glow-color); }
        .button-group { display: flex; gap: 1rem; }
        button { background-color: var(--matrix-green); color: var(--matrix-bg); padding: 0.8rem 1.5rem; border: none; border-radius: 2px; cursor: pointer; font-size: 1rem; font-weight: 600; width: 100%; transition: all 0.2s; text-shadow: 0 0 5px #fff; }
        button:hover:not(:disabled) { background-color: #fff; text-shadow: 0 0 5px var(--glow-color); }
        button:disabled { background-color: #555; color: #999; cursor: not-allowed; }
        button.delete { background-color: #900; color: #eee; }
        button.delete:hover:not(:disabled) { background-color: #f00; color: #000; }
        #status-message { border: 1px solid; padding: 1rem; border-radius: 2px; margin-top: 1.5rem; font-weight: 600; font-size: 0.9rem; white-space: pre-wrap; word-wrap: break-word; text-align: left;}
        .hidden { display: none; }
        .status-success { border-color: var(--matrix-green-dark); color: var(--matrix-green); }
        .status-error { border-color: #f00; color: #f00; }
    </style>
</head>
<body>
    <canvas id="matrix-canvas"></canvas>
    <div class="container">
        <h1>THE BLUE PILL</h1>
        <p class="subtitle">AUTOMATION PROTOCOL v1.0</p>
        <form id="enroll-form">
            <div class="form-group">
                <label for="username">Operator ID</label>
                <input type="text" id="username" required autocomplete="username">
            </div>
            <div class="form-group">
                <label for="password">Construct Key</label>
                <input type="password" id="password" required autocomplete="current-password">
            </div>
            <div class="button-group">
                <button type="submit" id="enroll-btn">Activate Automation</button>
                <button type="button" id="delete-btn" class="delete">Deactivate</button>
            </div>
        </form>
        <div id="status-message" class="hidden"></div>
    </div>
    <script>
        const canvas = document.getElementById('matrix-canvas'); const ctx = canvas.getContext('2d'); canvas.width = window.innerWidth; canvas.height = window.innerHeight; const alphabet = 'アァカサタナハマヤャラワガザダバパイィキシチニヒミリヰギジヂビピウゥクスツヌフムユュルグズブプエェケセテネヘメレヱゲゼデベペオォコソトノホモヨョロヲゴゾドボポヴッンABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; const fontSize = 16; const columns = canvas.width / fontSize; const rainDrops = Array.from({ length: columns }).fill(1);
        const draw = () => { ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#0F0'; ctx.font = fontSize + 'px monospace'; rainDrops.forEach((y, ind) => { const text = alphabet.charAt(Math.floor(Math.random() * alphabet.length)); const x = ind * fontSize; ctx.fillText(text, x, y); rainDrops[ind] = (y > canvas.height && Math.random() > 0.975) ? 0 : y + fontSize; }); };
        setInterval(draw, 33);

        const form = document.getElementById('enroll-form');
        const enrollBtn = document.getElementById('enroll-btn');
        const deleteBtn = document.getElementById('delete-btn');
        const statusMessage = document.getElementById('status-message');

        const handleApiRequest = async (endpoint, button) => {
            const originalText = button.textContent;
            enrollBtn.disabled = true; deleteBtn.disabled = true;
            button.textContent = 'Connecting...';
            statusMessage.classList.add('hidden');

            const payload = {
                username: document.getElementById('username').value,
                password: document.getElementById('password').value
            };

            if (!payload.username || !payload.password) {
                showStatus('error', 'Operator ID and Construct Key cannot be empty.');
                enrollBtn.disabled = false; deleteBtn.disabled = false;
                button.textContent = originalText;
                return;
            }

            try {
                const response = await fetch(endpoint, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify(payload)
                });
                const result = await response.json();
                if (!response.ok) throw new Error(result.error || `Server responded with status: ${response.status}`);
                showStatus('success', result.message);
            } catch (error) {
                showStatus('error', error.message);
            } finally {
                enrollBtn.disabled = false; deleteBtn.disabled = false;
                button.textContent = originalText;
            }
        };

        enrollBtn.addEventListener('click', (e) => {
            e.preventDefault();
            handleApiRequest('/api/enroll', enrollBtn);
        });

        deleteBtn.addEventListener('click', () => {
            if (confirm('Are you sure you want to deactivate the automation protocol? This will remove your credentials from the system.')) {
                handleApiRequest('/api/delete', deleteBtn);
            }
        });

        function showStatus(type, message) {
            statusMessage.textContent = `> ${type.toUpperCase()}: ${message}`;
            statusMessage.className = 'status-message';
            statusMessage.classList.add(type === 'success' ? 'status-success' : 'status-error');
            statusMessage.classList.remove('hidden');
        }
    </script>
</body>
</html>

4.2. Configure Nginx (with Security Headers)

1
sudo nano /etc/nginx/sites-available/seiue-app

Paste the complete configuration below:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
server {
    listen 80;
    server_name seiue.bdfz.net; # Replace with your domain
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name seiue.bdfz.net; # Replace with your domain

    # **ACTION REQUIRED**: For Cloudflare Full (Strict) mode, you MUST have a valid certificate here.
    # Use a free Cloudflare Origin CA certificate or Let's Encrypt.
    # ssl_certificate /etc/ssl/certs/your_origin_cert.pem;
    # ssl_certificate_key /etc/ssl/private/your_origin_key.pem;

    # Security Headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "no-referrer-when-downgrade" always;
    add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' https://img.bdfz.net; connect-src 'self';" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    root /var/www/seiue-app/frontend;
    index index.html;

    location / {
        try_files $uri /index.html;
    }
    
    location /api/ {
        proxy_pass http://127.0.0.1:8077;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

4.3. Enable Nginx Site

1
2
sudo ln -sf /etc/nginx/sites-available/seiue-app /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl restart nginx

Part 5: DNS & SSL/TLS (Cloudflare)

  1. DNS: Create an A record for seiue.bdfz.net pointing to your VPS IP, with proxy status Proxied (Orange Cloud).
  2. SSL/TLS: Set encryption mode to Full (Strict). This is the most secure option and requires a valid certificate on your server (as noted in step 4.2). A free Cloudflare Origin CA certificate is the easiest way to satisfy this.

Part 6: Final Testing and Sanity Checks

  1. Visit https://seiue.bdfz.net to enroll a user.
  2. Tail Worker Logs: sudo journalctl -u seiue-worker -f --no-pager
  3. Verify Database:
    1
    2
    3
    4
    5
    
    # Check users table
    sudo sqlite3 /var/www/seiue-app/backend/automation.db "SELECT id,username,is_active,last_am_success_date,last_pm_success_date FROM users;"
    
    # Check latest run logs
    sudo sqlite3 /var/www/seiue-app/backend/automation.db "SELECT user_id,period,run_at,status FROM run_logs ORDER BY id DESC LIMIT 10;"
    
  4. Smoke Test API: curl -I -X POST https://seiue.bdfz.net/api/enroll (This should be blocked by CORS unless run from your domain, which is correct behavior).

This v1.0 manual provides the complete steps to deploy a production-ready, secure, and resilient automation service.