[+] 结构调整

This commit is contained in:
acgist
2023-02-22 07:42:40 +08:00
parent a4696a9b9b
commit 6358255458
103 changed files with 1056 additions and 250 deletions

View File

@@ -0,0 +1,158 @@
const os = require("os");
/**
* 配置
*/
module.exports = {
// 服务名称
name: "taoyao-client-media",
// 服务版本
version: "1.0.0",
// 欢迎页面
welcome: `${__dirname}/index.html`,
// 日志级别
logLevel: "DEBUG",
// 录像目录
recordStoragePath: "/data/record",
// 信令服务
https: {
// 信令服务地址端口
listenIp: process.env.MEDIASOUP_LISTEN_IP || "0.0.0.0",
listenPort: process.env.HTTPS_LISTEN_PORT || 9443,
// 信令服务安全配置
username: "taoyao",
password: "taoyao",
// 信令服务证书配置
tls: {
cert: process.env.HTTPS_CERT_PUBLIC_KEY || `${__dirname}/certs/publicKey.pem`,
key: process.env.HTTPS_CERT_PRIVATE_KEY || `${__dirname}/certs/privateKey.pem`,
},
},
// 水印
watermark: {
enabled: false,
text: "taoyao",
posx: 0,
posy: 0,
opacity: 1,
},
// Mediasoup
mediasoup: {
// 配置Worker进程数量
workerSize: Object.keys(os.cpus()).length,
// Workerhttps://mediasoup.org/documentation/v3/mediasoup/api/#WorkerSettings
workerSettings: {
// 级别debug | warn | error | none
logLevel: "warn",
logTags: [
"bwe",
"ice",
"rtp",
"rtx",
"svc",
"dtls",
"info",
"rtcp",
"sctp",
"srtp",
"score",
"message",
"simulcast",
],
rtcMinPort: process.env.MEDIASOUP_MIN_PORT || 40000,
rtcMaxPort: process.env.MEDIASOUP_MAX_PORT || 49999,
},
// Routerhttps://mediasoup.org/documentation/v3/mediasoup/api/#RouterOptions
routerOptions: {
mediaCodecs: [
{
kind: "audio",
mimeType: "audio/opus",
clockRate: 48000,
channels: 2,
},
{
kind: "video",
mimeType: "video/VP8",
clockRate: 90000,
parameters: {
"x-google-start-bitrate": 1000,
},
},
{
kind: "video",
mimeType: "video/VP9",
clockRate: 90000,
parameters: {
"profile-id": 2,
"x-google-start-bitrate": 1000,
},
},
{
kind: "video",
mimeType: "video/h264",
clockRate: 90000,
parameters: {
"packetization-mode": 1,
"profile-level-id": "4d0032",
"level-asymmetry-allowed": 1,
"x-google-start-bitrate": 1000,
},
},
{
kind: "video",
mimeType: "video/h264",
clockRate: 90000,
parameters: {
"packetization-mode": 1,
"profile-level-id": "42e01f",
"level-asymmetry-allowed": 1,
"x-google-start-bitrate": 1000,
},
},
],
},
// WebRtcServerhttps://mediasoup.org/documentation/v3/mediasoup/api/#WebRtcServerOptions
webRtcServerOptions: {
listenInfos: [
{
protocol: "udp",
ip: process.env.MEDIASOUP_LISTEN_IP || "0.0.0.0",
port: 44444,
// 公网地址
announcedIp: process.env.MEDIASOUP_ANNOUNCED_IP,
},
{
protocol: "tcp",
ip: process.env.MEDIASOUP_LISTEN_IP || "0.0.0.0",
port: 44444,
// 公网地址
announcedIp: process.env.MEDIASOUP_ANNOUNCED_IP,
},
],
},
// WebRtcTransporthttps://mediasoup.org/documentation/v3/mediasoup/api/#WebRtcTransportOptions
webRtcTransportOptions: {
listenIps: [
{
ip: process.env.MEDIASOUP_LISTEN_IP || "0.0.0.0",
// 公网地址
announcedIp: process.env.MEDIASOUP_ANNOUNCED_IP,
},
],
initialAvailableOutgoingBitrate: 1000000,
minimumAvailableOutgoingBitrate: 600000,
maxSctpMessageSize: 262144,
maxIncomingBitrate: 1500000,
},
// PlainTransporthttps://mediasoup.org/documentation/v3/mediasoup/api/#PlainTransportOptions
plainTransportOptions: {
listenIp: {
ip: process.env.MEDIASOUP_LISTEN_IP || "0.0.0.0",
// 公网地址
announcedIp: process.env.MEDIASOUP_ANNOUNCED_IP,
},
maxSctpMessageSize: 262144,
},
},
};

