Browse Source

commit all the files

main
Michael Pilosov 2 years ago
parent
commit
2b21f69005
  1. 33
      Dockerfile
  2. 17
      README.md
  3. 95
      app.py
  4. 34
      docker-compose.yml
  5. 137
      mc.py
  6. 73
      nginx.conf
  7. 7
      requirements.txt
  8. 96
      serve.py
  9. 36
      templates/index.html
  10. 7
      wsgi.py

33
Dockerfile

@ -0,0 +1,33 @@
FROM debian:bullseye-slim
WORKDIR /opt/program
# Set some environment variables. PYTHONUNBUFFERED keeps Python from buffering our standard
# output stream, which means that logs can be delivered to the user quickly. PYTHONDONTWRITEBYTECODE
# keeps Python from writing the .pyc files which are unnecessary in this case. We also update
# PATH so that the serve program is found when the container is invoked.
ENV PYTHONUNBUFFERED=TRUE
ENV PYTHONDONTWRITEBYTECODE=TRUE
ENV PATH="/opt/program:${PATH}"
# Get basic dependencies
RUN apt-get -y update && apt-get install -y --no-install-recommends \
build-essential \
python3-pip \
python3-dev \
nginx \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# overwrite the link to system python and upgrade pip
RUN ln -s /usr/bin/python3 /usr/bin/python \
&& pip3 install --upgrade pip
COPY requirements.txt /tmp/
RUN pip3 install -r /tmp/requirements.txt && \
rm -rf /root/.cache
# Set up the program in the image
COPY ./ /opt/program
RUN chmod +x /opt/program/serve.py
EXPOSE 9992
CMD ./serve.py

17
README.md

@ -1,3 +1,20 @@
# mc-status-page # mc-status-page
Logs minecraft status to postgres and creates webpage based on current server status using mcstatus. Logs minecraft status to postgres and creates webpage based on current server status using mcstatus.
## Usage
Create `.env` with these keys:
```
DB_HOST=
DB_NAME=
DB_USER=
DB_PASS=
DB_PROJ=
DB_TABLE=
MINE_IP=
```
where `MINE_IP` is the IP Address of your Minecraft server (including port), and the rest correspond to your PostgreSQL database.
Then `docker-compose up -d` will run the site on port 9992.

95
app.py

@ -0,0 +1,95 @@
"""Flask app to display a table with the result of a postgreSQL query"""
from flask import Flask, render_template
import pandas as pd
from dotenv import load_dotenv
import os
import psycopg2
import logging
import sys
import datetime
import warnings
warnings.filterwarnings("ignore")
load_dotenv()
app = Flask(__name__)
def connect_to_database(args):
try:
conn = psycopg2.connect(
host=args.dbhost,
database=args.dbname,
user=args.dbuser,
password=args.dbpass,
sslmode='require',
options=f'project={args.project}'
)
cur = conn.cursor()
except Exception as e:
logging.error('Failed to connect to database: %s', e)
sys.exit(1)
table_name = vars(args).get('table', 'minecraft')
# if args.clean doesn't exist, it will be False
if vars(args).get('clean', False):
# remove table if it exists
try:
cur.execute(f'DROP TABLE IF EXISTS {table_name}')
conn.commit()
except Exception as e:
logging.error('Failed to drop table: %s', e)
sys.exit(1)
# Create table if it doesn't exist
try:
cur.execute(f'CREATE TABLE IF NOT EXISTS {table_name} (timestamp TIMESTAMP, server_name TEXT, version TEXT, protocol INT, players INT, max_players INT, latency INT)')
conn.commit()
except Exception as e:
logging.error('Failed to create table: %s', e)
sys.exit(1)
return conn
@app.route("/")
def index():
# make a fake args object that acts the same way as argparse.Namespace
class Args:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
args = Args(
dbhost=os.environ.get("DB_HOST", "localhost"),
dbname=os.environ.get("DB_NAME", "minecraft"),
dbuser=os.environ.get("DB_USER", "postgres"),
dbpass=os.environ.get("DB_PASS", ""),
project=os.environ.get("DB_PROJ", "default"),
table=os.environ.get("DB_TABLE", "minecraft"),
)
conn = connect_to_database(args)
table_name = vars(args).get("table", "minecraft")
df = pd.read_sql(
f"SELECT * FROM {table_name} ORDER BY timestamp DESC LIMIT 100;", conn
)
online = df["players"].tolist()[0]
df = df[['timestamp', 'players', 'latency']]
# format timestamp
df['timestamp'] = df['timestamp'].dt.strftime('%Y-%m-%d %H:%M:%S')
# if the timestamp is more than 5 minutes old, the server is offline
latest_timestamp = datetime.datetime.strptime(df['timestamp'].tolist()[0], '%Y-%m-%d %H:%M:%S')
if (datetime.datetime.now() - latest_timestamp) > datetime.timedelta(minutes=5):
status = "The server is currently offline. Please try again later."
else:
if online > 0:
status = f"There are currently <b>{ online }</b> players online."
else:
status = "There are currently no players online."
return render_template("index.html", data=df.to_html(justify="center"), status=status)
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=9993)

