Compare commits

..

9 Commits

Author SHA1 Message Date
d5d964ea8e wip: 2024-01-03 13:40:42 +01:00
8d4882d7c4 wip: 2024-01-03 12:39:16 +01:00
6ca2278838 wip: 2024-01-03 12:25:01 +01:00
8884e924e8 wip: 2024-01-03 12:24:54 +01:00
9eef4b1353 wip: 2024-01-03 01:21:34 +01:00
ebb644fdf2 wip: 2024-01-03 00:48:44 +01:00
c487c769b0 wip: 2024-01-02 13:54:04 +01:00
ff2d8296f3 wip: 2024-01-02 12:31:06 +01:00
ad08f69df1 wip: 2024-01-01 20:41:29 +01:00
8 changed files with 42 additions and 198 deletions

View File

@@ -4,8 +4,6 @@ on:
push: push:
branches: branches:
- "master" - "master"
schedule:
- cron: "0 2 * * 0"
jobs: jobs:
docker: docker:
@@ -16,6 +14,9 @@ jobs:
- -
name: Checkout name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
- -
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2

View File

@@ -1,15 +1,5 @@
import icinga2api import icinga2api
import icinga2api.client import icinga2api.client
from urllib.parse import urlparse
def split_url(url):
parsed = urlparse(url)
http_vhost = parsed.hostname
http_uri = parsed.path or "/"
http_ssl = parsed.scheme == "https"
return http_vhost, http_uri, http_ssl
def _create_client(app): def _create_client(app):
@@ -49,7 +39,7 @@ def _build_service_name(user, async_service_name):
return "{}_async_{}".format(user, async_service_name) return "{}_async_{}".format(user, async_service_name)
def create_service(user, async_service_name, app, webcheck=False): def create_service(user, async_service_name, app):
if not app.config.get("ICINGA_API_URL"): if not app.config.get("ICINGA_API_URL"):
return return
@@ -58,43 +48,20 @@ def create_service(user, async_service_name, app, webcheck=False):
name = _build_service_name(user, async_service_name) name = _build_service_name(user, async_service_name)
host_name = app.config["ASYNC_ICINGA_DUMMY_HOST"] host_name = app.config["ASYNC_ICINGA_DUMMY_HOST"]
# TODO: query service from DB service_config = {
accepted_return_codes = [200, 204] "templates": ["generic-service"],
"attrs": {
"display_name": name,
if webcheck: "check_command": "gateway",
http_vhost, http_uri, http_ssl = split_url(url) "host_name" : host_name,
service_config = { "vars" : {
"templates": ["generic-service"], "host" : "async-icinga.atlantishq.de",
"attrs": { "service_name" : async_service_name,
"display_name": name, "protocol" : "https",
"check_command": "http", "owner" : user
"host_name": host_name,
"vars": {
"http_vhost": http_vhost,
"http_uri": http_uri,
"http_expect": http_expect,
"http_accept_status": accepted_return_codes, # array
"http_ssl": True,
"http_sni": True
}
}
}
else:
service_config = {
"templates": ["generic-service"],
"attrs": {
"display_name": name,
"check_command": "gateway",
"host_name" : host_name,
"vars" : {
"host" : "async-icinga.atlantishq.de",
"service_name" : async_service_name,
"protocol" : "https",
"owner" : user
}
} }
} }
}
# Create the service (name is required in this format) # Create the service (name is required in this format)
service_api_helper_name = "{}!{}".format(host_name, name) service_api_helper_name = "{}!{}".format(host_name, name)

View File

@@ -5,5 +5,3 @@ flask-wtf
waitress waitress
requests requests
icinga2api icinga2api
psycopg2-binary
tzdata

121
server.py
View File