View File

@@ -0,0 +1,153 @@
#!/usr/bin/env node
const fs = require("fs");
const ws = require("ws");
const https = require("https");
const mediasoup = require("mediasoup");
const config = require("./Config");
const Signal = require("./Signal");
// 线程名称
process.title = config.name;
// 无效信令终端列表
const clients = [];
// Mediasoup Worker列表
const mediasoupWorkers = [];
// HTTPS server
let httpsServer;
// WebSocket server
let webSocketServer;
// 信令
const signal = new Signal(mediasoupWorkers);
/**
* 启动Mediasoup Worker
*/
async function buildMediasoupWorkers() {
const { workerSize } = config.mediasoup;
console.info("启动Worker", workerSize);
for (let index = 0; index < workerSize; index++) {
// 新建Worker
const worker = await mediasoup.createWorker({
logLevel: config.mediasoup.workerSettings.logLevel,
logTags: config.mediasoup.workerSettings.logTags,
rtcMinPort: Number(config.mediasoup.workerSettings.rtcMinPort),
rtcMaxPort: Number(config.mediasoup.workerSettings.rtcMaxPort),
});
// 监听Worker事件
worker.on("died", () => {
console.warn("Worker停止服务", worker.pid);
setTimeout(() => process.exit(1), 2000);
});
worker.observer.on("close", () => {
console.warn("Worker关闭服务", worker.pid);
});
// 配置WebRTC服务
const webRtcServerOptions = JSON.parse(
JSON.stringify(config.mediasoup.webRtcServerOptions)
);
for (const listenInfo of webRtcServerOptions.listenInfos) {
listenInfo.port = listenInfo.port + mediasoupWorkers.length - 1;
}
const webRtcServer = await worker.createWebRtcServer(webRtcServerOptions);
worker.appData.webRtcServer = webRtcServer;
// 加入Worker队列
mediasoupWorkers.push(worker);
}
}
/**
* 启动信令服务
*/
async function buildSignalServer() {
const tls = {
cert: fs.readFileSync(config.https.tls.cert),
key: fs.readFileSync(config.https.tls.key),
};
// 配置HTTPS Server
httpsServer = https.createServer(tls, (request, response) => {
response.writeHead(200);
response.end(fs.readFileSync(config.welcome));
});
// 配置WebSocket Server
webSocketServer = new ws.Server({ server: httpsServer });
webSocketServer.on("connection", (session) => {
console.info("打开信令通道", session._socket.remoteAddress);
session.datetime = Date.now();
session.authorize = false;
clients.push(session);
session.on("close", (code) => {
console.info("关闭信令通道", session._socket.remoteAddress, code);
});
session.on("error", (error) => {
console.error("信令通道异常", session._socket.remoteAddress, error);
});
session.on("message", (message) => {
console.debug("处理信令消息", message.toString());
try {
signal.on(JSON.parse(message), session);
} catch (error) {
console.error("处理信令消息异常", message.toString(), error);
}
});
});
// 打开监听
httpsServer.listen(
Number(config.https.listenPort),
config.https.listenIp,
() => {
console.info("信令服务启动完成");
}
);
}
/**
* 定时任务
*/
async function buildInterval() {
// 定时打印使用情况
setInterval(async () => {
signal.usage();
}, 300 * 1000);
// 定时清理过期无效终端
setInterval(() => {
let failSize = 0;
let silentSize = 0;
let successSize = 0;
const datetime = Date.now();
for (let index = 0; index < clients.length; index++) {
const session = clients[index];
if (session.authorize) {
clients.splice(index, 1);
successSize++;
index--;
} else if (datetime - session.datetime >= 5000) {
clients.splice(index, 1);
session.close();
failSize++;
index--;
} else {
silentSize++;
}
}
console.info("定时清理无效信令终端(无效|静默|成功|现存)", failSize, silentSize, successSize, clients.length);
}, 60 * 1000);
}
/**
* 启动方法
*/
async function main() {
console.info("桃之夭夭,灼灼其华。")
console.info("之子于归,宜其室家。")
console.info("开始启动", config.name);
await buildMediasoupWorkers();
await buildSignalServer();
await buildInterval();
console.info("启动完成", config.name);
}
// 启动服务
main();

