Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions db/knex_migrations/2025-06-27-0001-add-rtsp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Add new columns and alter 'manual_status' to smallint
// migration file: add_rtsp_fields_to_monitor.js

exports.up = function (knex) {
return knex.schema.alterTable("monitor", function (table) {
table.string("rtsp_username");
table.string("rtsp_password");
Comment on lines +6 to +7
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we already have a few fields for usernames and passwords. lets reuse the general username field that the http monitor used. I don't rememember quite how we named it, but this should be fairly simple to find.

table.string("rtsp_path");
});
};

exports.down = function (knex) {
return knex.schema.alterTable("monitor", function (table) {
table.dropColumn("rtsp_username");
table.dropColumn("rtsp_password");
table.dropColumn("rtsp_path");
});
};
23 changes: 21 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@
"qs": "~6.10.4",
"redbean-node": "~0.3.0",
"redis": "~4.5.1",
"rtsp-client": "^1.4.5",
"semver": "~7.5.4",
"socket.io": "~4.8.0",
"socket.io-client": "~4.8.0",
Expand Down
4 changes: 4 additions & 0 deletions server/model/monitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ class Monitor extends BeanModel {
kafkaProducerSaslOptions: JSON.parse(this.kafkaProducerSaslOptions),
rabbitmqUsername: this.rabbitmqUsername,
rabbitmqPassword: this.rabbitmqPassword,
rtspUsername: this.rtspUsername,
rtspPassword: this.rtspPassword,
rtspPath: this.rtspPath

};
}

Expand Down
68 changes: 68 additions & 0 deletions server/monitor-types/rtsp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
const { MonitorType } = require("./monitor-type");
const RTSPClient = require("rtsp-client");
const { log, UP, DOWN } = require("../../src/util");

class RtspMonitorType extends MonitorType {
name = "rtsp";

/**
* @inheritdoc
*/
async check(monitor, heartbeat, _server) {
const { rtspUsername, rtspPassword, hostname, port, rtspPath } = monitor;

// Construct RTSP URL
let url = `rtsp://${hostname}:${port}${rtspPath}`;
if (rtspUsername && rtspPassword !== undefined) {
url = `rtsp://${rtspUsername}:${rtspPassword}@${hostname}:${port}${rtspPath}`;
}

// Validate URL
if (!url || !url.startsWith("rtsp://")) {
heartbeat.status = DOWN;
heartbeat.msg = "Invalid RTSP URL";
return;
}

const client = new RTSPClient();
client.on("error", (err) => {
log.debug("monitor", `RTSP client emitted error: ${err.message}`);
});

try {
log.debug("monitor", `Connecting to RTSP URL: ${url}`);
await client.connect(url);

const res = await client.describe();
log.debug("monitor", `RTSP DESCRIBE response: ${JSON.stringify(res)}`);

const statusCode = res?.statusCode;
const statusMessage = res?.statusMessage || "Unknown";

if (statusCode === 200) {
heartbeat.status = UP;
heartbeat.msg = "RTSP stream is accessible";
} else if (statusCode === 503) {
heartbeat.status = DOWN;
heartbeat.msg = res.body?.reason || "Service Unavailable";
} else {
heartbeat.status = DOWN;
heartbeat.msg = `${statusCode} - ${statusMessage}`;
}
} catch (error) {
log.debug("monitor", `[${monitor.name}] RTSP check failed: ${error.message}`);
heartbeat.status = DOWN;
heartbeat.msg = `RTSP check failed: ${error.message}`;
} finally {
try {
await client.close();
} catch (closeError) {
log.debug("monitor", `Error closing RTSP client: ${closeError.message}`);
}
}
}
}

module.exports = {
RtspMonitorType,
};
3 changes: 3 additions & 0 deletions server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,9 @@ let needSetup = false;
bean.rabbitmqPassword = monitor.rabbitmqPassword;
bean.conditions = JSON.stringify(monitor.conditions);
bean.manual_status = monitor.manual_status;
bean.rtspUsername = monitor.rtspUsername;
bean.rtspPassword = monitor.rtspPassword;
bean.rtspPath = monitor.rtspPath;