@@ -9,7 +9,6 @@ import datetime
import pytimeparse.timeparse as timeparse import pytimeparse.timeparse as timeparse
import sys import sys
import secrets import secrets
import zoneinfo
import flask_wtf import flask_wtf
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
@@ -23,8 +22,6 @@ from sqlalchemy.sql import func
import sqlalchemy import sqlalchemy
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.sql.expression import func from sqlalchemy.sql.expression import func
from sqlalchemy.exc import IntegrityError
from psycopg2.errors import UniqueViolation
import icingatools import icingatools
import smarttools import smarttools
@@ -32,28 +29,11 @@ import smarttools
app = flask.Flask("Icinga Report In Gateway") app = flask.Flask("Icinga Report In Gateway")
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('SQLALCHEMY_DATABASE_URI') or 'sqlite:///database.sqlite' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.sqlite'
app.config['JSON_CONFIG_FILE'] = 'services.json' app.config['JSON_CONFIG_FILE'] = 'services.json'
app.config['JSON_CONFIG_DIR'] = 'config' app.config['JSON_CONFIG_DIR'] = 'config'
app.config['TIME_ZONE'] = zoneinfo.ZoneInfo(os.getenv("TIME_ZONE", "UTC"))
app.config['AUTH_HEADER'] = os.environ.get("AUTH_HEADER") or "X-Forwarded-Preferred-Username"
db = SQLAlchemy(app) db = SQLAlchemy(app)
from urllib.parse import urlparse
def split_url(url: str):
parsed = urlparse(url)
http_vhost = parsed.hostname
http_uri = parsed.path or "/"
http_ssl = parsed.scheme == "https"
return {
"http_vhost": http_vhost,
"http_uri": http_uri,
"http_ssl": http_ssl
}
class Service(db.Model): class Service(db.Model):
__tablename__ = "services" __tablename__ = "services"
@@ -64,11 +44,6 @@ class Service(db.Model):
owner = Column(String) owner = Column(String)
special_type = Column(String) special_type = Column(String)
# web checks #
url = Column(String)
accepted_codes = Column(String)
http_expect = Column(String)
staticly_configured = Column(Boolean) staticly_configured = Column(Boolean)
class Status(db.Model): class Status(db.Model):
@@ -81,7 +56,7 @@ class Status(db.Model):
info_text = Column(String) info_text = Column(String)
def human_date(self): def human_date(self):
dt = datetime.datetime.fromtimestamp(self.timestamp, app.config["TIME_ZONE"]) dt = datetime.datetime.fromtimestamp(self.timestamp)
return dt.strftime("%d. %B %Y at %H:%M") return dt.strftime("%d. %B %Y at %H:%M")
class SMARTStatus(db.Model): class SMARTStatus(db.Model):
@@ -90,12 +65,12 @@ class SMARTStatus(db.Model):
service = Column(String, primary_key=True) service = Column(String, primary_key=True)
timestamp = Column(Integer, primary_key=True) timestamp = Column(Integer, primary_key=True)
model_number = Column(String, primary_key=True)
power_cycles = Column(Integer) power_cycles = Column(Integer)
temperature = Column(Integer) temperature = Column(Integer)
available_spare = Column(Integer) available_spare = Column(Integer)
unsafe_shutdowns = Column(Integer) unsafe_shutdowns = Column(Integer)
critical_warning = Column(Integer) critical_warning = Column(Integer)
model_number = Column(String)
power_cycles = Column(Integer) power_cycles = Column(Integer)
power_on_hours = Column(Integer) power_on_hours = Column(Integer)
wearleveling_count = Column(Integer) wearleveling_count = Column(Integer)
@@ -116,7 +91,7 @@ def buildReponseDict(status, service=None):
@app.route('/overview') @app.route('/overview')
def overview(): def overview():
user = str(flask.request.headers.get(app.config['AUTH_HEADER'])) user = str(flask.request.headers.get("X-Forwarded-Preferred-Username"))
# query all services # # query all services #
services = db.session.query(Service).filter(Service.owner == user).all() services = db.session.query(Service).filter(Service.owner == user).all()
@@ -186,7 +161,7 @@ def create_entry(form, user):
@app.route("/service-details") @app.route("/service-details")
def service_details(): def service_details():
user = flask.request.headers.get(app.config['AUTH_HEADER']) user = str(flask.request.headers.get("X-Forwarded-Preferred-Username"))
service = flask.request.args.get("service") service = flask.request.args.get("service")
# query service # # query service #
@@ -199,28 +174,8 @@ def service_details():
return ("Services is not owned by {}".format(user)) return ("Services is not owned by {}".format(user))
status_list_query = db.session.query(Status).filter(Status.service==service.service) status_list_query = db.session.query(Status).filter(Status.service==service.service)
status_list = status_list_query.order_by(sqlalchemy.desc(Status.timestamp)).limit(200).all() status_list = status_list_query.order_by(sqlalchemy.desc(Status.timestamp)).limit(20).all()
# build status tupel (repeats, status) #
current_tupel = None
prev_status = None
tupel_list = []
for s in status_list:
# set initial #
if not current_tupel:
current_tupel = [1, s]
tupel_list.append(current_tupel)
continue
if current_tupel[1].info_text == s.info_text:
current_tupel[0] += 1
else:
current_tupel = [1, s]
tupel_list.append(current_tupel)
print(tupel_list)
icinga_link = icingatools.build_icinga_link_for_service(user, service.service, icinga_link = icingatools.build_icinga_link_for_service(user, service.service,
service.staticly_configured, app) service.staticly_configured, app)
@@ -228,13 +183,13 @@ def service_details():
smart_entry = smart_entry_list.order_by(SMARTStatus.timestamp.desc()).first() smart_entry = smart_entry_list.order_by(SMARTStatus.timestamp.desc()).first()
return flask.render_template("service_info.html", service=service, flask=flask, return flask.render_template("service_info.html", service=service, flask=flask,
user=user, status_list=tupel_list, icinga_link=icinga_link, smart=smart_entry) user=user, status_list=status_list, icinga_link=icinga_link, smart=smart_entry)
@app.route("/entry-form", methods=["GET", "POST", "DELETE"]) @app.route("/entry-form", methods=["GET", "POST", "DELETE"])
def create_interface(): def create_interface():
user = flask.request.headers.get(app.config['AUTH_HEADER']) user = str(flask.request.headers.get("X-Forwarded-Preferred-Username"))
# check if is delete # # check if is delete #
operation = flask.request.args.get("operation") operation = flask.request.args.get("operation")
@@ -269,21 +224,9 @@ def create_interface():
return ("Not a valid service to modify", 404) return ("Not a valid service to modify", 404)
if flask.request.method == "POST": if flask.request.method == "POST":
create_entry(form, user)
try:
create_entry(form, user)
except IntegrityError as e:
db.session.rollback()
# TODO: this only works for PG
if isinstance(e.orig, UniqueViolation):
return ("A service with this name already exists (possibly by another user)", 409)
else:
return (f"Error: {e}", 500)
# service created successfully #
service_name = form.service.data or form.service_hidden.data service_name = form.service.data or form.service_hidden.data
return flask.redirect('/service-details?service={}'.format(service_name)) return flask.redirect('/service-details?service={}'.format(service_name))
else: else:
return flask.render_template('add_modify_service.html', form=form, return flask.render_template('add_modify_service.html', form=form,
is_modification=bool(modify_service_name)) is_modification=bool(modify_service_name))
@@ -399,20 +342,10 @@ def default():
text, status = record_and_check_smart(verifiedServiceObj, text, status = record_and_check_smart(verifiedServiceObj,
timestamp, smart) timestamp, smart)
status = Status(service=service, timestamp=timestamp, status=status, status = Status(service=service, timestamp=timestamp, status=status,
info_text=text) info_text=text)
db.session.merge(status) db.session.merge(status)
db.session.commit()
try:
db.session.commit()
except IntegrityError as e:
db.session.rollback()
# TODO: this only works for PG
if isinstance(e.orig, UniqueViolation):
return ("Status at this time already submitted", 409)
else:
return (f"Error: {e}", 500)
return ("", 204) return ("", 204)
else: else:
return ("Method not implemented: {}".format(flask.request.method), 405) return ("Method not implemented: {}".format(flask.request.method), 405)
@@ -463,7 +396,7 @@ def record_and_check_smart(service, timestamp, smart):
return ("SMART report prefail disk (wear_level < 20%)", "CRITICAL") return ("SMART report prefail disk (wear_level < 20%)", "CRITICAL")
# temp max > X # # temp max > X #
if smart_last.temperature > 60: if smart_last.temperature > 50:
return ("Disk Temperatur {}".format(smart_last.temperature), "CRITICAL") return ("Disk Temperatur {}".format(smart_last.temperature), "CRITICAL")
# available_SSD spare # # available_SSD spare #
@@ -479,12 +412,11 @@ def record_and_check_smart(service, timestamp, smart):
spare_change), "WARNING") spare_change), "WARNING")
# unsafe_shutdowns +1 # # unsafe_shutdowns +1 #
if(smart_second_last and if smart_second_last.unsafe_shutdowns - smart_last.unsafe_shutdowns >= 1:
smart_second_last.unsafe_shutdowns - smart_last.unsafe_shutdowns >= 1):
return ("Disk had {} unsafe shutdowns".format(smart_last.unsafe_shutdowns), return ("Disk had {} unsafe shutdowns".format(smart_last.unsafe_shutdowns),
"WARNING") "WARNING")
return ("{} - no problems detected".format(smart_last.model_number), "OK") return ("", "OK")
def create_app(): def create_app():
@@ -515,7 +447,8 @@ def create_app():
config |= json.load(f) config |= json.load(f)
if not config: if not config:
print("No static services configuration found - loading finished.") print("No valid configuration found - need at least one service")
return
for key in config: for key in config:
timeout = timeparse.timeparse(config[key]["timeout"]) timeout = timeparse.timeparse(config[key]["timeout"])
@@ -525,34 +458,12 @@ def create_app():
owner=config[key]["owner"])) owner=config[key]["owner"]))
db.session.commit() db.session.commit()
LOAD_FROM_ENV = [
"ICINGA_API_USER",
"ICINGA_API_PASS",
"ICINGA_API_URL",
"ICINGA_WEB_URL",
"ASYNC_ICINGA_DUMMY_HOST"
]
enforce_load_from_env = os.environ.get("ENFORCE_LOAD_FROM_ENV") or ""
missing = [k for k in LOAD_FROM_ENV if k not in os.environ]
if missing and enforce_load_from_env.lower() == "true":
print(f"ENFORCE_LOAD_FROM_ENV is 'true' but we are missing: {missing} - Abort.")
sys.exit(1)
for key in LOAD_FROM_ENV:
if key in os.environ:
print(f"Loading/Overwriting {key} from environment", file=sys.stderr)
app.config[key] = os.environ[key]
# create icinga host # # create icinga host #
if not app.config.get("ICINGA_API_URL"): if not app.config.get("ICINGA_API_URL"):
print("ICINGA_API_URL not defined. Not connecting Icinga", file=sys.stderr) print("ICINGA_API_URL not defined. Not connecting Icinga", file=sys.stderr)
else: else:
icingatools.create_master_host(app) icingatools.create_master_host(app)
print(f"Expected AUTH_HEADER is: {app.config['AUTH_HEADER']}")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -34,8 +34,6 @@ def normalize(smart):
elif name == "power_on_hours": elif name == "power_on_hours":
target_name = "power_on_hours" target_name = "power_on_hours"
use_raw = True use_raw = True
elif name == "perc_avail_resrvd_space":
target_name = "available_spare"
# check if metric should be recorded # # check if metric should be recorded #
if target_name in ret: if target_name in ret:
@@ -48,7 +46,4 @@ def normalize(smart):
ret[target_name] = value ret[target_name] = value
if ret["critical_warning"] == 0 and "smart_status" in smart:
ret["critical_warning"] = int(not smart["smart_status"]["passed"])
return ret return ret