34
docker-compose.yml

@ -0,0 +1,34 @@
# docker-compose file for serving nginx without SSL
version: '3.7'
services:
nginx:
build: .
container_name: mcstatus-server
restart: always
ports:
- 9992:9992
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./html:/usr/share/nginx/html
- ./logs:/var/log/nginx
environment:
- TZ=America/Denver
env_file:
- .env
status:
build: .
container_name: mcstatus
restart: always
environment:
- TZ=America/Denver
env_file:
- ../.env
command: python mc.py
networks:
default:
external:
name: mcstatus

137
mc.py

@ -0,0 +1,137 @@
"""
Application to ping Minecraft server and write all data to postgresql database.
"""
import os
import sys
import time
import datetime
import logging
import argparse
from mcstatus import JavaServer as MinecraftServer
import psycopg2
from typing import Tuple, Dict
from dotenv import load_dotenv
# read environment variables from .env file
load_dotenv()
# Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s')
# get DB_HOST, DB_PROJ, DB_USER, DB_PASS, and DB_NAME from environment variables
DB_HOST = os.environ.get('DB_HOST', 'localhost')
DB_NAME = os.environ.get('DB_NAME', 'main')
DB_TABLE = os.environ.get('DB_TABLE', 'minecraft')
DB_PROJ = os.environ.get('DB_PROJ', 'default')
DB_USER = os.environ.get('DB_USER', 'user')
DB_PASS = os.environ.get('DB_PASS', 'password')
IP_ADD = os.environ.get('MINE_IP', 'localhost')
def parse_args() -> argparse.Namespace:
# Set up command line arguments
parser = argparse.ArgumentParser(description='Monitor Minecraft server activity and write it to postgresql database.')
parser.add_argument('--dbhost', default=DB_HOST, help='Postgresql database host')
parser.add_argument('--dbname', default=DB_NAME, help='Postgresql database name')
parser.add_argument('--dbuser', default=DB_USER, help='Postgresql database user')
parser.add_argument('--dbpass', default=DB_PASS, help='Postgresql database password')
parser.add_argument('--project', default=DB_PROJ, help='Project name')
parser.add_argument('--table', default=DB_TABLE, help='Table name')
parser.add_argument('--ip', default=IP_ADD, help='IP Address of Minecraft Server')
parser.add_argument('--interval', default=60, type=int, help='Interval in seconds between measurements')
parser.add_argument('--clean', action='store_true', help='Clean database before starting (default: False)')
args = parser.parse_args()
return args
# Connect to database
def connect_to_database(args):
try:
conn = psycopg2.connect(
host=args.dbhost,
database=args.dbname,
user=args.dbuser,
password=args.dbpass,
sslmode='require',
options=f'project={args.project}'
)
cur = conn.cursor()
except Exception as e:
logging.error('Failed to connect to database: %s', e)
sys.exit(1)
table_name = vars(args).get('table', DB_TABLE)
# if args.clean doesn't exist, it will be False
if vars(args).get('clean', False):
# remove table if it exists
try:
cur.execute(f'DROP TABLE IF EXISTS {table_name}')
conn.commit()
except Exception as e:
logging.error('Failed to drop table: %s', e)
sys.exit(1)
# Create table if it doesn't exist
try:
cur.execute(f'CREATE TABLE IF NOT EXISTS {table_name} (timestamp TIMESTAMP, server_name TEXT, version TEXT, protocol INT, players INT, max_players INT, latency INT)')
conn.commit()
except Exception as e:
logging.error('Failed to create table: %s', e)
sys.exit(1)
return conn
def get_minecraft_server_status(host: str) -> Dict[str, str]:
# Get Minecraft server status
try:
server = MinecraftServer.lookup(host)
status = server.status()
logging.info('Successfully got Minecraft server status')
except Exception as e:
logging.error('Failed to get Minecraft server status: %s', e)
sys.exit(1)
# extract status as dictionary
status = status.__dict__
return status
def main(args, conn=None):
cur = conn.cursor()
try:
# Get current time
timestamp = datetime.datetime.now()
# Get Minecraft server status
ip_add = vars(args).get('ip', 'localhost:25571')
status = get_minecraft_server_status(ip_add)
table_name = vars(args).get('table', DB_TABLE)
if status:
# Write to postgres the status
cur.execute(f'INSERT INTO {table_name} (timestamp, server_name, version, protocol, players, max_players, latency) VALUES (%s, %s, %s, %s, %s, %s, %s)', (timestamp, status['raw']['description'], status['raw']['version']['name'], status['raw']['version']['protocol'], status['raw']['players']['online'], status['raw']['players']['max'], status['latency']))
conn.commit()
# Sleep for interval
time.sleep(args.interval)
except KeyboardInterrupt:
logging.info('Exiting...')
sys.exit(0)
except Exception as e:
logging.error('Failed to write to database: %s', e)
# sys.exit(1)
if __name__ == '__main__':
# Main loop
args = parse_args()
conn = connect_to_database(args)
while True:
# if connection to server is lost, reconnect
if not conn:
logging.info('Reconnecting to database...')
conn = connect_to_database(args)
main(args, conn=conn)

