add service

This commit is contained in:
notch
2020-12-21 14:55:30 +08:00
parent 7094607698
commit c108963b65
14 changed files with 1481 additions and 13 deletions

389
docs/apis.md Executable file
View File

@@ -0,0 +1,389 @@
## 1 系统API
系统API无需登录可以匿名访问
### 1.1 服务器信息查询
GET /api/v1/server
#### 1.1.1 参数和响应
+ Body参数
+ 查询参数
+ 响应200
项目 | 类型 | 说明
-|-|-
vendor|string| 软件提供商 |
name | string | 服务名称 |
version | string | 服务版本 |
os | string | 服务运行的平台 |
arch | string | 服务运行的平台架构 |
start_on | string(timestamp) | 服务启动时间(RFC3339Nano 格式) |
duration | string | 持续时间 |
#### 1.1.2 示例
curl 示例:
``` shell
curl http://locahost:1554/api/v1/server
```
响应:
``` json
{
"vendor": "CAOHONGJU",
"name": "ipchub",
"version": "V0.8.0",
"os": "Darwin",
"arch": "AMD64",
"start_on": "2019-07-15T14:02:16.638804+08:00",
"duration": "23.319603373s"
}
```
### 1.2 运行信息查询
GET /api/v1/runtime?extra={0|1}
#### 1.2.1 参数和响应
+ Body参数
+ 查询参数
项目 | 类型 | 说明及示例
-|-|-
extra | number | 0 或 1 ;如果=1响应会包含额外信息|
+ 响应200
获取运行是信息,extra=1返回额外信息
项目 | 类型 | 说明及示例
-|-|-
on | timestamp | 采集时间(RFC3339Nano 格式)|
proc | object | 进程相关的统计信息 |
proc.cpu | number |cpu使用情况|
proc.priv | number |物理内存使用情况kb|
proc.cpu | number |虚拟内存使用情况kb|
proc.uptime|number | 进程运行时间s|
streams | object | 流信息 |
streams.sc| number | 流媒体源数量 |
streams.cc|number|流媒体消费者数量 |
rtsp| object| RTSP连接信息 |
rtsp.total|number|总链接数 |
rtsp.active | number | 活跃连接数 |
wsp| object| WSP连接信息 |
wsp.total|number|总链接数 |
wsp.active | number | 活跃连接数 |
flv| object| flv连接信息 |
flv.total|number|总链接数 |
flv.active | number | 活跃连接数 |
extra | object | 额外信息 |
#### 1.2.1 示例
curl 示例一:
```
curl http://localhost:1554/api/v1/runtime
```
响应:
``` json
{
"on": "2019-07-15T14:05:20.524916+08:00",
"proc": {
"cpu": 0,
"priv": 6876,
"virt": 2545968,
"uptime": 183
},
"streams": {
"sources": 0,
"consumers": 0
},
"rtsp": {
"total": 0,
"active": 0
},
"wsp": {
"total": 0,
"active": 0
},
"rtmp": {
"total": 0,
"active": 0
}
}
```
示例二:
```
curl http://localhost:1554/api/v1/runtime?extra=1
```
响应:
``` json
{
"on": "2019-07-15T14:06:41.012543+08:00",
"proc": {
"cpu": 0,
"priv": 6912,
"virt": 2545968,
"uptime": 264
},
"streams": {
"sources": 0,
"consumers": 0
},
"rtsp": {
"total": 0,
"active": 0
},
"wsp": {
"total": 0,
"active": 0
},
"rtmp": {
"total": 0,
"active": 0
},
"extra": {
"heap": {
"inuse": 1768,
"sys": 64992,
"alloc": 641,
"idle": 63224,
"released": 0,
"objects": 3988
},
"mcache": {
"inuse": 13,
"sys": 16
},
"mspan": {
"inuse": 28,
"sys": 32
},
"stack": {
"inuse": 544,
"sys": 544
},
"gc": {
"cpu": 0,
"sys": 2182
},
"go": {
"count": 11,
"procs": 8,
"sys": 70462,
"alloc": 641
}
}
}
```
### 1.3 登录
POST api/v1/login
#### 1.3.1 参数和响应
+ Body 参数
项目 | 类型 | 说明及示例
-|-|-
username | string | 用户名称|
password | string | 密码 |
+ 查询参数
+ 响应200
项目 | 类型 | 说明及示例
-|-|-
access_token | string | 访问令牌|
refresh_token | string | 刷新令牌 |
#### 1.3.2 示例
curl 示例:
```
curl -H "Content-Type: application/json" -X POST --data '{"username":"admin","password":"admin"}' http://localhost:1554/api/v1/login
```
响应:
``` json
{
"access_token": "e8962d3214957043680e111d14e73721",
"refresh_token": "b447808fe9ada297bae6e2e898711bb4"
}
```
#### 1.3.3 使用access_token
所有需要授权的访问都需要 access_token。使用查询参数 token={access_token}
包括:
+ 访问Api
http://.../api/v1/streams/rtsp/room/door?token=your_access_token
+ http-flv
http://.../streams/room/door.flv?token=your_access_token
+ websocket-flv
ws://.../ws/room/door.flv?token=your_access_token
+ wps
ws://.../ws/room/door?token=your_access_token
+ rtmp
rtmp://.../room/door?token=your_access_token
### 1.4 刷新access token
GET api/v1/refreshtoken?token={refresh_tokebn}
#### 1.3.1 参数和响应
+ Body 参数
+ 查询参数
项目 | 类型 | 说明及示例
-|-|-
token | string | 登录或上次Refreshtoken返回的refresh_token|
+ 响应200
项目 | 类型 | 说明及示例
-|-|-
access_token | string | 访问令牌|
refresh_token | string | 刷新令牌 |
## 2 用户管理
需要管理员权限
### 2.1 获取用户信息
GET api/v1/users/{username}
#### 1.3.1 参数和响应
+ Body 参数
+ 查询参数
+ 路径参数
项目 | 类型 | 说明及示例
-|-|-
username | string | 用户名称|
+ 响应200
项目 | 类型 | 说明及示例
-|-|-
name | string | 用户名 |
admin | string | 是否是管理员 |
push | string |推送权限 |
pull | string | 拉取权限 |
### 2.2 删除用户
DELETE api/v1/users/{username}
删除用户信息,但不会断开已有连接
### 2.3 创建或更新用户信息
POST api/v1/users?update_password={0|1}
update_password 如果用户已存在1 更新密码,其他值不会更新密码
### 2.4 获取用户列表
GET api/v1/users
#### 1.3.1 参数和响应
+ Body 参数
+ 查询参数
项目 | 类型 | 说明及示例
-|-|-
page_size | number | 分页大小 |
page_token | string | 上次查询时返回的页token |
+ 路径参数
+ 响应200
项目 | 类型 | 说明及示例 |
-|-|-|
total | number | 用户总数 |
next_page_token | string | 下次查询的token |
users | array | 用户列表 |
name | string | 用户名 |
admin | string | 是否是管理员 |
push | string |推送权限 |
pull | string | 拉取权限 |
## 3 路由管理
### 3.1 基本对象
#### 3.1.1 路由
属性 | 类型 | 说明及示例
-|-|-
pattern | string | 本地路径模式字串|
url | string | 路由的目标地址用户名和密码可以直接写在url中 |
keepalive | bool|是否保持连接如果没有消费者是否继续保持连接如果为false在5分钟后自动断开 |
#### 3.1.2 路由表
属性 | 类型 | 说明及示例
-|-|-
total |number |路由表中总个数
next_page_token | string |下一页查询需带上的 page_token
routes | array|路由信息数组
### 3.2 获取路由信息
GET api/v1/routes/{pattern=**}
### 3.2 删除路由
DELETE api/v1/routes/{pattern=**}
但不会断开已有连接
### 3.3 创建路由
POST api/v1/routes
创建或更新路由信息
### 3.4 获取路由表
GET api/v1/routes
+ 查询参数
项目 | 类型 | 说明及示例
-|-|-
page_size | number | 分页大小 |
page_token | string | 上次查询时返回的页token |
### 4 流管理
### 4.1 基本对象
#### 4.1.1 流
属性 | 类型 | 说明及示例
-|-|-
start_on | string(timestamp) | 流启动时间(RFC3339Nano 格式) |
path | string | 流路径|
addr | string | 流提供者的地址push或pull|
size | number | 流的大小|
video| object | 视频元数据|
audio| object | 音频元数据|
cc | number | 正在消费流的消费者数量|
cs | array | 正在消费流的消费者数组|
[].id | number | 消费者ID|
[].start_on | string(timestamp) | 消费启动时间(RFC3339Nano 格式) |
[].packet_type | string | 消费的包类型|
[].extra | string | 消费者额外描述|
[].flow | object | 消费者接收和发送的流量统计|
[].flow.inbytes | number | 消费者接收和发送的流量统计(kb)|
[].flow.outbytes | number | 消费者接收和发送的流量统计(kb)|
#### 4.1.2 流列表
属性 | 类型 | 说明及示例
-|-|-
total | number|流总个数
next_page_token | string |下一页查询需带上的 page_token
streams | array|流数组
### 4.2 获取流列表
GET api/v1/streams?c={0|1}
获取流列表
+ 查询参数
项目 | 类型 | 说明及示例
-|-|-
page_size | number | 分页大小 |
page_token | string | 上次查询时返回的页token |
c |number|是否返回消费者信息 1 返回,其他值不返回|
### 4.3 获取流信息
GET api/v1/streams/{path=**}?c={0|1}
### 4.4 删除流
DELETE api/v1/streams/{path=**}
### 4.5 停止指定消费者
DELETE api/v1/streams/{path=**}:consumer?cid={cid}
+ 查询参数
项目 | 类型 | 说明及示例
-|-|-
cid | number | 消费者id |