View File

@@ -1,6 +1,5 @@
body{ body{
background: radial-gradient(ellipse at center, #47918a 0%, #0b3161 100%); background: radial-gradient(ellipse at center, #47918a 0%, #0b3161 100%);
background-attachment: fixed;
color: aliceblue !important; color: aliceblue !important;
} }

View File

@@ -8,11 +8,7 @@
<a href="/service-details?service={{ status.service}}" <a href="/service-details?service={{ status.service}}"
class="col-md-5 m-3 p-2 border rounded overview-tile" class="col-md-5 m-3 p-2 border rounded overview-tile"
{% if status.status == "OK" %} {% if status.status == "OK" %}
{% if status.info_text == "Submitted from Web-Interface" %} style="background-color: lightgreen;"
style="background-color: #5cffe0;"
{% else %}
style="background-color: lightgreen;"
{% endif %}
{% elif status.status == "WARNING" %} {% elif status.status == "WARNING" %}
style="background-color: orange;" style="background-color: orange;"
{% elif status.status == "CRITICAL" %} {% elif status.status == "CRITICAL" %}

View File

@@ -56,8 +56,8 @@
<div class="last-status"> <div class="last-status">
{% if status_list | length > 0 %} {% if status_list | length > 0 %}
<p class="{{ status_list[0][1].status }}"> <p class="{{ status_list[0].status }}">
{{ status_list[0][1].status }} submitted on {{ status_list[0][1].human_date() }} {{ status_list[0].status }} submitted on {{ status_list[0].human_date() }}
</p> </p>
{% else %} {% else %}
<p style="color: darkred;">No status for this service submitted</p> <p style="color: darkred;">No status for this service submitted</p>
@@ -83,13 +83,13 @@
</div> </div>
{% endif %} {% endif %}
{% if service.special_type == "SMART" %} {% if smart %}
<h5 class="clear my-4">Linux</h5> <h5 class="clear my-4">Linux</h5>
{% else %} {% else %}
<h5 class="clear my-4">Curl</h5> <h5 class="clear my-4">Curl</h5>
{% endif %} {% endif %}
<div class="ml-3 example"> <div class="ml-3 example">
{% if service.special_type == "SMART" %} {% if smart %}
SMART='{ <br> SMART='{ <br>
<div class="example-indent"> <div class="example-indent">
"service" : "{{ service.service }}", <br> "service" : "{{ service.service }}", <br>
@@ -115,7 +115,7 @@
{% endif %} {% endif %}
</div> </div>
{% if service.special_type == "SMART" %} {% if smart %}
<h5 class="my-4">Windows</h5> <h5 class="my-4">Windows</h5>
<div class="ml-3 example"> <div class="ml-3 example">
$SMART = @{ <br> $SMART = @{ <br>
@@ -128,22 +128,6 @@
} | ConvertTo-Json<br><br> } | ConvertTo-Json<br><br>
Invoke-RestMethod -TimeoutSec 2 -Uri "{{ flask.request.url_root.replace("http://", "https://" )}}report" -Method Post -Headers @{"Content-Type"="application/json"} -Body $SMART Invoke-RestMethod -TimeoutSec 2 -Uri "{{ flask.request.url_root.replace("http://", "https://" )}}report" -Method Post -Headers @{"Content-Type"="application/json"} -Body $SMART
</div> </div>
<!-- register task example -->
<h5 class="my-4">Windows Task (requires Admin-Powershell)</h5>
<div class="ml-3 example">
$ScriptPath = Join-Path $HOME -ChildPath "smart_monitor.ps1" <br>
echo '$SMART = @{ <br>
<div class="example-indent">
service = "{{ service.service }}"<br>
token = "{{ service.token }}"<br>
status = "N/A"<br>
smart = "$(smartctl -a C: --json | Out-String)"<br>
</div>
} | ConvertTo-Json<br><br>
Invoke-RestMethod -TimeoutSec 2 -Uri "{{ flask.request.url_root.replace("http://", "https://" )}}report" -Method Post -Headers @{"Content-Type"="application/json"} -Body $SMART' &gt; $ScriptPath <br>
schtasks /create /tn SMART_Monitor /tr "powershell.exe -executionpolicy bypass -File '$ScriptPath'" /sc hourly /mo 1 /ru "Administratoren"<br>
echo "Done" <br>
</div>
{% else %} {% else %}
<h5 class="my-4">Python</h5> <h5 class="my-4">Python</h5>
<div class="ml-3 example"> <div class="ml-3 example">
@@ -169,19 +153,12 @@
</thead> </thead>
<tbody class="mt-2"> <tbody class="mt-2">
{% for status_tupel in status_list %} {% for status in status_list %}
<tr> <tr>
<td>{{ status_tupel[1].human_date() }}</td> <td>{{ status.human_date() }}</td>
<td class="{{ status_tupel[1].status }}">{{ status_tupel[1].status }}</td> <td class="{{ status.status }}">{{ status.status }}</td>
<td>{{ status_tupel[1].info_text }}</td> <td>{{ status.info_text }}</td>
</tr> </tr>
{% if status_tupel[0] > 1 %}
<tr>
<td>---</td>
<td><i> + {{ status_tupel[0] }} identical reports</i></td>
<td>|</td>
</tr>
{% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>