View File

@@ -0,0 +1,396 @@
const config = require("./Config");
/**
* 信令协议
*/
const protocol = {
// 当前索引
index: 100000,
// 最小索引
minIndex: 100000,
// 最大索引
maxIndex: 999999,
/**
* @returns 索引
*/
buildId: function () {
if (this.index++ >= this.maxIndex) {
this.index = this.minIndex;
}
return Date.now() + "" + this.index;
},
/**
* 生成信令消息
*
* @param {*} signal 信令标识
* @param {*} roomId 房间标识
* @param {*} body 信令消息
* @param {*} id ID
*
* @returns 信令消息
*/
buildMessage: function (signal, roomId, body = {}, id) {
const message = {
header: {
v: config.version,
id: id || this.buildId(),
signal: signal,
},
body: {
roomId,
...body,
},
};
return message;
},
};
/**
* 房间
*/
class Room {
// 是否关闭
close = false;
// 房间ID
roomId = null;
// 信令
signal = null;
// WebRTCServer
webRtcServer = null;
// 路由
mediasoupRouter = null;
// 音频监控
audioLevelObserver = null;
// 音频监控
activeSpeakerObserver = null;
// 通道
transports = new Map();
// 生产者
producers = new Map();
// 消费者
consumers = new Map();
// 数据通道生产者
dataProducers = new Map();
// 数据通道消费者
dataConsumers = new Map();
constructor({
roomId,
signal,
webRtcServer,
mediasoupRouter,
audioLevelObserver,
activeSpeakerObserver,
}) {
this.close = false;
this.roomId = roomId;
this.networkThrottled = false;
this.signal = signal;
this.webRtcServer = webRtcServer;
this.mediasoupRouter = mediasoupRouter;
this.audioLevelObserver = audioLevelObserver;
this.activeSpeakerObserver = activeSpeakerObserver;
this.handleAudioLevelObserver();
this.handleActiveSpeakerObserver();
}
handleAudioLevelObserver() {
// 声音
this.audioLevelObserver.on("volumes", (volumes) => {
for (const value of volumes) {
const { producer, volume } = value;
this.signal.push(
protocol.buildMessage("audio::active::speaker", this.roomId, {
peerId: producer.appData.peerId,
volume: volume,
})
);
}
});
// 静音
this.audioLevelObserver.on("silence", () => {
this.signal.push(
protocol.buildMessage("audio::active::speaker", this.roomId, {
peerId: null,
})
);
});
}
handleActiveSpeakerObserver() {
this.activeSpeakerObserver.on("dominantspeaker", (dominantSpeaker) => {
console.debug("dominantspeaker", dominantSpeaker.producer.id);
});
}
usage() {
console.info("房间标识", this.roomId);
console.info("房间通道数量", this.transports.size);
console.info("房间生产者数量", this.producers.size);
console.info("房间消费者数量", this.consumers.size);
console.info("房间数据生产者数量", this.dataProducers.size);
console.info("房间数据消费者数量", this.dataConsumers.size);
}
close() {
this.close = true;
if (this.mediasoupRouter) {
this.mediasoupRouter.close();
}
}
}
/**
* 信令
*/
class Signal {
// 房间列表
rooms = new Map();
// 信令终端列表
clients = [];
// Worker列表
mediasoupWorkers = [];
// Worker索引
nextMediasoupWorkerIndex = 0;
constructor(mediasoupWorkers) {
this.mediasoupWorkers = mediasoupWorkers;
}
/**
* 处理事件
*
* @param {*} message 消息
* @param {*} session websocket
*/
on(message, session) {
// 授权验证
if (!session.authorize) {
if (
message?.header?.signal === "media::register" &&
message?.body?.username === config.https.username &&
message?.body?.password === config.https.password
) {
console.debug("授权成功", session._socket.remoteAddress);
this.clients.push(session);
session.authorize = true;
message.code = "0000";
message.message = "授权成功";
message.body.username = undefined;
message.body.password = undefined;
} else {
console.warn("授权失败", session._socket.remoteAddress);
message.code = "3401";
message.message = "授权失败";
}
this.push(message, session);
return;
}
// 处理信令
const body = message.body;
switch (message.header.signal) {
case "media::router::rtp::capabilities":
this.mediaRouterRtpCapabilities(session, message, body);
break;
case "media::transport::webrtc::connect":
this.mediaTransportWebrtcConnect(session, message, body);
break;
case "media::transport::webrtc::create":
this.mediaTransportWebrtcCreate(session, message, body);
break;
case "room::create":
this.roomCreate(session, message, body);
break;
}
}
/**
* 通知信令
*
* @param {*} message 消息
* @param {*} session 信令通道
*/
push(message, session) {
if (session) {
try {
session.send(JSON.stringify(message));
} catch (error) {
console.error(
"通知信令失败",
session._socket.remoteAddress,
message,
error
);
}
} else {
this.clients.forEach((session) => this.push(message, session));
}
}
/**
* 打印日志
*/
async usage() {
for (const worker of this.mediasoupWorkers) {
const usage = await worker.getResourceUsage();
console.info("Worker使用情况", worker.pid, usage);
}
console.info("路由数量", this.mediasoupWorkers.length);
console.info("房间数量", this.rooms.size);
Array.from(this.rooms.values()).forEach(room => room.usage());
}
/**
* @returns 下个Meidasoup Worker
*/
nextMediasoupWorker() {
const worker = this.mediasoupWorkers[this.nextMediasoupWorkerIndex];
if (++this.nextMediasoupWorkerIndex === this.mediasoupWorkers.length) {
this.nextMediasoupWorkerIndex = 0;
}
return worker;
}
/**
* 路由RTP能力信令
*
* @param {*} session 信令通道
* @param {*} message 消息
* @param {*} body 消息主体
*/
mediaRouterRtpCapabilities(session, message, body) {
const { roomId } = body;
const room = this.rooms.get(roomId);
message.body = room.mediasoupRouter.rtpCapabilities;
this.push(message, session);
}
async mediaTransportWebrtcConnect(session, message, body) {
const { roomId, transportId, dtlsParameters } = body;
const room = this.rooms.get(roomId);
const transport = room.transports.get(transportId);
if (!transport) {
throw new Error(`transport with id "${transportId}" not found`);
}
await transport.connect({ dtlsParameters });
message.body = { roomId };
this.push(message, session);
}
/**
* @param {*} session 信令通道
* @param {*} message 消息
* @param {*} body 消息主体
*/
async mediaTransportWebrtcCreate(session, message, body) {
const self = this;
const { roomId, forceTcp, producing, consuming, sctpCapabilities } = body;
const webRtcTransportOptions = {
...config.mediasoup.webRtcTransportOptions,
appData: { producing, consuming },
enableSctp: Boolean(sctpCapabilities),
numSctpStreams: (sctpCapabilities || {}).numStreams,
};
if (forceTcp) {
webRtcTransportOptions.enableUdp = false;
webRtcTransportOptions.enableTcp = true;
}
const room = this.rooms.get(roomId);
const transport = await room.mediasoupRouter.createWebRtcTransport({
...webRtcTransportOptions,
webRtcServer: room.webRtcServer,
});
transport.on("dtlsstatechange", (dtlsState) => {
console.debug(
'WebRtcTransport dtlsstatechange event',
dtlsState
);
});
transport.on("sctpstatechange", (sctpState) => {
console.debug(
'WebRtcTransport sctpstatechange event',
sctpState
);
});
// await transport.enableTraceEvent([ 'probation', 'bwe' ]);
await transport.enableTraceEvent(["bwe"]);
transport.on("trace", (trace) => {
console.debug(
'transport trace event',
trace,
trace.type,
transport.id,
);
});
// Store the WebRtcTransport into the protoo Peer data Object.
room.transports.set(transport.id, transport);
message.body = {
transportId: transport.id,
iceCandidates: transport.iceCandidates,
iceParameters: transport.iceParameters,
dtlsParameters: transport.dtlsParameters,
sctpParameters: transport.sctpParameters,
};
self.push(
message,
session
);
const { maxIncomingBitrate } = config.mediasoup.webRtcTransportOptions;
// If set, apply max incoming bitrate limit.
if (maxIncomingBitrate) {
try {
await transport.setMaxIncomingBitrate(maxIncomingBitrate);
} catch (error) {}
}
}
/**
* 创建房间信令
*
* @param {*} session 信令通道
* @param {*} message 消息
* @param {*} body 消息主体
*
* @returns 房间
*/
async roomCreate(session, message, body) {
const roomId = body.roomId;
let room = this.rooms.get(roomId);
if (room) {
this.push(message, session);
return room;
}
const mediasoupWorker = this.nextMediasoupWorker();
const { mediaCodecs } = config.mediasoup.routerOptions;
const mediasoupRouter = await mediasoupWorker.createRouter({ mediaCodecs });
mediasoupRouter.on("workerclose", () => {
// TODO通知房间关闭
});
mediasoupRouter.observer.on("close", () => {
// TODO通知房间关闭
});
// TODO下面两个监控改为配置启用
const audioLevelObserver = await mediasoupRouter.createAudioLevelObserver({
maxEntries: 1,
threshold: -80,
interval: 2000,
});
const activeSpeakerObserver =
await mediasoupRouter.createActiveSpeakerObserver({
interval: 500,
});
room = new Room({
roomId,
webRtcServer: mediasoupWorker.appData.webRtcServer,
mediasoupRouter,
audioLevelObserver,
activeSpeakerObserver,
});
this.rooms.set(roomId, room);
console.info("创建房间", roomId);
this.push(message, session);
return room;
}
}
module.exports = Signal;

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCfmqQa2T/dGQIk
cXYiRE47csmhtOEmDL9e1uncyFOYaDLopqma+z0OWV71HrT9m5lEAbK3HZy56CWL
HNlFmnIztT84jTc11b3996cd6G1/5nffRj8D3FcSps9qVsRwAaEnKE6tGrCJhjWB
+/qKIZDP3MmGOkf1guQMTPeZBOEzo+JmL2MOwaLk8dXbb4KHbrye31UnvoXoANs3
1IbdAE3gRMDc40yBoVHeWOQ0h4c/wg1+rMhYPaUtgFkyqFr7zX1YvTa7BwZE4oVf
l7f5k+FRWO8OKn++kHIlE9H3pjdaGKWIuYFRYCxNC9IfGazhPSfYHVgg0q/8UUKg
jFqgbjIJAgMBAAECggEANs6ateGOjbUtyCfyQjgkiUOUu+PqQO+1s7KnYjqkgjyd
5sh8i4zk3Y2RDyl5S3FoQzM2FK2liS2P3uKMNdugheMij5/mqqT4dkLZ72pGV9pj
pZdwwjmi6PPBXCnpkPDuTw0HX2g/4SnmK/nEgjSejtKpnV9cIJHPD+5KRBCp6No+
JLgVFdhCCMEmyzTOU9ASxgRuw0sxGmPsdg3ZewUmoDb94mGDDot500rCB+wnYHue
ACegbaalrTWhY2DzYyNCfnR+F/mshIeqDjMVLsdPoj8MahdQbIoFcyNF+ts3pFVl
MXUUuO3bhZtrSVIT+4r07u82XoXaBufyPzK6Mo1vNQKBgQC9uebhZ1jCyYgGro5o
xuPuW+B5tHKGtlElJ0e7D/XjDvPWj3eGEkVCD5BH2L3qG9oqpKxX+hpmjc5ey21Q
zcnAGG2gC9o+uGQxxjix8sbZ0HEr2J6EdRlKzIZ/N2EnTmU+K8WWWI4g2PEqsId9
yt39EwU/D81bc0b5pcMGJTMk1wKBgQDXWxhywNFtPVreCeerXvvHLIQEtiZUMdhm
8zWtqHNhdojYoeaFPifVcajBo41Jl9qSVCot8cmdhIzmPwbRk1Ob7MtBC8t7OtzT
UmwT9nuxC5iSaGLJNBMtP3M0TzV7+qvvg+Az13kYtje9475LMEYdBLnCb4Mz1Y/R
QOR0mhWkHwKBgBxoepajl9nKvVBq0K4Fodlt7mWqzD85i1rpz8bFtAaklYQ6BSaR
E8e5dtwbKwyj0P3znE6sB0n1z8HH6f1gYuYdgkSloa8kgvQk/xY+COJSYK+1Br9E
nV3i0/y2eRiel3BAs5w4dEec1DeVKSR/vM+JCo8PuasIzsbQuCvyY/8PAoGBALWH
kT0xsZcej9j4inMXNq62pHYAQKDZ/2sQed/vTYsLSuEo39LTCOrPywum3LL7MQAF
uCRQWr3PfKGc4ReJ04FtAgvLcHNos7niET5ml+8uMia/nP2zSrLqeCbQ2emu7H2S
MUwhxm8BMk17iu2APKm7UQZHz1XDIF6oD6sGM1XLAoGALFpdCO486AbHYts2NHey
vQ39u3WDlrgzIM6hs8BI0FEZIdtuNWa/wpZYPaiXUvwTPsfQA1eCqXXuQGzH0fFn
g1M8RxsZ8XNjfQVpqSceZp+qFkTrR8zrbbQiZwBUm9WBdKfryMZHLhFraGLShFdM
ONu5qg3tXgMfFpNcMyiGgeA=
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,22 @@
-----BEGIN CERTIFICATE-----
MIIDnzCCAoegAwIBAgIJAIKYVI9RPbj+MA0GCSqGSIb3DQEBCwUAMF0xCzAJBgNV
BAYTAkNOMQswCQYDVQQIEwJHRDELMAkGA1UEBxMCR1oxDzANBgNVBAoTBnRhb3lh
bzEPMA0GA1UECxMGYWNnaXN0MRIwEAYDVQQDEwlsb2NhbGhvc3QwHhcNMjIxMTA5
MDEyMTM3WhcNMzIxMTA2MDEyMTM3WjBdMQswCQYDVQQGEwJDTjELMAkGA1UECBMC
R0QxCzAJBgNVBAcTAkdaMQ8wDQYDVQQKEwZ0YW95YW8xDzANBgNVBAsTBmFjZ2lz
dDESMBAGA1UEAxMJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
CgKCAQEAn5qkGtk/3RkCJHF2IkROO3LJobThJgy/Xtbp3MhTmGgy6Kapmvs9Dlle
9R60/ZuZRAGytx2cueglixzZRZpyM7U/OI03NdW9/fenHehtf+Z330Y/A9xXEqbP
albEcAGhJyhOrRqwiYY1gfv6iiGQz9zJhjpH9YLkDEz3mQThM6PiZi9jDsGi5PHV
22+Ch268nt9VJ76F6ADbN9SG3QBN4ETA3ONMgaFR3ljkNIeHP8INfqzIWD2lLYBZ
Mqha+819WL02uwcGROKFX5e3+ZPhUVjvDip/vpByJRPR96Y3WhiliLmBUWAsTQvS
Hxms4T0n2B1YINKv/FFCoIxaoG4yCQIDAQABo2IwYDAdBgNVHQ4EFgQUPpT59FzS
UUzHsxrKeGOQ/YeaqpswDgYDVR0PAQH/BAQDAgWgMBoGA1UdEQQTMBGCCWxvY2Fs
aG9zdIcEfwAAATATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0BAQsFAAOC
AQEAKsoYmBr9EmYev6sWVet8x+/YDZgXGZolZff3agPT+uFL/6mtyAqnFD/ncPOh
n206l4RimSChVlEVx3pE4r5sBiLzPaX/dcCRoZNxEQMtjfYCk+4iRfkxhIvpqLzf
ZsEGbJCh9JodG2xkYNViPF2AqR8OchEMRttYQa2dDkk2oDVMg0bmqgZgSD7vEjdk
ovBhEIQ1Rhgv0yi9IT+kXYa+nTpc+/9m/GmmejtaFaVdpj+WuNqPf/WyzR+3JQWZ
Y/O7ESF7tUcw0HSxNv/pk6Z13RQClUo6bPHzPF4JJw1tAIbkuyKZ6ZpErQePUEpk
dVZPy7rDD3N/BI//0vBZmy0v1w==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>桃夭媒体服务</title>
<style type="text/css">
p{text-align:center;}
a{text-decoration:none;}
</style>
</head>
<body>
<p><a href="https://gitee.com/acgist/taoyao">taoyao-client-media</a></p>
<p><a href="https://www.acgist.com">acgist</a></p>
</body>
</html>