// ping advanced options
bean.ping_numeric = monitor.ping_numeric;
Expand Down
2 changes: 2 additions & 0 deletions server/uptime-kuma-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ class UptimeKumaServer {
UptimeKumaServer.monitorTypeList["mongodb"] = new MongodbMonitorType();
UptimeKumaServer.monitorTypeList["rabbitmq"] = new RabbitMqMonitorType();
UptimeKumaServer.monitorTypeList["manual"] = new ManualMonitorType();
UptimeKumaServer.monitorTypeList["rtsp"] = new RtspMonitorType();

// Allow all CORS origins (polling) in development
let cors = undefined;
Expand Down Expand Up @@ -560,4 +561,5 @@ const { SNMPMonitorType } = require("./monitor-types/snmp");
const { MongodbMonitorType } = require("./monitor-types/mongodb");
const { RabbitMqMonitorType } = require("./monitor-types/rabbitmq");
const { ManualMonitorType } = require("./monitor-types/manual");
const { RtspMonitorType } = require("./monitor-types/rtsp");
const Monitor = require("./model/monitor");
6 changes: 5 additions & 1 deletion src/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1123,5 +1123,9 @@
"Staged Tags for Batch Add": "Staged Tags for Batch Add",
"Clear Form": "Clear Form",
"pause": "Pause",
"Manual": "Manual"
"Manual": "Manual",
"RTSP Username": "RTSP Username",
"RTSP Password": "RTSP Password",
"RTSP Path": "RTSP Path",
"Path": "Path"
}
36 changes: 33 additions & 3 deletions src/pages/EditMonitor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@
<option v-if="!$root.info.isContainer" value="tailscale-ping">
Tailscale Ping
</option>
<option value="rtsp">
RTSP
</option>
</optgroup>
</select>
<i18n-t v-if="monitor.type === 'rabbitmq'" keypath="rabbitmqHelpText" tag="div" class="form-text">
Expand Down Expand Up @@ -300,7 +303,7 @@

<!-- Hostname -->
<!-- TCP Port / Ping / DNS / Steam / MQTT / Radius / Tailscale Ping / SNMP / SMTP only -->
<div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'tailscale-ping' || monitor.type === 'smtp' || monitor.type === 'snmp'" class="my-3">
<div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'tailscale-ping' || monitor.type === 'smtp' || monitor.type === 'snmp' || monitor.type === 'rtsp'" class="my-3">
<label for="hostname" class="form-label">{{ $t("Hostname") }}</label>
<input
id="hostname"
Expand All @@ -315,7 +318,7 @@

<!-- Port -->
<!-- For TCP Port / Steam / MQTT / Radius Type / SNMP -->
<div v-if="monitor.type === 'port' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'smtp' || monitor.type === 'snmp'" class="my-3">
<div v-if="monitor.type === 'port' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'smtp' || monitor.type === 'snmp' || monitor.type === 'rtsp'" class="my-3">
<label for="port" class="form-label">{{ $t("Port") }}</label>
<input id="port" v-model="monitor.port" type="number" class="form-control" required min="0" max="65535" step="1">
</div>
Expand Down Expand Up @@ -515,6 +518,13 @@
</div>
</template>

<template v-if="monitor.type === 'rtsp'">
<div class="my-3">
<label for="rtspPath" class="form-label"> {{ $t("RTSP Path") }}</label>
<input id="rtspPath" v-model="monitor.rtspPath" :placeholder="$t('Path')" type="text" class="form-control">
</div>
</template>

<template v-if="monitor.type === 'radius'">
<div class="my-3">
<label for="radius_username" class="form-label">Radius {{ $t("Username") }}</label>
Expand Down Expand Up @@ -1052,6 +1062,22 @@
</template>
</template>