108
docs/config.md Executable file
View File

@@ -0,0 +1,108 @@
## 1. 跟配置
属性 | 说明 | 示例
-|-|-
listen | 侦听地址,":1554" | 默认:":1554" |
auth | 访问流媒体时,是否启用身份和权限验证 |默认false |
cache_gop | 是否缓存GOP缓存GOP会提高打开速度|默认false |
hlspath | hls临时文件存储目录不设置则在内存存储|默认:空字串 |
profile | 是否启动在线诊断|默认false |
tls | 安全连接配置 |如果需要http范围设置该配置向 |
routetable | 路由表提供者 | 默认json|
users | 用户提供者 |默认json |
log | 日志配置 | |
## 2. tls 配置
属性 | 说明 | 示例
-|-|-
listen | 安全连接侦听地址 |默认":443" |
cert | 证书内容或文件 | |
key | 私钥内容或文件 | |
## 3. 路由表配置文件
属性 | 说明 | 示例
-|-|-
pattern | 本地路径模式字串 | 当以'/'结尾表示一个以pattern开头的请求都路由到下面的url |
url | 路由的目标地址用户名和密码可以直接写在url中 | rtsp://admin@adminlocalhost/live2 |
keepalive | 是否保持连接如果没有消费者是否继续保持连接如果为false在5分钟后自动断开 | false/true |
### 3.1 pattern
模式字串有两种形式:
+ 精确形式
+ 目录形式
目录形式以'/'字符结束表示以此pattern开始的流路径都将路由到它对应的url。它适合于多层组织结构的路由导航。
### 3.2 完整实例:
``` json
[
{
"pattern": "/entrance/A1",
"url": "rtsp://admin:admin@localhost:5540/live2",
"keepalive": true
},
{
"pattern": "/hr/",
"url": "rtsp://admin:admin@localhost:8540/video",
"keepalive": false
}
]
```
访问流媒体描述
+ rtsp://localhost:1554/entrance/A1
将路由到 rtsp://admin:admin@localhost:5540/live2
+ rtsp://localhost:1554/hr/door
将路由到 rtsp://admin:admin@localhost:8540/video/door
## 4. 用户配置文件
属性 | 说明 | 示例
-|-|-
name | 用户名 | admin |
password | 密码 | |
admin | 是否是管理员 | false/true |
push | 推送权限 | /rooms/+/entrace |
pull | 拉取权限 | * |
### 4.1 完整示例:
``` json
[
{
"name":"admin",
"password":"admin",
"admin":true,
"push":"*",
"pull":"*"
},
{
"name":"user1",
"password":"user1",
"push":"/rooms/+/entrance",
"pull":"/test/*;/rooms/*"
}
]
```
### 4.2 权限配置格式说明
+ `*` 0-n 段通配
+ `+` 表示可以一个路径端通配
可以通过分号设置多个
#### 4.2.1 例子1
当权限设置为 /a
+ 路径 /a 通过授权
+ 路径 /a/b 不通过授权
#### 4.2.2 例子2
当权限设置为 /a/*
+ 路径 /a 通过授权
+ 路径 /a/b, /a/c, /a/b/c 都通过授权
#### 4.2.3 例子3
当权限设置为 /a/+/c/*
+ 路径 a/b/c, a/d/c, a/b/c/d, a/b/c/d/e 都通过授权
+ 路径 a/c 不通过授权
## 5 ffmpeg
将ffmpeg设置到环境变量路径中供rtsp到rtmp的转换

96
docs/quickstart.md Executable file
View File

@@ -0,0 +1,96 @@
## 1. 安装
即拷即用,根据自己的操作系统版本拷贝相应的可执行文件。
## 2. 配置
服务器需要配置自己的摄像头拉流。
默认配置拉流的路由信息在routetable.json中详细参考配置文档说明。
以下是一个典型的例子:
``` json
[
{
"pattern": "/group/door",
"url": "rtsp://admin:888888@192.168.110.250:8554/H264MainStream",
"keepalive":true
},
{
"pattern": "/hr/",
"url": "rtsp://admin:admin@192.168.110.145:1554",
"keepalive": false
}
]
```
我们配置了两个路由:
+ /group/door
集团大门直接连接到摄像头
+ /hr/
人力资源部门的摄像头路由到下级的服务器中hr的服务器包含/door/video1和/door/video2
## 3. 使用
服务器提供了多种访问终端摄像头的方式,包括:
+ rtsp
+ websocket-rtsp
+ wspwebsocket 代理模式)
+ http-flv
+ websocket-flv
+ http-hls
下面我们分别使用不同的方式访问上面两个路由的摄像头
### 3.1 使用rtsp访问
```
ffplay -rtsp_transport tcp rtsp://localhost:1554/group/door -fflags nobuffer
ffplay -rtsp_transport udp rtsp://localhost:1554/group/door -fflags nobuffer
ffplay -rtsp_transport udp_multicast rtsp://localhost:1554/group/door -fflags nobuffer
```
上面分别使用了 TCP、UDP、multicast 等三种方式访问
要访问hr的/door/video1只要将/group/door换成/hr/door/video1即可
```
ffplay -rtsp_transport tcp rtsp://localhost:1554/hr/door/video1 -fflags nobuffer
```
rtsp://localhost:1554/hr/door/video1 请求在服务器内自动变成去拉取rtsp://admin:admin@192.168.110.145:1554/door/video1
### 3.2 使用websocket-rtsp
打开demo地址http://localhost:1554/demos/rtsp
输入ws://localhost:1554/ws/group/door 即可访问
### 3.3 使用wsp访问
和上面一样打开demo地址http://localhost:1554/demos/wsp
输入rtsp://localhost:1554/group/door 即可访问
### 3.4 使用http-flv访问
打开demo地址http://localhost:1554/demos/flv
输入http://locaolhost:1554/streams/group/door.flv 即可访问
由于 Chrome 对长连接的流限制为6个因此如果使用 Chrome 打开更多建议使用websocket-flv
### 3.5 使用 websocket-flv访问
打开demo地址http://localhost:1554/demos/flv
输入ws://locaolhost:1554/ws/group/door.flv 即可访问
### 3.6 使用 http-hls访问
由于 iOS的Safari不支持上述任何http访问模式请使用 http-hls
在浏览器输入: http://localhost:1554/streams/group/door.m3m8 即可访问
**注意:** 由于http-hls的段文件默认被放在内存中占用大量的内存如系统内存不足请配置存储路径。
## 4. 需要授权的情况
除rtsp外其他使用token进行访问
如果 http-flv,
输入http://locaolhost:1554/streams/group/door.flv?token=7f97509e321a18ccf281607f4c0bd4fb
其中 token 通过登录api获得
对于配置用户参考配置和Api文档
## 5. 浏览器支持情况
wsp、http-flv、websocket-flv等浏览器访问支持
+ Firefox v.42+
+ Chrome v.23+
+ OSX Safari v.8+
+ MS Edge v.13+
+ Opera v.15+
+ Android browser v.5.0+
+ IE Mobile v.11+
不支持 iOS Safari 和 IE

1
go.mod
View File

@@ -4,6 +4,7 @@ go 1.14
require (
github.com/BurntSushi/toml v0.3.1 // indirect
github.com/cnotch/apirouter v0.0.0-20200731232942-89e243a791f3
github.com/cnotch/bitutil v0.0.0-20200512012328-08db448fb960
github.com/cnotch/loader v0.0.0-20200405015128-d9d964d09439
github.com/cnotch/scheduler v0.0.0-20200522024700-1d2da93eefc5

5
go.sum
View File

@@ -1,9 +1,13 @@
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/cnotch/apirouter v0.0.0-20200731232942-89e243a791f3 h1:Y8fe6nOk/UMsVZOPPLEVd9axxbIuBBjhZ+g6RpMz6vI=
github.com/cnotch/apirouter v0.0.0-20200731232942-89e243a791f3/go.mod h1:5deJPLON/x/s2dLOQfuKS0lenhOIT4xX0pvtN/OEIuY=
github.com/cnotch/bitutil v0.0.0-20200512012328-08db448fb960 h1:mZGYgYjlZOh+oMLvv3uxT61J0y8SWStIoOaqWU9S3Lk=
github.com/cnotch/bitutil v0.0.0-20200512012328-08db448fb960/go.mod h1:ivZKsbAhsndNi8A5NFiDkBUY3+lVINm+IXNUnQNn6JM=
github.com/cnotch/loader v0.0.0-20200405015128-d9d964d09439 h1:iNWyllf6zuby+nDNC6zKEkM7aUFbp4RccfWVdQ3HFfQ=
github.com/cnotch/loader v0.0.0-20200405015128-d9d964d09439/go.mod h1:oWpDagHB6p+Kqqq7RoRZKyC4XAXft50hR8pbTxdbYYs=
github.com/cnotch/queue v0.0.0-20200326024423-6e88bdbf2ad4 h1:bU2h1mvmsh6V1gQDGKgA9lHfBMLCmaVs1t2xyloDYHY=
github.com/cnotch/queue v0.0.0-20200326024423-6e88bdbf2ad4/go.mod h1:zOssjAlNusOxvtaqT+EMA+Iyi8rrtKr4/XfzN1Fgoeg=
github.com/cnotch/scheduler v0.0.0-20200522024700-1d2da93eefc5 h1:m9Wx/d4iPXFmE0f2zJ6iQ8tXZ52kOZO9qs/kMevEHxk=
github.com/cnotch/scheduler v0.0.0-20200522024700-1d2da93eefc5/go.mod h1:F4GE3SZkJZ8an1Y0ZCqvSM3jeozNuKzoC67erG1PhIo=
github.com/cnotch/xlog v0.0.0-20201208005456-cfda439cd3a0 h1:YXATGJEn/ymZjZOGCFfE5248ABcLbfwpd/dQGfByxGQ=
@@ -39,6 +43,7 @@ github.com/pixelbender/go-sdp v1.1.0 h1:rkm9aFBNKrnB+YGfhLmAkal3pC8XYXb9h+172Plr
github.com/pixelbender/go-sdp v1.1.0/go.mod h1:6IBlz9+BrUHoFTea7gcp4S54khtOhjCW/nVDLhmZBAs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sqs/goreturns v0.0.0-20181028201513-538ac6014518/go.mod h1:CKI4AZ4XmGV240rTHfO0hfE83S6/a3/Q1siZJ/vXf7A=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=

17
main.go
View File

@@ -5,9 +5,12 @@
package main
import (
"context"
"github.com/cnotch/ipchub/config"
"github.com/cnotch/ipchub/provider/auth"
"github.com/cnotch/ipchub/provider/route"
"github.com/cnotch/ipchub/service"
"github.com/cnotch/scheduler"
"github.com/cnotch/xlog"
)
@@ -29,12 +32,12 @@ func main() {
userProvider := config.LoadUsersProvider(auth.JSON)
auth.Reset(userProvider.(auth.UserProvider))
// // Start new service
// svc, err := service.NewService(context.Background(), xlog.L())
// if err != nil {
// xlog.L().Panic(err.Error())
// }
// Start new service
svc, err := service.NewService(context.Background(), xlog.L())
if err != nil {
xlog.L().Panic(err.Error())
}
// // Listen and serve
// svc.Listen()
// Listen and serve
svc.Listen()
}

536
service/apis.go Executable file
View File

@@ -0,0 +1,536 @@
// Copyright (c) 2019,CAOHONGJU All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package service
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/url"
"path"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/cnotch/apirouter"
"github.com/cnotch/ipchub/config"
"github.com/cnotch/ipchub/media"
"github.com/cnotch/ipchub/provider/auth"
"github.com/cnotch/ipchub/provider/route"
"github.com/cnotch/ipchub/stats"
)
const (
usernameHeaderKey = "user_name_in_token"
)
var (
buffers = sync.Pool{
New: func() interface{} {
return bytes.NewBuffer(make([]byte, 0, 1024*2))
},
}
noAuthRequired = map[string]bool{
"/api/v1/login": true,
"/api/v1/server": true,
"/api/v1/runtime": true,
"/api/v1/refreshtoken": true,
}
)
var crossdomainxml = []byte(
`<?xml version="1.0" ?><cross-domain-policy>
<allow-access-from domain="*" />
<allow-http-request-headers-from domain="*" headers="*"/>
</cross-domain-policy>`)
func (s *Service) initApis(mux *http.ServeMux) {
api := apirouter.NewForGRPC(
// 系统信息类API
apirouter.POST("/api/v1/login", s.onLogin),
apirouter.GET("/api/v1/server", s.onGetServerInfo),
apirouter.GET("/api/v1/runtime", s.onGetRuntime),
apirouter.GET("/api/v1/refreshtoken", s.onRefreshToken),
// 流管理API
apirouter.GET("/api/v1/streams", s.onListStreams),
apirouter.GET("/api/v1/streams/{path=**}", s.onGetStreamInfo),
apirouter.DELETE("/api/v1/streams/{path=**}", s.onStopStream),
apirouter.DELETE("/api/v1/streams/{path=**}:consumer", s.onStopConsumer),
// 路由管理API
apirouter.GET("/api/v1/routes", s.onListRoutes),
apirouter.GET("/api/v1/routes/{pattern=**}", s.onGetRoute),
apirouter.DELETE("/api/v1/routes/{pattern=**}", s.onDelRoute),
apirouter.POST("/api/v1/routes", s.onSaveRoute),
// 用户管理API
apirouter.GET("/api/v1/users", s.onListUsers),
apirouter.GET("/api/v1/users/{userName=*}", s.onGetUser),
apirouter.DELETE("/api/v1/users/{userName=*}", s.onDelUser),
apirouter.POST("/api/v1/users", s.onSaveUser),
)
iterc := apirouter.ChainInterceptor(apirouter.PreInterceptor(s.authInterceptor),
apirouter.PreInterceptor(roleInterceptor))
// api add to mux
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
if path.Base(r.URL.Path) == "crossdomain.xml" {
w.Header().Set("Content-Type", "application/xml")
w.Write(crossdomainxml)
return
}
path := strings.ToLower(r.URL.Path)
if _, ok := noAuthRequired[path]; ok || iterc.PreHandle(w, r) {
w.Header().Set("Access-Control-Allow-Origin", "*")
api.ServeHTTP(w, r)
}
})
}
// 刷新Token
func (s *Service) onRefreshToken(w http.ResponseWriter, r *http.Request, pathParams apirouter.Params) {
token := r.URL.Query().Get("token")
if token != "" {
newtoken := s.tokens.Refresh(token)
if newtoken != nil {
if err := jsonTo(w, newtoken); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
}
http.Error(w, "Token is not valid", http.StatusUnauthorized)
return
}
// 登录
func (s *Service) onLogin(w http.ResponseWriter, r *http.Request, pathParams apirouter.Params) {
type UserCredentials struct {
Username string `json:"username"`
Password string `json:"password"`
}
// 提取凭证
var uc UserCredentials
err := json.NewDecoder(r.Body).Decode(&uc)
if err != nil {
// 尝试 Form解析
uc.Username = r.FormValue("username")
uc.Password = r.FormValue("password")
if len(uc.Username) == 0 || len(uc.Password) == 0 {
http.Error(w, "用户名或密码错误", http.StatusForbidden)
return
}
}
// 验证用户和密码
u := auth.Get(uc.Username)
if u == nil || u.ValidatePassword(uc.Password) != nil {
http.Error(w, "用户名或密码错误", http.StatusForbidden)
return
}
// 新建Token并返回
token := s.tokens.NewToken(u.Name)
if err := jsonTo(w, token); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// 获取运行时信息
func (s *Service) onGetServerInfo(w http.ResponseWriter, r *http.Request, pathParams apirouter.Params) {
type server struct {
Vendor string `json:"vendor"`
Name string `json:"name"`
Version string `json:"version"`
OS string `json:"os"`
Arch string `json:"arch"`
StartOn string `json:"start_on"`
Duration string `json:"duration"`
}
srv := server{
Vendor: config.Vendor,
Name: config.Name,
Version: config.Version,
OS: strings.Title(runtime.GOOS),
Arch: strings.ToUpper(runtime.GOARCH),
StartOn: stats.StartingTime.Format(time.RFC3339Nano),
Duration: time.Now().Sub(stats.StartingTime).String(),
}
if err := jsonTo(w, &srv); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// 获取运行时信息
func (s *Service) onGetRuntime(w http.ResponseWriter, r *http.Request, pathParams apirouter.Params) {
const extraKey = "extra"
type sccc struct {
SC int `json:"sources"`
CC int `json:"consumers"`
}
type runtime struct {
On string `json:"on"`
Proc stats.Proc `json:"proc"`
Streams sccc `json:"streams"`
Rtsp stats.ConnsSample `json:"rtsp"`
Rtmp stats.ConnsSample `json:"rtmp"`
Wsp stats.ConnsSample `json:"wsp"`
Flv stats.ConnsSample `json:"flv"`
Extra *stats.Runtime `json:"extra,omitempty"`
}
sc, cc := media.Count()
rt := runtime{
On: time.Now().Format(time.RFC3339Nano),
Proc: stats.MeasureRuntime(),
Streams: sccc{sc, cc},
Rtsp: stats.RtspConns.GetSample(),
Wsp: stats.WspConns.GetSample(),
Flv: stats.FlvConns.GetSample(),
}
params := r.URL.Query()
if strings.TrimSpace(params.Get(extraKey)) == "1" {
rt.Extra = stats.MeasureFullRuntime()
}
if err := jsonTo(w, &rt); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Service) onListStreams(w http.ResponseWriter, r *http.Request, pathParams apirouter.Params) {
params := r.URL.Query()
pageSize, pageToken, err := listParamers(params)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
includeCS := strings.TrimSpace(params.Get("c")) == "1"
count, sinfos := media.Infos(pageToken, pageSize, includeCS)
type streamInfos struct {
Total int `json:"total"`
NextPageToken string `json:"next_page_token"`
Streams []*media.StreamInfo `json:"streams,omitempty"`
}
list := &streamInfos{
Total: count,
Streams: sinfos,
}
if len(sinfos) > 0 {
list.NextPageToken = sinfos[len(sinfos)-1].Path
}
if err := jsonTo(w, list); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Service) onGetStreamInfo(w http.ResponseWriter, r *http.Request, pathParams apirouter.Params) {
path := pathParams.ByName("path")
var rt *media.Stream
rt = media.Get(path)
if rt == nil {
http.NotFound(w, r)
return
}
params := r.URL.Query()
includeCS := strings.TrimSpace(params.Get("c")) == "1"
si := rt.Info(includeCS)
if err := jsonTo(w, si); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Service) onStopStream(w http.ResponseWriter, r *http.Request, pathParams apirouter.Params) {
path := pathParams.ByName("path")
var rt *media.Stream
rt = media.Get(path)
if rt != nil {
rt.Close()
}
w.WriteHeader(http.StatusOK)
}
func (s *Service) onStopConsumer(w http.ResponseWriter, r *http.Request, pathParams apirouter.Params) {
path := pathParams.ByName("path")
param := r.URL.Query().Get("cid")
no, err := strconv.ParseInt(param, 10, 64)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var rt *media.Stream
rt = media.Get(path)
if rt != nil {
rt.StopConsume(media.CID(no))
}
w.WriteHeader(http.StatusOK)
}
func (s *Service) onListRoutes(w http.ResponseWriter, r *http.Request, pathParams apirouter.Params) {
params := r.URL.Query()
pageSize, pageToken, err := listParamers(params)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
routes := route.All()
sort.Slice(routes, func(i, j int) bool {
return routes[i].Pattern < routes[j].Pattern
})
begini := 0
for _, r1 := range routes {
if r1.Pattern <= pageToken {
begini++
continue
}
break
}
type routeInfos struct {
Total int `json:"total"`
NextPageToken string `json:"next_page_token"`
Routes []*route.Route `json:"routes,omitempty"`
}
list := &routeInfos{
Total: len(routes),
NextPageToken: pageToken,
Routes: make([]*route.Route, 0, pageSize),
}
j := 0
for i := begini; i < len(routes) && j < pageSize; i++ {
j++
list.Routes = append(list.Routes, routes[i])
list.NextPageToken = routes[i].Pattern
}
if err := jsonTo(w, list); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Service) onGetRoute(w http.ResponseWriter, r *http.Request, pathParams apirouter.Params) {
pattern := pathParams.ByName("pattern")
r1 := route.Get(pattern)
if r1 == nil {
http.NotFound(w, r)
return
}
if err := jsonTo(w, r1); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Service) onDelRoute(w http.ResponseWriter, r *http.Request, pathParams apirouter.Params) {
pattern := pathParams.ByName("pattern")
err := route.Del(pattern)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
} else {
w.WriteHeader(http.StatusOK)
}
}
func (s *Service) onSaveRoute(w http.ResponseWriter, r *http.Request, pathParams apirouter.Params) {
r1 := &route.Route{}
err := json.NewDecoder(r.Body).Decode(r1)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = route.Save(r1)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
} else {
w.WriteHeader(http.StatusOK)
}
}
func (s *Service) onListUsers(w http.ResponseWriter, r *http.Request, pathParams apirouter.Params) {
params := r.URL.Query()
pageSize, pageToken, err := listParamers(params)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
users := auth.All()
sort.Slice(users, func(i, j int) bool {
return users[i].Name < users[j].Name
})
begini := 0
for _, u := range users {
if u.Name <= pageToken {
begini++
continue
}
break
}
type userInfos struct {
Total int `json:"total"`
NextPageToken string `json:"next_page_token"`
Users []auth.User `json:"users,omitempty"`
}
list := &userInfos{
Total: len(users),
NextPageToken: pageToken,
Users: make([]auth.User, 0, pageSize),
}
j := 0
for i := begini; i < len(users) && j < pageSize; i++ {
j++
u := *users[i]
u.Password = ""
list.Users = append(list.Users, u)
list.NextPageToken = u.Name
}
if err := jsonTo(w, list); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Service) onGetUser(w http.ResponseWriter, r *http.Request, pathParams apirouter.Params) {
userName := pathParams.ByName("userName")
u := auth.Get(userName)
if u == nil {
http.NotFound(w, r)
return
}
u2 := *u
u2.Password = ""
if err := jsonTo(w, &u2); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Service) onDelUser(w http.ResponseWriter, r *http.Request, pathParams apirouter.Params) {
userName := pathParams.ByName("userName")
err := auth.Del(userName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
} else {
w.WriteHeader(http.StatusOK)
}
}
func (s *Service) onSaveUser(w http.ResponseWriter, r *http.Request, pathParams apirouter.Params) {
u := &auth.User{}
err := json.NewDecoder(r.Body).Decode(u)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
updatePassword := r.URL.Query().Get("update_password") == "1"
err = auth.Save(u, updatePassword)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
} else {
w.WriteHeader(http.StatusOK)
}
}
func jsonTo(w io.Writer, o interface{}) error {
formatted := buffers.Get().(*bytes.Buffer)
formatted.Reset()
defer buffers.Put(formatted)
body, err := json.Marshal(o)
if err != nil {
return err
}
if err := json.Indent(formatted, body, "", "\t"); err != nil {
return err
}
if _, err := w.Write(formatted.Bytes()); err != nil {
return err
}
return nil
}
func listParamers(params url.Values) (pageSize int, pageToken string, err error) {
pageSizeStr := params.Get("page_size")
pageSize = 20
if pageSizeStr != "" {
var err error
pageSize, err = strconv.Atoi(pageSizeStr)
if err != nil {
return pageSize, pageToken, err
}
}
pageToken = params.Get("page_token")
return
}
// ?token=
func (s *Service) authInterceptor(w http.ResponseWriter, r *http.Request) bool {
token := r.URL.Query().Get("token")
if token != "" {
username := s.tokens.AccessCheck(token)
if username != "" {
r.Header.Set(usernameHeaderKey, username)
return true // 继续执行
}
}
http.Error(w, "Token is not valid", http.StatusUnauthorized)
return false
}
func roleInterceptor(w http.ResponseWriter, r *http.Request) bool {
// 流查询方法,无需管理员身份
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/streams") {
return true
}
userName := r.Header.Get(usernameHeaderKey)
u := auth.Get(userName)
if u == nil || !u.Admin {
http.Error(w /*http.StatusText(http.StatusForbidden)*/, "访问被拒绝,请用管理员登录", http.StatusForbidden)
return false
}
return true
}

