compose.yml für dockge dazu

This commit is contained in:
rxf
2026-03-11 11:58:49 +01:00
parent 2739d478f6
commit e01ab276b6
10 changed files with 2078 additions and 1301 deletions

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM node:20-alpine
WORKDIR /opt/app
RUN apk add --no-cache tzdata
ENV TZ=Europe/Berlin
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3011
CMD ["npm", "start"]

View File

@@ -1,16 +0,0 @@
FROM node:9-alpine
WORKDIR /opt/app
ADD . /opt/app
RUN apk add --no-cache tzdata
ENV TZ Europe/Berlin
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
RUN touch cmds.sh \
&& echo 'npm start' >>cmds.sh
EXPOSE 3011
CMD sh ./cmds.sh

36
compose.yml Normal file
View File

@@ -0,0 +1,36 @@
# Docker Compose für Production Server mit Traefik
services:
laufschrift:
image: docker.citysensor.de/laufschrift:latest
container_name: laufschrift
restart: unless-stopped
environment:
- NODE_ENV=production
- MQTTHOST=${MQTTHOST:-localhost}
- MQTTPORT=${MQTTPORT:-1883}
- MQTTUSR=${MQTTUSR:-}
- MQTTPWD=${MQTTPWD:-}
- TOPIC=${TOPIC:-sonoff}
- SWITCH_API_TOKEN=${SWITCH_API_TOKEN:-}
labels:
- traefik.enable=true
- traefik.http.routers.laufschrift.entrypoints=http
- traefik.http.routers.laufschrift.rule=Host(`laufschrift.fuerst-stuttgart.de`)
- traefik.http.middlewares.laufschrift-https-redirect.redirectscheme.scheme=https
- traefik.http.routers.laufschrift.middlewares=laufschrift-https-redirect
- traefik.http.routers.laufschrift-secure.entrypoints=https
- traefik.http.routers.laufschrift-secure.rule=Host(`laufschrift.fuerst-stuttgart.de`)
- traefik.http.routers.laufschrift-secure.tls=true
- traefik.http.routers.laufschrift-secure.tls.certresolver=letsencrypt
- traefik.http.routers.laufschrift-secure.service=laufschrift
- traefik.http.services.laufschrift.loadbalancer.server.port=3000
networks:
- proxy
- gitea-internal
networks:
proxy:
name: dockge_default
external: true
gitea-internal:
name: gitea_gitea-internal
external: true

66
deploy.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/bin/bash
# Deploy Script für laufschrift
# Baut das Docker Image und lädt es zu docker.citysensor.de hoch
set -e
# Konfiguration
REGISTRY="docker.citysensor.de"
IMAGE_NAME="laufschrift"
TAG="${TAG:-$(date +%Y%m%d%H%M)}" # default Datum
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${TAG}"
# Build-Datum
BUILD_DATE=$(date +%d.%m.%Y)
echo "=========================================="
echo "Laufschrift Deploy Script"
echo "=========================================="
echo "Registry: ${REGISTRY}"
echo "Image: ${IMAGE_NAME}"
echo "Tag: ${TAG}"
echo "Build-Datum: ${BUILD_DATE}"
echo "=========================================="
echo ""
# 1. Login zur Registry (falls noch nicht eingeloggt)
echo ">>> Login zu ${REGISTRY}..."
docker login "${REGISTRY}"
echo ""
# 2. Multiplatform Builder einrichten (docker-container driver erforderlich)
echo ">>> Richte Multiplatform Builder ein..."
if ! docker buildx inspect multiplatform-builder &>/dev/null; then
docker buildx create --name multiplatform-builder --driver docker-container --bootstrap
fi
docker buildx use multiplatform-builder
echo ""
# 3. Docker Image bauen und pushen (Multiplatform)
echo ">>> Baue Multiplatform Docker Image und pushe zu Registry..."
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg BUILD_DATE="${BUILD_DATE}" \
-t "${FULL_IMAGE}" \
--push \
.
# 4. Keep :latest in sync for simple rollbacks and manual usage.
echo ">>> Tagge das image zusätzlich als :latest ..."
docker buildx imagetools create \
-t "${REGISTRY}/${IMAGE_NAME}:latest" \
"${FULL_IMAGE}"
echo ">>> Build und Push erfolgreich!"
echo ""
echo "=========================================="
echo "✓ Deploy erfolgreich abgeschlossen!"
echo "=========================================="
echo ""
echo "Auf dem Server ausführen:"
echo " docker pull ${FULL_IMAGE}"
echo " docker-compose -f docker-compose.prod.yml up -d"
echo ""