73
nginx.conf

@ -0,0 +1,73 @@
worker_processes 1;
daemon off; # Prevent forking
pid /tmp/nginx.pid;
error_log /var/log/nginx/error.log;
events {
# defaults
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log combined;
upstream gunicorn {
server unix:/tmp/gunicorn.sock;
}
server {
listen 9992 deferred;
client_max_body_size 5m;
keepalive_timeout 5;
proxy_read_timeout 1200s;
location ~ ^/ {
#
# Wide-open CORS config for nginx
#
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
#
# Om nom nom cookies
#
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
#
# Custom headers and headers various browsers *should* be OK with but aren't
#
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';
#
# Tell client that this pre-flight info is valid for 20 days
#
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
if ($request_method = 'POST') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';
}
if ($request_method = 'GET') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';
}
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_pass http://gunicorn;
}
location / {
return 404 "{}";
}
}
}

7
requirements.txt

@ -0,0 +1,7 @@
gunicorn
gevent
flask
mcstatus
psycopg2-binary
pandas
python-dotenv

96
serve.py

@ -0,0 +1,96 @@
#!/usr/bin/env python
# This file implements the API shell.
# You don't necessarily need to modify it for various algorithms.
# It starts nginx and gunicorn with the correct configurations and
# then waits until gunicorn exits.
#
# The flask server is specified to be the app object in wsgi.py
#
# We set the following parameters:
#
# Parameter Environment Variable Default Value
# --------- -------------------- -------------
# number of workers MODEL_SERVER_WORKERS the number of CPU cores
# timeout MODEL_SERVER_TIMEOUT 60 seconds
from __future__ import print_function
import multiprocessing
import os
import signal
import subprocess
import sys
cpu_count = multiprocessing.cpu_count()
model_server_timeout = os.environ.get("MODEL_SERVER_TIMEOUT", 60)
model_server_workers = int(os.environ.get("MODEL_SERVER_WORKERS", cpu_count))
def sigterm_handler(nginx_pid, gunicorn_pid):
try:
os.kill(nginx_pid, signal.SIGQUIT)
except OSError:
pass
try:
os.kill(gunicorn_pid, signal.SIGTERM)
except OSError:
pass
sys.exit(0)
def start_server():
print(f"Starting server with {model_server_workers} workers.")
# link the log streams to stdout/err so that
# they will be logged to the container logs
subprocess.check_call(["ln", "-sf", "/dev/stdout", "/var/log/nginx/access.log"])
subprocess.check_call(["ln", "-sf", "/dev/stderr", "/var/log/nginx/error.log"])
nginx = subprocess.Popen(["nginx", "-c", "/opt/program/nginx.conf"])
gunicorn = subprocess.Popen(
[
"gunicorn",
"--timeout",
str(model_server_timeout),
"-k",
"gevent",
"-b",
"unix:/tmp/gunicorn.sock",
"-w",
str(model_server_workers),
"wsgi:app",
]
)
# NOTE: https://docs.python.org/3/library/signal.html#signal.signal
# This sets a handler (second argument) for the signal SIGTERM (first argument)
# in the function `signal.signal` which we want to call (unclear why...)
# The handler should be a callable which takes two arguments (hence `a`, `b`),
# and it is called with two arguments: the signal number and current stack frame.
# Neither of these are relevant to us, so we define a dummy handler which kills
# the two processes we have started (nginx and gunicorn) regardless of `a`/`b`.
# Basically, we are hard-coding "kill these two processes if you get a SIGTERM"
def handler(a, b, c):
return sigterm_handler(nginx.pid, gunicorn.pid)
signal.signal(signal.SIGTERM, handler)
# If either subprocess exits, so do we.
pids = set([nginx.pid, gunicorn.pid])
while True:
pid, _ = os.wait()
if pid in pids:
break
sigterm_handler(nginx.pid, gunicorn.pid)
print("API server exiting")
# The main routine just invokes the start function.
if __name__ == "__main__":
start_server()

36
templates/index.html

@ -0,0 +1,36 @@
<!-- Flask template for displaying the results of a SQL query as a basic website -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Minecraft Server Status</title>
</head>
<body>
<h1>Minecraft Server Status</h1>
<div align="center">
<p style="font-size: 150%;">
{{ status|safe }}
</p>
</div>
<br>
<!-- inline style to center table elements and create spacing in each column-->
<style>
table, th, td {
border: 1px solid black;
border-collapse: collapse;
text-align: center;
padding: 5px;
font-size: 125%;
}
</style>
<div align="center">
{{ data|safe }}
</div>
</body>
</html>

7
wsgi.py

@ -0,0 +1,7 @@
import app as myapp
# This is just a simple wrapper for gunicorn to find your app.
# If you want to change the algorithm file, simply change "main"
# above to the new file.
app = myapp.app
Loading…
Cancel
Save