<template v-if="monitor.type === 'rtsp'">
<h4 class="mt-5 mb-2">{{ $t("Authentication") }}</h4>

<div class="my-3">
<label for="rtspUsername" class="form-label">{{ $t("RTSP Username") }}</label>
<input
id="rtspUsername" v-model="monitor.rtspUsername" :placeholder="$t('Username')" type="text" class="form-control"
>
</div>

<div class="my-3">
<label for="rtspPassword" class="form-label">{{ $t("RTSP Password") }}</label>
<input id="rtspPassword" v-model="monitor.rtspPassword" :placeholder="$t('Password')" type="password" class="form-control">
</div>
</template>

<!-- gRPC Options -->
<template v-if="monitor.type === 'grpc-keyword' ">
<!-- Proto service enable TLS -->
Expand Down Expand Up @@ -1194,7 +1220,11 @@ const monitorDefaults = {
rabbitmqNodes: [],
rabbitmqUsername: "",
rabbitmqPassword: "",
conditions: []
conditions: [],
rtspUsername: "",
rtspPassword: "",
rtspPath: ""

};

export default {
Expand Down
93 changes: 93 additions & 0 deletions test/backend-test/test-rtsp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
const { describe, test } = require("node:test");
const assert = require("node:assert");
const { RtspMonitorType } = require("../../server/monitor-types/rtsp");
const { UP, DOWN, PENDING } = require("../../src/util");
const RTSPClient = require("rtsp-client");

describe("RTSP Monitor", {
skip: !!process.env.CI && (process.platform !== "linux" || process.arch !== "x64"),
}, () => {
test("RTSP stream is accessible", async () => {
const rtspMonitor = new RtspMonitorType();
const monitor = {
hostname: "localhost",
port: 8554,
rtspPath: "/teststream",
rtspUsername: "user",
rtspPassword: "pass",
name: "RTSP Test Monitor",
};

const heartbeat = {
msg: "",
status: PENDING,
};

RTSPClient.prototype.connect = async () => {};
RTSPClient.prototype.describe = async () => ({
statusCode: 200,
statusMessage: "OK",
});
RTSPClient.prototype.close = async () => {};

await rtspMonitor.check(monitor, heartbeat, {});
assert.strictEqual(heartbeat.status, UP);
assert.strictEqual(heartbeat.msg, "RTSP stream is accessible");
});

test("RTSP stream is not accessible", async () => {
const rtspMonitor = new RtspMonitorType();
const monitor = {
hostname: "localhost",
port: 9999,
rtspPath: "/teststream",
rtspUsername: "user",
rtspPassword: "pass",
name: "RTSP Test Monitor",
};

const heartbeat = {
msg: "",
status: PENDING,
};

RTSPClient.prototype.connect = async () => {
throw new Error("Connection refused");
};
RTSPClient.prototype.close = async () => {};

await rtspMonitor.check(monitor, heartbeat, {});
assert.strictEqual(heartbeat.status, DOWN);
assert.match(heartbeat.msg, /RTSP check failed: Connection refused/);
});

test("RTSP stream returns 503 error", async () => {
const rtspMonitor = new RtspMonitorType();
const monitor = {
hostname: "localhost",
port: 8554,
rtspPath: "/teststream",
rtspUsername: "user",
rtspPassword: "pass",
name: "RTSP Test Monitor",
};

const heartbeat = {
msg: "",
status: PENDING,
};

RTSPClient.prototype.connect = async () => {};
RTSPClient.prototype.describe = async () => ({
statusCode: 503,
statusMessage: "Service Unavailable",
body: { reason: "Server overloaded" },
});
RTSPClient.prototype.close = async () => {};

await rtspMonitor.check(monitor, heartbeat, {});
assert.strictEqual(heartbeat.status, DOWN);
assert.strictEqual(heartbeat.msg, "Server overloaded");
});
});

Loading