60
deploy_citysensor.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
# Deploys laufschrift to docker.citysensor.de by:
# 1) building and pushing a multi-platform image
# 2) syncing compose/env files to the remote host
# 3) pulling and restarting the service remotely
REMOTE_HOST="${REMOTE_HOST:-docker.citysensor.de}"
REMOTE_USER="${REMOTE_USER:-rxf}"
REMOTE_PORT="${REMOTE_PORT:-22022}"
REMOTE_DIR="${REMOTE_DIR:-/opt/laufschrift}"
IMAGE_REPO="${IMAGE_REPO:-gitea.fuerst-stuttgart.de/admin/laufschrift}"
IMAGE_TAG="${IMAGE_TAG:-$(date +%Y%m%d%H%M)}"
PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}"
DEPLOY_ENV_FILE="${DEPLOY_ENV_FILE:-.env}"
BUILD_PUSH="${BUILD_PUSH:-1}"
FULL_IMAGE="${IMAGE_REPO}:${IMAGE_TAG}"
REMOTE="${REMOTE_USER}@${REMOTE_HOST}"
if [[ ! -f "docker-compose.yml" ]]; then
echo "ERROR: docker-compose.yml not found in current directory."
exit 1
fi
if [[ ! -f "${DEPLOY_ENV_FILE}" ]]; then
echo "ERROR: ${DEPLOY_ENV_FILE} not found."
echo " Create it first, e.g. from .env.example"
exit 1
fi
if [[ "${BUILD_PUSH}" == "1" ]]; then
echo "==> Building and pushing multi-platform image: ${FULL_IMAGE}"
docker buildx build \
--platform "${PLATFORMS}" \
-f Dockerfile_laufschrift \
-t "${FULL_IMAGE}" \
--push \
.
# Keep :latest in sync for simple rollbacks and manual usage.
docker buildx imagetools create \
-t "${IMAGE_REPO}:latest" \
"${FULL_IMAGE}"
fi
echo "==> Syncing compose and env to ${REMOTE}:${REMOTE_DIR}"
ssh -p "${REMOTE_PORT}" "${REMOTE}" "mkdir -p '${REMOTE_DIR}'"
scp -P "${REMOTE_PORT}" docker-compose.yml "${REMOTE}:${REMOTE_DIR}/docker-compose.yml"
scp -P "${REMOTE_PORT}" "${DEPLOY_ENV_FILE}" "${REMOTE}:${REMOTE_DIR}/.env"
echo "==> Deploying ${FULL_IMAGE} on ${REMOTE_HOST}"
ssh -p "${REMOTE_PORT}" "${REMOTE}" "cd '${REMOTE_DIR}' && IMAGE='${FULL_IMAGE}' docker compose pull app && IMAGE='${FULL_IMAGE}' docker compose up -d --no-build --remove-orphans"
echo "==> Deployment finished"
echo " Host: ${REMOTE_HOST}"
echo " Image: ${FULL_IMAGE}"

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
services:
app:
image: docker.citysensor.de/laufschrift:latest
build:
context: .
dockerfile: Dockerfile
platforms:
- linux/amd64
- linux/arm64
ports:
- "3011:3011"
environment:
MQTTHOST: ${MQTTHOST:-localhost}
MQTTPORT: ${MQTTPORT:-1883}
MQTTUSR: ${MQTTUSR:-}
MQTTPWD: ${MQTTPWD:-}
TOPIC: ${TOPIC:-sonoff}
SWITCH_API_TOKEN: ${SWITCH_API_TOKEN:-}
restart: unless-stopped