View File

@@ -11,8 +11,8 @@ import (
"sync"
"github.com/cnotch/ipchub/media"
"github.com/cnotch/ipchub/utils"
"github.com/cnotch/xlog"
"github.com/emitter-io/address"
)
// 组播代理
@@ -104,11 +104,11 @@ func (proxy *multicastProxy) TTL() int {
func (proxy *multicastProxy) SourceIP() string {
if len(proxy.sourceIP) == 0 {
addrs, err := address.GetPublic()
if err != nil {
addrs := utils.GetLocalIP()
if len(addrs) == 0 {
proxy.sourceIP = "Unknown"
} else {
proxy.sourceIP = addrs[0].IP.String()
proxy.sourceIP = addrs[0]
}
}
return proxy.sourceIP

View File

@@ -254,7 +254,7 @@ func (s *Session) onDescribe(resp *Response, req *Request) {
}
// 从流中取 sdp
sdpRaw := stream.Attr("sdp")
sdpRaw := stream.Sdp()
if len(sdpRaw) == 0 {
resp.StatusCode = StatusNotFound
return

View File

@@ -251,6 +251,7 @@ func (s *Session) asTCPConsumer(stream *media.Stream, resp *Response) (err error
func (s *Session) asUDPConsumer(stream *media.Stream, resp *Response) (err error) {
c := &udpConsumer{
Session: s,
source: stream,
}
// 创建udp连接

196
service/service.go Executable file
View File

@@ -0,0 +1,196 @@
// Copyright (c) 2019,CAOHONGJU All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package service
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"net/http/pprof"
"os"
"os/signal"
"syscall"
"time"
"github.com/cnotch/ipchub/config"
"github.com/cnotch/ipchub/media"
"github.com/cnotch/ipchub/network/socket/listener"
"github.com/cnotch/ipchub/provider/auth"
"github.com/cnotch/ipchub/provider/route"
"github.com/cnotch/ipchub/service/rtsp"
"github.com/cnotch/ipchub/service/wsp"
"github.com/cnotch/scheduler"
"github.com/cnotch/xlog"
"github.com/emitter-io/address"
"github.com/kelindar/tcp"
)
// Service 网络服务对象(服务的入口)
type Service struct {
context context.Context
cancel context.CancelFunc
logger *xlog.Logger
tlsusing bool
http *http.Server
rtsp *tcp.Server
wsp *tcp.Server
tokens *auth.TokenManager
}
// NewService 创建服务
func NewService(ctx context.Context, l *xlog.Logger) (s *Service, err error) {
ctx, cancel := context.WithCancel(context.Background())
s = &Service{
context: ctx,
cancel: cancel,
logger: l,
http: new(http.Server),
rtsp: new(tcp.Server),
wsp: new(tcp.Server),
tokens: new(auth.TokenManager),
}
// 设置 http 的Handler
mux := http.NewServeMux()
// 管理员控制台
if consoleAppDir, ok := config.ConsoleAppDir(); ok {
mux.Handle("/", http.FileServer(http.Dir(consoleAppDir)))
}
// Demo应用
if demosAppDir, ok := config.DemosAppDir(); ok {
mux.Handle("/demos/", http.StripPrefix("/demos/", http.FileServer(http.Dir(demosAppDir))))
}
if config.Profile() {
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
}
s.initApis(mux)
s.initHTTPStreams(mux)
s.http.Handler = mux
// 设置 rtsp AcceptHandler
s.rtsp.OnAccept = rtsp.CreateAcceptHandler()
// 设置 wsp AcceptHandler
s.wsp.OnAccept = wsp.CreateAcceptHandler()
// 启动定时存储拉流信息
scheduler.PeriodFunc(time.Minute*5, time.Minute*5, func() {
route.Flush()
auth.Flush()
s.tokens.ExpCheck()
}, "The task of scheduled storage of routing tables and authorization information tables(5minutes")
s.logger.Info("service configured")
return s, nil
}
// Listen starts the service.
func (s *Service) Listen() (err error) {
defer s.Close()
s.hookSignals()
// http rtsp rtmp ws
addr, err := address.Parse(config.Addr(), 554)
if err != nil {
s.logger.Panic(err.Error())
}
s.listen(addr, nil)
// https wss
tlsconf := config.GetTLSConfig()
if tlsconf != nil {
tls, err := tlsconf.Load()
if err == nil {
if tlsAddr, err := address.Parse(tlsconf.ListenAddr, 443); err == nil {
s.listen(tlsAddr, tls)
s.tlsusing = true
}
}
}
s.logger.Infof("service started(%s).", config.Version)
s.logger = xlog.L()
// Block
select {}
}
// listen configures an main listener on a specified address.
func (s *Service) listen(addr *net.TCPAddr, conf *tls.Config) {
// Create new listener
s.logger.Infof("starting the listener, addr = %s.", addr.String())
l, err := listener.New(addr.String(), conf)
if err != nil {
s.logger.Panic(err.Error())
}
// Set the read timeout on our mux listener
timeout := time.Duration(int64(config.NetTimeout()) / 3)
l.SetReadTimeout(timeout)
// Set Error handler
l.HandleError(listener.ErrorHandler(func(err error) bool {
xlog.Warn(err.Error())
return true
}))
// Configure the matchers
l.ServeAsync(rtsp.MatchRTSP(), s.rtsp.Serve)
l.ServeAsync(listener.MatchHTTP(), s.http.Serve)
go l.Serve()
}
// Close closes gracefully the service.,
func (s *Service) Close() {
if s.cancel != nil {
s.cancel()
}
// 停止计划任务
jobs := scheduler.Jobs()
for _, job := range jobs {
job.Cancel()
}
// 清空注册
media.UnregistAll()
// 退出前确保最新数据被存储
route.Flush()
auth.Flush()
}
// OnSignal starts the signal processing and makes su
func (s *Service) hookSignals() {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
go func() {
for sig := range c {
s.onSignal(sig)
}
}()
}
// OnSignal will be called when a OS-level signal is received.
func (s *Service) onSignal(sig os.Signal) {
switch sig {
case syscall.SIGTERM:
fallthrough
case syscall.SIGINT:
s.logger.Warn(fmt.Sprintf("received signal %s, exiting...", sig.String()))
s.Close()
os.Exit(0)
}
}

117
service/streamapis.go Executable file
View File

@@ -0,0 +1,117 @@
// Copyright (c) 2019,CAOHONGJU All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package service
import (
"net/http"
"path"
"github.com/cnotch/ipchub/config"
"github.com/cnotch/ipchub/network/websocket"
"github.com/cnotch/ipchub/provider/auth"
"github.com/cnotch/apirouter"
"github.com/cnotch/ipchub/utils/scan"
)
// 初始化流式访问
func (s *Service) initHTTPStreams(mux *http.ServeMux) {
mux.Handle("/ws/", apirouter.WrapHandler(http.HandlerFunc(s.onWebSocketRequest), apirouter.PreInterceptor(s.streamInterceptor)))
// mux.Handle("/streams/", apirouter.WrapHandler(http.HandlerFunc(s.onStreamsRequest), apirouter.PreInterceptor(s.streamInterceptor)))
}
// websocket 请求处理
func (s *Service) onWebSocketRequest(w http.ResponseWriter, r *http.Request) {
username := r.Header.Get(usernameHeaderKey)
streamPath, ext := extractStreamPathAndExt(r.URL.Path)
_ = ext
if ws, ok := websocket.TryUpgrade(w, r, streamPath, username); ok {
if ws.Subprotocol() == "rtsp" { // rtsp 直连
// rtsp接入
s.rtsp.OnAccept(ws)
return
}
if ws.Subprotocol() == "control" || ws.Subprotocol() == "data" {
// 代理访问
s.wsp.OnAccept(ws)
return
}
// if ext == ".flv" {
// go flv.ConsumeByWebsocket(s.logger, streamPath, r.RemoteAddr, ws)
// return
// }
s.logger.Warnf("websocket sub-protocol is not supported: %s.", ws.Subprotocol())
ws.Close()
}
}
// // streams 请求处理(flv,mu38,ts)
// func (s *Service) onStreamsRequest(w http.ResponseWriter, r *http.Request) {
// // 获取文件后缀和流路径
// streamPath, ext := extractStreamPathAndExt(r.URL.Path)
// s.logger.Debug("http access stream media.",
// xlog.F("path", streamPath),
// xlog.F("ext", ext))
// w.Header().Set("Access-Control-Allow-Origin", "*")
// switch ext {
// case ".flv":
// flv.ConsumeByHTTP(s.logger, streamPath, r.RemoteAddr, w)
// case ".m3u8":
// hls.GetM3u8(s.logger, streamPath, r.RemoteAddr, w)
// case ".ts":
// hls.GetTS(s.logger, streamPath, r.RemoteAddr, w)
// default:
// s.logger.Warnf("request file ext is not supported: %s.", ext)
// http.NotFound(w, r)
// }
// }
func (s *Service) streamInterceptor(w http.ResponseWriter, r *http.Request) bool {
if path.Base(r.URL.Path) == "crossdomain.xml" {
w.Header().Set("Content-Type", "application/xml")
w.Write(crossdomainxml)
return false
}
if !config.Auth() {
// 不启用媒体流访问验证
return true
}
if s.authInterceptor(w, r) {
return permissionInterceptor(w, r)
}
return false
}
// 验证用户是否有权限播放指定的流
func permissionInterceptor(w http.ResponseWriter, r *http.Request) bool {
userName := r.Header.Get(usernameHeaderKey)
u := auth.Get(userName)
streamPath, _ := extractStreamPathAndExt(r.URL.Path)
if u == nil || !u.ValidatePermission(streamPath, auth.PullRight) {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return false
}
return true
}
// 提取请求路径中的流path和格式后缀
func extractStreamPathAndExt(requestPath string) (streamPath, ext string) {
ext = path.Ext(requestPath)
_, substr, _ := scan.NewScanner('/', nil).Scan(requestPath[1:])
streamPath = requestPath[1+len(substr) : len(requestPath)-len(ext)]
return
}

View File

@@ -277,7 +277,7 @@ func (s *Session) onDescribe(resp *rtsp.Response, req *rtsp.Request) {
}
// 从流中取 sdp
sdpRaw := stream.Attr("sdp")
sdpRaw := stream.Sdp()
if len(sdpRaw) == 0 {
resp.StatusCode = rtsp.StatusNotFound
return

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"net"
"strings"
"github.com/emitter-io/address"
)
@@ -18,6 +19,21 @@ func GetIP(addr net.Addr) string {
return s[:i]
}
// GetLocalIP 获取本地IP
func GetLocalIP() []string {
addrs, _ := net.InterfaceAddrs()
ips := []string{}
for _, address := range addrs {
// 检查ip地址判断是否回环地址
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
ips = append(ips, ipnet.IP.String())
}
}
}
return ips
}
// IsLocalhostIP 判断是否为本机IP
func IsLocalhostIP(ip net.IP) bool {
for _, localhost := range loopbackBlocks {