2787
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,18 @@
{
"name": "laufschrift",
"version": "0.0.0",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "node ./bin/www >>/var/log/laufschrift.log 2>&1"
"start": "node ./bin/www"
},
"dependencies": {
"await-fs": "^1.0.0",
"cookie-parser": "~1.4.3",
"debug": "~2.6.9",
"express": "~4.16.0",
"http-errors": "~1.6.2",
"moment": "^2.24.0",
"morgan": "~1.9.0",
"mqtt": "^2.18.8",
"pug": "^2.0.3"
"cookie-parser": "^1.4.7",
"debug": "^4.4.3",
"express": "^4.22.1",
"http-errors": "^2.0.1",
"moment": "^2.30.1",
"morgan": "^1.10.1",
"mqtt": "^5.15.0",
"pug": "^3.0.3"
}
}

View File

@@ -5,10 +5,16 @@ $(document).ready(function() {
console.log("Es geht los");
const URL = "switch/";
const checktime = 5000000; // alle 30sec Zustand prüfen
const POLL_INTERVAL_MS = 1000;
const queryToken = new URLSearchParams(window.location.search).get('token');
const storedToken = localStorage.getItem('switchApiToken');
const API_TOKEN = queryToken || storedToken || '';
let interval = null;
let status = "";
let tr;
if (queryToken) {
localStorage.setItem('switchApiToken', queryToken);
}
$('#versn').html("V " + VERSION + ' ' + VDATE); // Vesion anzeigen
@@ -19,13 +25,13 @@ $(document).ready(function() {
// sendCommand(URL,"PowerOnState%200"); // OFF bei Power ON
// sendCommand(URL,"PulseTime%20"+(brenndauer+100)); // Brenndauer einstellen
let interval = setInterval(sendTimedCommand, 1000); // alle Sekunde pollen
startPolling();
document.addEventListener('visibilitychange', function (event) {
if (!document.hidden) {
interval = setInterval(sendTimedCommand, 1000);
startPolling();
} else {
clearTimeout(interval); // The page is hidden.
stopPolling();
}
});
@@ -33,8 +39,7 @@ $(document).ready(function() {
sendCommand(URL, "get_status")
$("#schalter").click(function () {
let message;
if (status == 'ON') {
if (status === 'ON') {
console.log("Sende OFF")
sendCommand(URL, "switch_off")
} else {
@@ -45,18 +50,36 @@ $(document).ready(function() {
function sendCommand(url, cmnd) {
console.log("sendCommand", cmnd);
const p = new Promise((resolve, reject) => {
$.getJSON(url + cmnd, function (data, err) { // AJAX Call
if (err != 'success') {
reject(err);
alert("Fehler <br />" + err); // if error, show it
} else {
let endpoint = url + cmnd;
if (API_TOKEN) {
endpoint += '?token=' + encodeURIComponent(API_TOKEN);
}
return new Promise((resolve, reject) => {
$.getJSON(endpoint)
.done(function (data) {
console.log("gekommen: ", data);
resolve(data);
})
.fail(function (_jqXHR, textStatus, errorThrown) {
const msg = errorThrown || textStatus || "Unbekannter Fehler";
reject(msg);
});
});
}
function startPolling() {
if (interval !== null) {
clearInterval(interval);
}
interval = setInterval(sendTimedCommand, POLL_INTERVAL_MS);
}
function stopPolling() {
if (interval !== null) {
clearInterval(interval);
interval = null;
}
});
});
return p;
}
function sendTimedCommand() {
@@ -65,13 +88,12 @@ $(document).ready(function() {
.then(data => {
// console.log("Timed Status= ", st);
status = data.relais;
if (status != 'pending') {
clearTimeout(tr);
if (status == 'ON') {
if (status !== 'pending') {
if (status === 'ON') {
$('#schalter').html('Laufschrift <b>AUS</b> schalten');
$('#status').text('EIN');
$('#laufzeile').addClass('machrot');
if(data.offtime != undefined) {
if (data.offtime !== undefined) {
$('#auszeit').text(data.offtime);
$('#auszeile').show();
}
@@ -82,6 +104,9 @@ $(document).ready(function() {
$('#auszeile').hide();
}
}
})
.catch(err => {
console.log('Statusabfrage fehlgeschlagen:', err);
});
}

View File

@@ -1,118 +1,169 @@
var express = require('express');
let router = express.Router();
let mqtt = require('mqtt');
let moment = require('moment');
const fs = require('await-fs');
var router = express.Router();
var mqtt = require('mqtt');
var moment = require('moment');
var fs = require('fs/promises');
var crypto = require('crypto');
var DEFAULT_BRENNDAUER = 300;
var brenndauer = DEFAULT_BRENNDAUER;
const configName = "config.json"
var MQTTHOST = process.env.MQTTHOST || 'localhost';
var MQTTPORT = process.env.MQTTPORT || 1883;
var MQTTUSR = process.env.MQTTUSR || '';
var MQTTPWD = process.env.MQTTPWD || '';
var TOPIC = process.env.TOPIC || 'sonoff';
var SWITCH_API_TOKEN = process.env.SWITCH_API_TOKEN || '';
var switchOffTimer;
var state = {
connect: 'disconnected',
relais: 'UNKNOWN',
offtime: undefined,
lastError: undefined
};
let brenndauer = 300; // 20sec Relais ON
var client = mqtt.connect('mqtt://' + MQTTHOST + ':' + MQTTPORT, {
username: MQTTUSR,
password: MQTTPWD
});
// MQTT:
let MQTTHOST = process.env.MQTTHOST;
let MQTTPORT = process.env.MQTTPORT;
let MQTTUSR = process.env.MQTTUSR;
let MQTTPWD = process.env.MQTTPWD;
let TOPIC=process.env.TOPIC;
if (MQTTHOST === undefined) { MQTTHOST = 'localhost';}
if (MQTTPORT === undefined) { MQTTPORT = 1883;}
if (MQTTUSR === undefined) { MQTTUSR = "";}
if (MQTTPWD === undefined) { MQTTPWD = "";}
if (TOPIC === undefined) { TOPIC = "sonoff";}
/*
if (MQTTHOST === undefined) { MQTTHOST = 'laufschrift.rexfue.de';}
if (MQTTPORT === undefined) { MQTTPORT = 1883;}
if (MQTTUSR === undefined) { MQTTUSR = "stwLauf";}
if (MQTTPWD === undefined) { MQTTPWD = "37CrNcgP";}
*/
// console.log("H",MQTTHOST," P",MQTTPORT," U",MQTTUSR," p",MQTTPWD);
let tio;
let connected = false;
let relais = 'MIST';
let client = mqtt.connect("mqtt://" + MQTTHOST + ":" + MQTTPORT,{username: MQTTUSR, password: MQTTPWD});
let offtime = "";
let retur = {};
console.log("Start: ", moment().format("YYYY-MM-DD HH:mm"));
console.log('Start: ', moment().format('YYYY-MM-DD HH:mm'));
client.on('connect', function() {
connected = true;
retur.connect = 'connected';
client.subscribe('stat/'+TOPIC+'/POWER');
})
client.on('message',function(topix,message) {
relais = message.toString();
console.log("Status = ",relais);
if (relais == 'OFF') {
delete retur.offtime;
state.connect = 'connected';
state.lastError = undefined;
client.subscribe('stat/' + TOPIC + '/POWER', function(err) {
if (err) {
state.lastError = 'subscribe failed: ' + err.message;
}
retur.relais = relais;
});
});
client.on('reconnect', function() {
retur.connect = 'reconnect';
state.connect = 'reconnect';
});
client.on('offline', function() {
state.connect = 'offline';
});
// Konfig einlesen und entsprechende Variablen einstellen
(async () => {
try{
let json = await fs.readFile('config/config.json','utf8')
js = JSON.parse(json);
if(js.brenndauer != undefined) {
brenndauer = js.brenndauer;
client.on('error', function(err) {
state.lastError = err.message;
});
client.on('message', function(_topic, message) {
state.relais = message.toString();
if (state.relais === 'OFF') {
state.offtime = undefined;
}
console.log(brenndauer);
}catch(err){
console.log(err)
});
function getResponse() {
return {
connect: state.connect,
relais: state.relais,
offtime: state.offtime,
lastError: state.lastError
};
}
})()
// beim start mal den Zustand abfragen
doPublish("");
function doPublish(payload) {
// retur.relais='pending';
client.publish('cmnd/'+TOPIC+'/Power',payload);
if (payload == 'On') {
offtime = moment().add(brenndauer, 's').format("HH.mm");
tio = setTimeout(doPublish, brenndauer * 1000, "Off");
retur.offtime = offtime;
} else if (payload == 'Off') {
clearTimeout(tio);
client.publish('cmnd/' + TOPIC + '/Power', payload, function(err) {
if (err) {
state.lastError = err.message;
}
}
// end MQTT
/* GET switch data */
router.get('/:cmd', function(req, res, next) {
let cmd = req.params.cmd;
let wert = req.query.wert;
if (cmd == 'get_status') {
doPublish("");
} else if (cmd == 'switch_on') {
doPublish("On");
} else if (cmd == 'switch_off') {
doPublish("Off");
} else if (cmd == 'check') {
} else {
retur = { error: "invalid command"};
res.json(retur);
}
console.log("return: ",retur)
res.json(retur);
});
if (payload === 'On') {
clearTimeout(switchOffTimer);
state.offtime = moment().add(brenndauer, 's').format('HH.mm');
switchOffTimer = setTimeout(doPublish, brenndauer * 1000, 'Off');
} else if (payload === 'Off') {
clearTimeout(switchOffTimer);
state.offtime = undefined;
}
}
// Read optional runtime config.
(async function loadConfig() {
try {
var json = await fs.readFile('config/config.json', 'utf8');
var cfg = JSON.parse(json);
if (cfg.brenndauer !== undefined) {
brenndauer = Number(cfg.brenndauer) || DEFAULT_BRENNDAUER;
}
} catch (err) {
state.lastError = 'config load failed: ' + err.message;
}
})();
// Query initial state once at startup.
doPublish('');
function timingSafeEqualString(a, b) {
var aBuf = Buffer.from(String(a));
var bBuf = Buffer.from(String(b));
if (aBuf.length !== bBuf.length) {
return false;
}
return crypto.timingSafeEqual(aBuf, bBuf);
}
function extractRequestToken(req) {
var authHeader = req.get('authorization') || '';
if (authHeader.startsWith('Bearer ')) {
return authHeader.slice(7).trim();
}
var apiKeyHeader = req.get('x-api-key');
if (apiKeyHeader) {
return apiKeyHeader;
}
if (typeof req.query.token === 'string') {
return req.query.token;
}
return '';
}
router.use(function(req, res, next) {
if (!SWITCH_API_TOKEN) {
return next();
}
var providedToken = extractRequestToken(req);
if (!providedToken || !timingSafeEqualString(providedToken, SWITCH_API_TOKEN)) {
return res.status(401).json({ error: 'unauthorized' });
}
return next();
});
router.get('/:cmd', function(req, res) {
var cmd = req.params.cmd;
if (cmd === 'get_status') {
doPublish('');
return res.json(getResponse());
}
if (cmd === 'switch_on') {
doPublish('On');
return res.json(getResponse());
}
if (cmd === 'switch_off') {
doPublish('Off');
return res.json(getResponse());
}
if (cmd === 'check') {
return res.json(getResponse());
}
return res.status(400).json({ error: 'invalid command' });
});
module.exports = router;