Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2dc0d58ba7 | |||
| cb22ae7833 | |||
| c98b0a83c4 | |||
| 0bae158e41 | |||
| e2b63a4f6c | |||
| 3897f10a4d | |||
| ac80f1470e | |||
| 1fe602679e | |||
| e2c7d06730 | |||
| 2133f5323c | |||
| c10a06d199 | |||
| d053d88ce9 | |||
| 2ce38b4486 | |||
| 44d59b1696 | |||
| 15ec995ecc | |||
| 231cab36b2 | |||
| 640db3029e | |||
| 2836fdae13 | |||
| 964bb225fa | |||
| 5cc32197b8 | |||
| bc1a4ac8e4 | |||
| 158f9d3a08 | |||
| 81cfcf877a | |||
| 96919bf9e3 | |||
| e4359ac217 | |||
| ff18283d11 | |||
| 994e0dc526 | |||
| 7254bd4fbc | |||
| 9f407a754d | |||
| cc97bc33c4 | |||
| 6db4dda535 |
@@ -879,6 +879,7 @@ api:
|
|||||||
listen: ":1984" # default ":1984", HTTP API port ("" - disabled)
|
listen: ":1984" # default ":1984", HTTP API port ("" - disabled)
|
||||||
username: "admin" # default "", Basic auth for WebUI
|
username: "admin" # default "", Basic auth for WebUI
|
||||||
password: "pass" # default "", Basic auth for WebUI
|
password: "pass" # default "", Basic auth for WebUI
|
||||||
|
local_auth: true # default false, Enable auth check for localhost requests
|
||||||
base_path: "/rtc" # default "", API prefix for serving on suburl (/api => /rtc/api)
|
base_path: "/rtc" # default "", API prefix for serving on suburl (/api => /rtc/api)
|
||||||
static_dir: "www" # default "", folder for static files (custom web interface)
|
static_dir: "www" # default "", folder for static files (custom web interface)
|
||||||
origin: "*" # default "", allow CORS requests (only * supported)
|
origin: "*" # default "", allow CORS requests (only * supported)
|
||||||
@@ -1201,6 +1202,27 @@ log:
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> If an attacker gains access to the API, you are in danger. Through the API, an attacker can use insecure sources such as echo and exec. And get full access to your server.
|
||||||
|
|
||||||
|
For maximum (paranoid) security, go2rtc has special settings:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
app:
|
||||||
|
# use only allowed modules
|
||||||
|
modules: [api, rtsp, webrtc, exec, ffmpeg, mjpeg]
|
||||||
|
|
||||||
|
api:
|
||||||
|
# use only allowed API paths
|
||||||
|
allow_paths: [/api, /api/streams, /api/webrtc, /api/frame.jpeg]
|
||||||
|
# enable auth for localhost (used together with username and password)
|
||||||
|
local_auth: true
|
||||||
|
|
||||||
|
exec:
|
||||||
|
# use only allowed exec paths
|
||||||
|
allow_paths: [ffmpeg]
|
||||||
|
```
|
||||||
|
|
||||||
By default, `go2rtc` starts the Web interface on port `1984` and RTSP on port `8554`, as well as uses port `8555` for WebRTC connections. The three ports are accessible from your local network. So anyone on your local network can watch video from your cameras without authorization. The same rule applies to the Home Assistant Add-on.
|
By default, `go2rtc` starts the Web interface on port `1984` and RTSP on port `8554`, as well as uses port `8555` for WebRTC connections. The three ports are accessible from your local network. So anyone on your local network can watch video from your cameras without authorization. The same rule applies to the Home Assistant Add-on.
|
||||||
|
|
||||||
This is not a problem if you trust your local network as much as I do. But you can change this behaviour with a `go2rtc.yaml` config:
|
This is not a problem if you trust your local network as much as I do. But you can change this behaviour with a `go2rtc.yaml` config:
|
||||||
|
|||||||
+17
-3
@@ -7,6 +7,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -23,6 +24,7 @@ func Init() {
|
|||||||
Listen string `yaml:"listen"`
|
Listen string `yaml:"listen"`
|
||||||
Username string `yaml:"username"`
|
Username string `yaml:"username"`
|
||||||
Password string `yaml:"password"`
|
Password string `yaml:"password"`
|
||||||
|
LocalAuth bool `yaml:"local_auth"`
|
||||||
BasePath string `yaml:"base_path"`
|
BasePath string `yaml:"base_path"`
|
||||||
StaticDir string `yaml:"static_dir"`
|
StaticDir string `yaml:"static_dir"`
|
||||||
Origin string `yaml:"origin"`
|
Origin string `yaml:"origin"`
|
||||||
@@ -30,6 +32,8 @@ func Init() {
|
|||||||
TLSCert string `yaml:"tls_cert"`
|
TLSCert string `yaml:"tls_cert"`
|
||||||
TLSKey string `yaml:"tls_key"`
|
TLSKey string `yaml:"tls_key"`
|
||||||
UnixListen string `yaml:"unix_listen"`
|
UnixListen string `yaml:"unix_listen"`
|
||||||
|
|
||||||
|
AllowPaths []string `yaml:"allow_paths"`
|
||||||
} `yaml:"api"`
|
} `yaml:"api"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +47,7 @@ func Init() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allowPaths = cfg.Mod.AllowPaths
|
||||||
basePath = cfg.Mod.BasePath
|
basePath = cfg.Mod.BasePath
|
||||||
log = app.GetLogger("api")
|
log = app.GetLogger("api")
|
||||||
|
|
||||||
@@ -61,7 +66,7 @@ func Init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Mod.Username != "" {
|
if cfg.Mod.Username != "" {
|
||||||
Handler = middlewareAuth(cfg.Mod.Username, cfg.Mod.Password, Handler) // 2nd
|
Handler = middlewareAuth(cfg.Mod.Username, cfg.Mod.Password, cfg.Mod.LocalAuth, Handler) // 2nd
|
||||||
}
|
}
|
||||||
|
|
||||||
if log.Trace().Enabled() {
|
if log.Trace().Enabled() {
|
||||||
@@ -152,6 +157,10 @@ func HandleFunc(pattern string, handler http.HandlerFunc) {
|
|||||||
if len(pattern) == 0 || pattern[0] != '/' {
|
if len(pattern) == 0 || pattern[0] != '/' {
|
||||||
pattern = basePath + "/" + pattern
|
pattern = basePath + "/" + pattern
|
||||||
}
|
}
|
||||||
|
if allowPaths != nil && !slices.Contains(allowPaths, pattern) {
|
||||||
|
log.Trace().Str("path", pattern).Msg("[api] ignore path not in allow_paths")
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Trace().Str("path", pattern).Msg("[api] register path")
|
log.Trace().Str("path", pattern).Msg("[api] register path")
|
||||||
http.HandleFunc(pattern, handler)
|
http.HandleFunc(pattern, handler)
|
||||||
}
|
}
|
||||||
@@ -185,6 +194,7 @@ func Response(w http.ResponseWriter, body any, contentType string) {
|
|||||||
|
|
||||||
const StreamNotFound = "stream not found"
|
const StreamNotFound = "stream not found"
|
||||||
|
|
||||||
|
var allowPaths []string
|
||||||
var basePath string
|
var basePath string
|
||||||
var log zerolog.Logger
|
var log zerolog.Logger
|
||||||
|
|
||||||
@@ -195,9 +205,13 @@ func middlewareLog(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func middlewareAuth(username, password string, next http.Handler) http.Handler {
|
func isLoopback(remoteAddr string) bool {
|
||||||
|
return strings.HasPrefix(remoteAddr, "127.") || strings.HasPrefix(remoteAddr, "[::1]") || remoteAddr == "@"
|
||||||
|
}
|
||||||
|
|
||||||
|
func middlewareAuth(username, password string, localAuth bool, next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !strings.HasPrefix(r.RemoteAddr, "127.") && !strings.HasPrefix(r.RemoteAddr, "[::1]") && r.RemoteAddr != "@" {
|
if localAuth || !isLoopback(r.RemoteAddr) {
|
||||||
user, pass, ok := r.BasicAuth()
|
user, pass, ok := r.BasicAuth()
|
||||||
if !ok || user != username || pass != password {
|
if !ok || user != username || pass != password {
|
||||||
w.Header().Set("Www-Authenticate", `Basic realm="go2rtc"`)
|
w.Header().Set("Www-Authenticate", `Basic realm="go2rtc"`)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
Version string
|
Version string
|
||||||
|
Modules []string
|
||||||
UserAgent string
|
UserAgent string
|
||||||
ConfigPath string
|
ConfigPath string
|
||||||
Info = make(map[string]any)
|
Info = make(map[string]any)
|
||||||
@@ -76,6 +77,16 @@ func Init() {
|
|||||||
if ConfigPath != "" {
|
if ConfigPath != "" {
|
||||||
Logger.Info().Str("path", ConfigPath).Msg("config")
|
Logger.Info().Str("path", ConfigPath).Msg("config")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cfg struct {
|
||||||
|
Mod struct {
|
||||||
|
Modules []string `yaml:"modules"`
|
||||||
|
} `yaml:"app"`
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadConfig(&cfg)
|
||||||
|
|
||||||
|
Modules = cfg.Mod.Modules
|
||||||
}
|
}
|
||||||
|
|
||||||
func readRevisionTime() (revision, vcsTime string) {
|
func readRevisionTime() (revision, vcsTime string) {
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package echo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/internal/app"
|
"github.com/AlexxIT/go2rtc/internal/app"
|
||||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||||
@@ -10,11 +12,25 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Init() {
|
func Init() {
|
||||||
|
var cfg struct {
|
||||||
|
Mod struct {
|
||||||
|
AllowPaths []string `yaml:"allow_paths"`
|
||||||
|
} `yaml:"echo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
app.LoadConfig(&cfg)
|
||||||
|
|
||||||
|
allowPaths := cfg.Mod.AllowPaths
|
||||||
|
|
||||||
log := app.GetLogger("echo")
|
log := app.GetLogger("echo")
|
||||||
|
|
||||||
streams.RedirectFunc("echo", func(url string) (string, error) {
|
streams.RedirectFunc("echo", func(url string) (string, error) {
|
||||||
args := shell.QuoteSplit(url[5:])
|
args := shell.QuoteSplit(url[5:])
|
||||||
|
|
||||||
|
if allowPaths != nil && !slices.Contains(allowPaths, args[0]) {
|
||||||
|
return "", errors.New("echo: bin not in allow_paths: " + args[0])
|
||||||
|
}
|
||||||
|
|
||||||
b, err := exec.Command(args[0], args[1:]...).Output()
|
b, err := exec.Command(args[0], args[1:]...).Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -26,4 +42,5 @@ func Init() {
|
|||||||
|
|
||||||
return string(b), nil
|
return string(b), nil
|
||||||
})
|
})
|
||||||
|
streams.MarkInsecure("echo")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -26,6 +27,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Init() {
|
func Init() {
|
||||||
|
var cfg struct {
|
||||||
|
Mod struct {
|
||||||
|
AllowPaths []string `yaml:"allow_paths"`
|
||||||
|
} `yaml:"exec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
app.LoadConfig(&cfg)
|
||||||
|
|
||||||
|
allowPaths = cfg.Mod.AllowPaths
|
||||||
|
|
||||||
rtsp.HandleFunc(func(conn *pkg.Conn) bool {
|
rtsp.HandleFunc(func(conn *pkg.Conn) bool {
|
||||||
waitersMu.Lock()
|
waitersMu.Lock()
|
||||||
waiter := waiters[conn.URL.Path]
|
waiter := waiters[conn.URL.Path]
|
||||||
@@ -45,10 +56,13 @@ func Init() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
streams.HandleFunc("exec", execHandle)
|
streams.HandleFunc("exec", execHandle)
|
||||||
|
streams.MarkInsecure("exec")
|
||||||
|
|
||||||
log = app.GetLogger("exec")
|
log = app.GetLogger("exec")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var allowPaths []string
|
||||||
|
|
||||||
func execHandle(rawURL string) (prod core.Producer, err error) {
|
func execHandle(rawURL string) (prod core.Producer, err error) {
|
||||||
rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
|
rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
|
||||||
query := streams.ParseQuery(rawQuery)
|
query := streams.ParseQuery(rawQuery)
|
||||||
@@ -73,6 +87,10 @@ func execHandle(rawURL string) (prod core.Producer, err error) {
|
|||||||
debug: log.Debug().Enabled(),
|
debug: log.Debug().Enabled(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if allowPaths != nil && !slices.Contains(allowPaths, cmd.Args[0]) {
|
||||||
|
return nil, errors.New("exec: bin not in allow_paths: " + cmd.Args[0])
|
||||||
|
}
|
||||||
|
|
||||||
if s := query.Get("killsignal"); s != "" {
|
if s := query.Get("killsignal"); s != "" {
|
||||||
sig := syscall.Signal(core.Atoi(s))
|
sig := syscall.Signal(core.Atoi(s))
|
||||||
cmd.Cancel = func() error {
|
cmd.Cancel = func() error {
|
||||||
|
|||||||
@@ -25,4 +25,5 @@ func Init() {
|
|||||||
|
|
||||||
return url, nil
|
return url, nil
|
||||||
})
|
})
|
||||||
|
streams.MarkInsecure("expr")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ func apiStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
// 1. link to go2rtc stream: rtsp://...:8554/{stream_name}
|
// 1. link to go2rtc stream: rtsp://...:8554/{stream_name}
|
||||||
// 2. static link to Hass camera
|
// 2. static link to Hass camera
|
||||||
// 3. dynamic link to Hass camera
|
// 3. dynamic link to Hass camera
|
||||||
if streams.Patch(v.Name, v.Channels.First.Url) != nil {
|
if _, err := streams.Patch(v.Name, v.Channels.First.Url); err == nil {
|
||||||
apiOK(w, r)
|
apiOK(w, r)
|
||||||
} else {
|
} else {
|
||||||
http.Error(w, "", http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// /stream/{id}/channel/0/webrtc
|
// /stream/{id}/channel/0/webrtc
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/internal/api"
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
@@ -37,8 +38,13 @@ func Init() {
|
|||||||
api.HandleFunc("/streams", apiOK)
|
api.HandleFunc("/streams", apiOK)
|
||||||
api.HandleFunc("/stream/", apiStream)
|
api.HandleFunc("/stream/", apiStream)
|
||||||
|
|
||||||
streams.RedirectFunc("hass", func(url string) (string, error) {
|
streams.RedirectFunc("hass", func(rawURL string) (string, error) {
|
||||||
if location := entities[url[5:]]; location != "" {
|
rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
|
||||||
|
|
||||||
|
if location := entities[rawURL[5:]]; location != "" {
|
||||||
|
if rawQuery != "" {
|
||||||
|
return location + "#" + rawQuery, nil
|
||||||
|
}
|
||||||
return location, nil
|
return location, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func handlerWSHLS(tr *ws.Transport, msg *ws.Message) error {
|
func handlerWSHLS(tr *ws.Transport, msg *ws.Message) error {
|
||||||
stream := streams.GetOrPatch(tr.Request.URL.Query())
|
stream, _ := streams.GetOrPatch(tr.Request.URL.Query())
|
||||||
if stream == nil {
|
if stream == nil {
|
||||||
return errors.New(api.StreamNotFound)
|
return errors.New(api.StreamNotFound)
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-38
@@ -3,6 +3,7 @@ package homekit
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,56 +15,93 @@ import (
|
|||||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||||
)
|
)
|
||||||
|
|
||||||
func apiHandler(w http.ResponseWriter, r *http.Request) {
|
func apiDiscovery(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sources, err := discovery()
|
||||||
|
if err != nil {
|
||||||
|
api.Error(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
urls := findHomeKitURLs()
|
||||||
|
for id, u := range urls {
|
||||||
|
deviceID := u.Query().Get("device_id")
|
||||||
|
for _, source := range sources {
|
||||||
|
if strings.Contains(source.URL, deviceID) {
|
||||||
|
source.Location = id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, source := range sources {
|
||||||
|
if source.Location == "" {
|
||||||
|
source.Location = " "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.ResponseSources(w, sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiHomekit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case "GET":
|
case "GET":
|
||||||
sources, err := discovery()
|
if id := r.Form.Get("id"); id != "" {
|
||||||
if err != nil {
|
api.ResponsePrettyJSON(w, servers[id])
|
||||||
api.Error(w, err)
|
} else {
|
||||||
return
|
api.ResponsePrettyJSON(w, servers)
|
||||||
}
|
}
|
||||||
|
|
||||||
urls := findHomeKitURLs()
|
|
||||||
for id, u := range urls {
|
|
||||||
deviceID := u.Query().Get("device_id")
|
|
||||||
for _, source := range sources {
|
|
||||||
if strings.Contains(source.URL, deviceID) {
|
|
||||||
source.Location = id
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, source := range sources {
|
|
||||||
if source.Location == "" {
|
|
||||||
source.Location = " "
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
api.ResponseSources(w, sources)
|
|
||||||
|
|
||||||
case "POST":
|
case "POST":
|
||||||
if err := r.ParseMultipartForm(1024); err != nil {
|
id := r.Form.Get("id")
|
||||||
api.Error(w, err)
|
rawURL := r.Form.Get("src") + "&pin=" + r.Form.Get("pin")
|
||||||
return
|
if err := apiPair(id, rawURL); err != nil {
|
||||||
}
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
||||||
if err := apiPair(r.Form.Get("id"), r.Form.Get("url")); err != nil {
|
|
||||||
api.Error(w, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "DELETE":
|
case "DELETE":
|
||||||
if err := r.ParseMultipartForm(1024); err != nil {
|
id := r.Form.Get("id")
|
||||||
api.Error(w, err)
|
if err := apiUnpair(id); err != nil {
|
||||||
return
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
}
|
|
||||||
|
|
||||||
if err := apiUnpair(r.Form.Get("id")); err != nil {
|
|
||||||
api.Error(w, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func apiHomekitAccessories(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.URL.Query().Get("id")
|
||||||
|
stream := streams.Get(id)
|
||||||
|
if stream == nil {
|
||||||
|
http.Error(w, "", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rawURL := findHomeKitURL(stream.Sources())
|
||||||
|
if rawURL == "" {
|
||||||
|
http.Error(w, "", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := hap.Dial(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
res, err := client.Get(hap.PathAccessories)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", api.MimeJSON)
|
||||||
|
_, _ = io.Copy(w, res.Body)
|
||||||
|
}
|
||||||
|
|
||||||
func discovery() ([]*api.Source, error) {
|
func discovery() ([]*api.Source, error) {
|
||||||
var sources []*api.Source
|
var sources []*api.Source
|
||||||
|
|
||||||
|
|||||||
+28
-52
@@ -2,8 +2,6 @@ package homekit
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -35,12 +33,15 @@ func Init() {
|
|||||||
|
|
||||||
streams.HandleFunc("homekit", streamHandler)
|
streams.HandleFunc("homekit", streamHandler)
|
||||||
|
|
||||||
api.HandleFunc("api/homekit", apiHandler)
|
api.HandleFunc("api/homekit", apiHomekit)
|
||||||
|
api.HandleFunc("api/homekit/accessories", apiHomekitAccessories)
|
||||||
|
api.HandleFunc("api/discovery/homekit", apiDiscovery)
|
||||||
|
|
||||||
if cfg.Mod == nil {
|
if cfg.Mod == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hosts = map[string]*server{}
|
||||||
servers = map[string]*server{}
|
servers = map[string]*server{}
|
||||||
var entries []*mdns.ServiceEntry
|
var entries []*mdns.ServiceEntry
|
||||||
|
|
||||||
@@ -66,33 +67,14 @@ func Init() {
|
|||||||
|
|
||||||
srv := &server{
|
srv := &server{
|
||||||
stream: id,
|
stream: id,
|
||||||
srtp: srtp.Server,
|
|
||||||
pairings: conf.Pairings,
|
pairings: conf.Pairings,
|
||||||
}
|
}
|
||||||
|
|
||||||
srv.hap = &hap.Server{
|
srv.hap = &hap.Server{
|
||||||
Pin: pin,
|
Pin: pin,
|
||||||
DeviceID: deviceID,
|
DeviceID: deviceID,
|
||||||
DevicePrivate: calcDevicePrivate(conf.DevicePrivate, id),
|
DevicePrivate: calcDevicePrivate(conf.DevicePrivate, id),
|
||||||
GetPair: srv.GetPair,
|
GetClientPublic: srv.GetPair,
|
||||||
AddPair: srv.AddPair,
|
|
||||||
Handler: homekit.ServerHandler(srv),
|
|
||||||
}
|
|
||||||
|
|
||||||
if url := findHomeKitURL(stream.Sources()); url != "" {
|
|
||||||
// 1. Act as transparent proxy for HomeKit camera
|
|
||||||
dial := func() (net.Conn, error) {
|
|
||||||
client, err := homekit.Dial(url, srtp.Server)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return client.Conn(), nil
|
|
||||||
}
|
|
||||||
srv.hap.Handler = homekit.ProxyHandler(srv, dial)
|
|
||||||
} else {
|
|
||||||
// 2. Act as basic HomeKit camera
|
|
||||||
srv.accessory = camera.NewAccessory("AlexxIT", "go2rtc", name, "-", app.Version)
|
|
||||||
srv.hap.Handler = homekit.ServerHandler(srv)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
srv.mdns = &mdns.ServiceEntry{
|
srv.mdns = &mdns.ServiceEntry{
|
||||||
@@ -114,15 +96,24 @@ func Init() {
|
|||||||
|
|
||||||
srv.UpdateStatus()
|
srv.UpdateStatus()
|
||||||
|
|
||||||
|
if url := findHomeKitURL(stream.Sources()); url != "" {
|
||||||
|
// 1. Act as transparent proxy for HomeKit camera
|
||||||
|
srv.proxyURL = url
|
||||||
|
} else {
|
||||||
|
// 2. Act as basic HomeKit camera
|
||||||
|
srv.accessory = camera.NewAccessory("AlexxIT", "go2rtc", name, "-", app.Version)
|
||||||
|
}
|
||||||
|
|
||||||
host := srv.mdns.Host(mdns.ServiceHAP)
|
host := srv.mdns.Host(mdns.ServiceHAP)
|
||||||
servers[host] = srv
|
hosts[host] = srv
|
||||||
|
servers[id] = srv
|
||||||
|
|
||||||
|
log.Trace().Msgf("[homekit] new server: %s", srv.mdns)
|
||||||
}
|
}
|
||||||
|
|
||||||
api.HandleFunc(hap.PathPairSetup, hapHandler)
|
api.HandleFunc(hap.PathPairSetup, hapHandler)
|
||||||
api.HandleFunc(hap.PathPairVerify, hapHandler)
|
api.HandleFunc(hap.PathPairVerify, hapHandler)
|
||||||
|
|
||||||
log.Trace().Msgf("[homekit] mdns: %s", entries)
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := mdns.Serve(mdns.ServiceHAP, entries); err != nil {
|
if err := mdns.Serve(mdns.ServiceHAP, entries); err != nil {
|
||||||
log.Error().Err(err).Caller().Send()
|
log.Error().Err(err).Caller().Send()
|
||||||
@@ -131,6 +122,7 @@ func Init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var log zerolog.Logger
|
var log zerolog.Logger
|
||||||
|
var hosts map[string]*server
|
||||||
var servers map[string]*server
|
var servers map[string]*server
|
||||||
|
|
||||||
func streamHandler(rawURL string) (core.Producer, error) {
|
func streamHandler(rawURL string) (core.Producer, error) {
|
||||||
@@ -142,6 +134,8 @@ func streamHandler(rawURL string) (core.Producer, error) {
|
|||||||
client, err := homekit.Dial(rawURL, srtp.Server)
|
client, err := homekit.Dial(rawURL, srtp.Server)
|
||||||
if client != nil && rawQuery != "" {
|
if client != nil && rawQuery != "" {
|
||||||
query := streams.ParseQuery(rawQuery)
|
query := streams.ParseQuery(rawQuery)
|
||||||
|
client.MaxWidth = core.Atoi(query.Get("maxwidth"))
|
||||||
|
client.MaxHeight = core.Atoi(query.Get("maxheight"))
|
||||||
client.Bitrate = parseBitrate(query.Get("bitrate"))
|
client.Bitrate = parseBitrate(query.Get("bitrate"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,45 +143,27 @@ func streamHandler(rawURL string) (core.Producer, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resolve(host string) *server {
|
func resolve(host string) *server {
|
||||||
if len(servers) == 1 {
|
if len(hosts) == 1 {
|
||||||
for _, srv := range servers {
|
for _, srv := range hosts {
|
||||||
return srv
|
return srv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if srv, ok := servers[host]; ok {
|
if srv, ok := hosts[host]; ok {
|
||||||
return srv
|
return srv
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func hapHandler(w http.ResponseWriter, r *http.Request) {
|
func hapHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
conn, rw, err := w.(http.Hijacker).Hijack()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
// Can support multiple HomeKit cameras on single port ONLY for Apple devices.
|
// Can support multiple HomeKit cameras on single port ONLY for Apple devices.
|
||||||
// Doesn't support Home Assistant and any other open source projects
|
// Doesn't support Home Assistant and any other open source projects
|
||||||
// because they don't send the host header in requests.
|
// because they don't send the host header in requests.
|
||||||
srv := resolve(r.Host)
|
srv := resolve(r.Host)
|
||||||
if srv == nil {
|
if srv == nil {
|
||||||
log.Error().Msg("[homekit] unknown host: " + r.Host)
|
log.Error().Msg("[homekit] unknown host: " + r.Host)
|
||||||
_ = hap.WriteBackoff(rw)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
srv.Handle(w, r)
|
||||||
switch r.RequestURI {
|
|
||||||
case hap.PathPairSetup:
|
|
||||||
err = srv.hap.PairSetup(r, rw, conn)
|
|
||||||
case hap.PathPairVerify:
|
|
||||||
err = srv.hap.PairVerify(r, rw, conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil && err != io.EOF {
|
|
||||||
log.Error().Err(err).Caller().Send()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func findHomeKitURL(sources []string) string {
|
func findHomeKitURL(sources []string) string {
|
||||||
@@ -203,7 +179,7 @@ func findHomeKitURL(sources []string) string {
|
|||||||
if strings.HasPrefix(url, "hass") {
|
if strings.HasPrefix(url, "hass") {
|
||||||
location, _ := streams.Location(url)
|
location, _ := streams.Location(url)
|
||||||
if strings.HasPrefix(location, "homekit") {
|
if strings.HasPrefix(location, "homekit") {
|
||||||
return url
|
return location
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+208
-95
@@ -4,10 +4,16 @@ import (
|
|||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/sha512"
|
"crypto/sha512"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/internal/app"
|
"github.com/AlexxIT/go2rtc/internal/app"
|
||||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
|
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
|
||||||
@@ -16,23 +22,133 @@ import (
|
|||||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/camera"
|
"github.com/AlexxIT/go2rtc/pkg/hap/camera"
|
||||||
|
"github.com/AlexxIT/go2rtc/pkg/hap/hds"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/homekit"
|
"github.com/AlexxIT/go2rtc/pkg/homekit"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/magic"
|
"github.com/AlexxIT/go2rtc/pkg/magic"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/srtp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type server struct {
|
type server struct {
|
||||||
stream string // stream name from YAML
|
hap *hap.Server // server for HAP connection and encryption
|
||||||
hap *hap.Server // server for HAP connection and encryption
|
mdns *mdns.ServiceEntry
|
||||||
mdns *mdns.ServiceEntry
|
|
||||||
srtp *srtp.Server
|
|
||||||
accessory *hap.Accessory // HAP accessory
|
|
||||||
pairings []string // pairings list
|
|
||||||
|
|
||||||
streams map[string]*homekit.Consumer
|
pairings []string // pairings list
|
||||||
consumer *homekit.Consumer
|
conns []any
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
accessory *hap.Accessory // HAP accessory
|
||||||
|
consumer *homekit.Consumer
|
||||||
|
proxyURL string
|
||||||
|
stream string // stream name from YAML
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) MarshalJSON() ([]byte, error) {
|
||||||
|
v := struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
Paired int `json:"paired"`
|
||||||
|
Conns []any `json:"connections"`
|
||||||
|
}{
|
||||||
|
Name: s.mdns.Name,
|
||||||
|
DeviceID: s.mdns.Info[hap.TXTDeviceID],
|
||||||
|
Paired: len(s.pairings),
|
||||||
|
Conns: s.conns,
|
||||||
|
}
|
||||||
|
return json.Marshal(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) Handle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, rw, err := w.(http.Hijacker).Hijack()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Fix reading from Body after Hijack.
|
||||||
|
r.Body = io.NopCloser(rw)
|
||||||
|
|
||||||
|
switch r.RequestURI {
|
||||||
|
case hap.PathPairSetup:
|
||||||
|
id, key, err := s.hap.PairSetup(r, rw)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Caller().Send()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.AddPair(id, key, hap.PermissionAdmin)
|
||||||
|
|
||||||
|
case hap.PathPairVerify:
|
||||||
|
id, key, err := s.hap.PairVerify(r, rw)
|
||||||
|
if err != nil {
|
||||||
|
log.Debug().Err(err).Caller().Send()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().Str("stream", s.stream).Str("client_id", id).Msgf("[homekit] %s: new conn", conn.RemoteAddr())
|
||||||
|
|
||||||
|
controller, err := hap.NewConn(conn, rw, key, false)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Caller().Send()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.AddConn(controller)
|
||||||
|
defer s.DelConn(controller)
|
||||||
|
|
||||||
|
var handler homekit.HandlerFunc
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case s.accessory != nil:
|
||||||
|
handler = homekit.ServerHandler(s)
|
||||||
|
case s.proxyURL != "":
|
||||||
|
client, err := hap.Dial(s.proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Caller().Send()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handler = homekit.ProxyHandler(s, client.Conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If your iPhone goes to sleep, it will be an EOF error.
|
||||||
|
if err = handler(controller); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
log.Error().Err(err).Caller().Send()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type logger struct {
|
||||||
|
v any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l logger) String() string {
|
||||||
|
switch v := l.v.(type) {
|
||||||
|
case *hap.Conn:
|
||||||
|
return "hap " + v.RemoteAddr().String()
|
||||||
|
case *hds.Conn:
|
||||||
|
return "hds " + v.RemoteAddr().String()
|
||||||
|
case *homekit.Consumer:
|
||||||
|
return "rtp " + v.RemoteAddr
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) AddConn(v any) {
|
||||||
|
log.Trace().Str("stream", s.stream).Msgf("[homekit] add conn %s", logger{v})
|
||||||
|
s.mu.Lock()
|
||||||
|
s.conns = append(s.conns, v)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) DelConn(v any) {
|
||||||
|
log.Trace().Str("stream", s.stream).Msgf("[homekit] del conn %s", logger{v})
|
||||||
|
s.mu.Lock()
|
||||||
|
if i := slices.Index(s.conns, v); i >= 0 {
|
||||||
|
s.conns = slices.Delete(s.conns, i, i+1)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) UpdateStatus() {
|
func (s *server) UpdateStatus() {
|
||||||
@@ -44,12 +160,68 @@ func (s *server) UpdateStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *server) pairIndex(id string) int {
|
||||||
|
id = "client_id=" + id
|
||||||
|
for i, pairing := range s.pairings {
|
||||||
|
if strings.HasPrefix(pairing, id) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) GetPair(id string) []byte {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if i := s.pairIndex(id); i >= 0 {
|
||||||
|
query, _ := url.ParseQuery(s.pairings[i])
|
||||||
|
b, _ := hex.DecodeString(query.Get("client_public"))
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) AddPair(id string, public []byte, permissions byte) {
|
||||||
|
log.Debug().Str("stream", s.stream).Msgf("[homekit] add pair id=%s public=%x perm=%d", id, public, permissions)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.pairIndex(id) < 0 {
|
||||||
|
s.pairings = append(s.pairings, fmt.Sprintf(
|
||||||
|
"client_id=%s&client_public=%x&permissions=%d", id, public, permissions,
|
||||||
|
))
|
||||||
|
s.UpdateStatus()
|
||||||
|
s.PatchConfig()
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) DelPair(id string) {
|
||||||
|
log.Debug().Str("stream", s.stream).Msgf("[homekit] del pair id=%s", id)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
if i := s.pairIndex(id); i >= 0 {
|
||||||
|
s.pairings = append(s.pairings[:i], s.pairings[i+1:]...)
|
||||||
|
s.UpdateStatus()
|
||||||
|
s.PatchConfig()
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) PatchConfig() {
|
||||||
|
if err := app.PatchConfig([]string{"homekit", s.stream, "pairings"}, s.pairings); err != nil {
|
||||||
|
log.Error().Err(err).Msgf(
|
||||||
|
"[homekit] can't save %s pairings=%v", s.stream, s.pairings,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *server) GetAccessories(_ net.Conn) []*hap.Accessory {
|
func (s *server) GetAccessories(_ net.Conn) []*hap.Accessory {
|
||||||
return []*hap.Accessory{s.accessory}
|
return []*hap.Accessory{s.accessory}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any {
|
func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any {
|
||||||
log.Trace().Msgf("[homekit] %s: get char aid=%d iid=0x%x", conn.RemoteAddr(), aid, iid)
|
log.Trace().Str("stream", s.stream).Msgf("[homekit] get char aid=%d iid=0x%x", aid, iid)
|
||||||
|
|
||||||
char := s.accessory.GetCharacterByID(iid)
|
char := s.accessory.GetCharacterByID(iid)
|
||||||
if char == nil {
|
if char == nil {
|
||||||
@@ -59,11 +231,12 @@ func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any {
|
|||||||
|
|
||||||
switch char.Type {
|
switch char.Type {
|
||||||
case camera.TypeSetupEndpoints:
|
case camera.TypeSetupEndpoints:
|
||||||
if s.consumer == nil {
|
consumer := s.consumer
|
||||||
|
if consumer == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
answer := s.consumer.GetAnswer()
|
answer := consumer.GetAnswer()
|
||||||
v, err := tlv8.MarshalBase64(answer)
|
v, err := tlv8.MarshalBase64(answer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -76,7 +249,7 @@ func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value any) {
|
func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value any) {
|
||||||
log.Trace().Msgf("[homekit] %s: set char aid=%d iid=0x%x value=%v", conn.RemoteAddr(), aid, iid, value)
|
log.Trace().Str("stream", s.stream).Msgf("[homekit] set char aid=%d iid=0x%x value=%v", aid, iid, value)
|
||||||
|
|
||||||
char := s.accessory.GetCharacterByID(iid)
|
char := s.accessory.GetCharacterByID(iid)
|
||||||
if char == nil {
|
if char == nil {
|
||||||
@@ -86,61 +259,64 @@ func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value a
|
|||||||
|
|
||||||
switch char.Type {
|
switch char.Type {
|
||||||
case camera.TypeSetupEndpoints:
|
case camera.TypeSetupEndpoints:
|
||||||
var offer camera.SetupEndpoints
|
var offer camera.SetupEndpointsRequest
|
||||||
if err := tlv8.UnmarshalBase64(value, &offer); err != nil {
|
if err := tlv8.UnmarshalBase64(value, &offer); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.consumer = homekit.NewConsumer(conn, srtp2.Server)
|
consumer := homekit.NewConsumer(conn, srtp2.Server)
|
||||||
s.consumer.SetOffer(&offer)
|
consumer.SetOffer(&offer)
|
||||||
|
s.consumer = consumer
|
||||||
|
|
||||||
case camera.TypeSelectedStreamConfiguration:
|
case camera.TypeSelectedStreamConfiguration:
|
||||||
var conf camera.SelectedStreamConfig
|
var conf camera.SelectedStreamConfiguration
|
||||||
if err := tlv8.UnmarshalBase64(value, &conf); err != nil {
|
if err := tlv8.UnmarshalBase64(value, &conf); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Trace().Msgf("[homekit] %s stream id=%x cmd=%d", conn.RemoteAddr(), conf.Control.SessionID, conf.Control.Command)
|
log.Trace().Str("stream", s.stream).Msgf("[homekit] stream id=%x cmd=%d", conf.Control.SessionID, conf.Control.Command)
|
||||||
|
|
||||||
switch conf.Control.Command {
|
switch conf.Control.Command {
|
||||||
case camera.SessionCommandEnd:
|
case camera.SessionCommandEnd:
|
||||||
if consumer := s.streams[conf.Control.SessionID]; consumer != nil {
|
for _, consumer := range s.conns {
|
||||||
_ = consumer.Stop()
|
if consumer, ok := consumer.(*homekit.Consumer); ok {
|
||||||
|
if consumer.SessionID() == conf.Control.SessionID {
|
||||||
|
_ = consumer.Stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case camera.SessionCommandStart:
|
case camera.SessionCommandStart:
|
||||||
if s.consumer == nil {
|
consumer := s.consumer
|
||||||
|
if consumer == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !s.consumer.SetConfig(&conf) {
|
if !consumer.SetConfig(&conf) {
|
||||||
log.Warn().Msgf("[homekit] wrong config")
|
log.Warn().Msgf("[homekit] wrong config")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.streams == nil {
|
s.AddConn(consumer)
|
||||||
s.streams = map[string]*homekit.Consumer{}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.streams[conf.Control.SessionID] = s.consumer
|
|
||||||
|
|
||||||
stream := streams.Get(s.stream)
|
stream := streams.Get(s.stream)
|
||||||
if err := stream.AddConsumer(s.consumer); err != nil {
|
if err := stream.AddConsumer(consumer); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
_, _ = s.consumer.WriteTo(nil)
|
_, _ = consumer.WriteTo(nil)
|
||||||
stream.RemoveConsumer(s.consumer)
|
stream.RemoveConsumer(consumer)
|
||||||
|
|
||||||
delete(s.streams, conf.Control.SessionID)
|
s.DelConn(consumer)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) GetImage(conn net.Conn, width, height int) []byte {
|
func (s *server) GetImage(conn net.Conn, width, height int) []byte {
|
||||||
log.Trace().Msgf("[homekit] %s: get image width=%d height=%d", conn.RemoteAddr(), width, height)
|
log.Trace().Str("stream", s.stream).Msgf("[homekit] get image width=%d height=%d", width, height)
|
||||||
|
|
||||||
stream := streams.Get(s.stream)
|
stream := streams.Get(s.stream)
|
||||||
cons := magic.NewKeyframe()
|
cons := magic.NewKeyframe()
|
||||||
@@ -166,69 +342,6 @@ func (s *server) GetImage(conn net.Conn, width, height int) []byte {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) GetPair(conn net.Conn, id string) []byte {
|
|
||||||
log.Trace().Msgf("[homekit] %s: get pair id=%s", conn.RemoteAddr(), id)
|
|
||||||
|
|
||||||
for _, pairing := range s.pairings {
|
|
||||||
if !strings.Contains(pairing, id) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
query, err := url.ParseQuery(pairing)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if query.Get("client_id") != id {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
s := query.Get("client_public")
|
|
||||||
b, _ := hex.DecodeString(s)
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) AddPair(conn net.Conn, id string, public []byte, permissions byte) {
|
|
||||||
log.Trace().Msgf("[homekit] %s: add pair id=%s public=%x perm=%d", conn.RemoteAddr(), id, public, permissions)
|
|
||||||
|
|
||||||
query := url.Values{
|
|
||||||
"client_id": []string{id},
|
|
||||||
"client_public": []string{hex.EncodeToString(public)},
|
|
||||||
"permissions": []string{string('0' + permissions)},
|
|
||||||
}
|
|
||||||
if s.GetPair(conn, id) == nil {
|
|
||||||
s.pairings = append(s.pairings, query.Encode())
|
|
||||||
s.UpdateStatus()
|
|
||||||
s.PatchConfig()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) DelPair(conn net.Conn, id string) {
|
|
||||||
log.Trace().Msgf("[homekit] %s: del pair id=%s", conn.RemoteAddr(), id)
|
|
||||||
|
|
||||||
id = "client_id=" + id
|
|
||||||
for i, pairing := range s.pairings {
|
|
||||||
if !strings.Contains(pairing, id) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
s.pairings = append(s.pairings[:i], s.pairings[i+1:]...)
|
|
||||||
s.UpdateStatus()
|
|
||||||
s.PatchConfig()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) PatchConfig() {
|
|
||||||
if err := app.PatchConfig([]string{"homekit", s.stream, "pairings"}, s.pairings); err != nil {
|
|
||||||
log.Error().Err(err).Msgf(
|
|
||||||
"[homekit] can't save %s pairings=%v", s.stream, s.pairings,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func calcName(name, seed string) string {
|
func calcName(name, seed string) string {
|
||||||
if name != "" {
|
if name != "" {
|
||||||
return name
|
return name
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func Init() {
|
|||||||
var log zerolog.Logger
|
var log zerolog.Logger
|
||||||
|
|
||||||
func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||||
stream := streams.GetOrPatch(r.URL.Query())
|
stream, _ := streams.GetOrPatch(r.URL.Query())
|
||||||
if stream == nil {
|
if stream == nil {
|
||||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||||
return
|
return
|
||||||
@@ -145,7 +145,7 @@ func inputMjpeg(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handlerWS(tr *ws.Transport, _ *ws.Message) error {
|
func handlerWS(tr *ws.Transport, _ *ws.Message) error {
|
||||||
stream := streams.GetOrPatch(tr.Request.URL.Query())
|
stream, _ := streams.GetOrPatch(tr.Request.URL.Query())
|
||||||
if stream == nil {
|
if stream == nil {
|
||||||
return errors.New(api.StreamNotFound)
|
return errors.New(api.StreamNotFound)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -91,7 +91,7 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stream := streams.GetOrPatch(query)
|
stream, _ := streams.GetOrPatch(query)
|
||||||
if stream == nil {
|
if stream == nil {
|
||||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||||
return
|
return
|
||||||
|
|||||||
+2
-2
@@ -11,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error {
|
func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error {
|
||||||
stream := streams.GetOrPatch(tr.Request.URL.Query())
|
stream, _ := streams.GetOrPatch(tr.Request.URL.Query())
|
||||||
if stream == nil {
|
if stream == nil {
|
||||||
return errors.New(api.StreamNotFound)
|
return errors.New(api.StreamNotFound)
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handlerWSMP4(tr *ws.Transport, msg *ws.Message) error {
|
func handlerWSMP4(tr *ws.Transport, msg *ws.Message) error {
|
||||||
stream := streams.GetOrPatch(tr.Request.URL.Query())
|
stream, _ := streams.GetOrPatch(tr.Request.URL.Query())
|
||||||
if stream == nil {
|
if stream == nil {
|
||||||
return errors.New(api.StreamNotFound)
|
return errors.New(api.StreamNotFound)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ func streamOnvif(rawURL string) (core.Producer, error) {
|
|||||||
|
|
||||||
log.Debug().Msgf("[onvif] new uri=%s", uri)
|
log.Debug().Msgf("[onvif] new uri=%s", uri)
|
||||||
|
|
||||||
|
if err = streams.Validate(uri); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return streams.GetProducer(uri)
|
return streams.GetProducer(uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ func apiStreams(w http.ResponseWriter, r *http.Request) {
|
|||||||
name = src
|
name = src
|
||||||
}
|
}
|
||||||
|
|
||||||
if New(name, query["src"]...) == nil {
|
if _, err := New(name, query["src"]...); err != nil {
|
||||||
http.Error(w, "", http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,8 +69,8 @@ func apiStreams(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass
|
// support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass
|
||||||
if Patch(name, src) == nil {
|
if _, err := Patch(name, src); err != nil {
|
||||||
http.Error(w, "", http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "POST":
|
case "POST":
|
||||||
@@ -176,3 +176,7 @@ func apiPreload(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func apiSchemes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
api.ResponseJSON(w, SupportedSchemes())
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package streams
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApiSchemes(t *testing.T) {
|
||||||
|
// Setup: Register some test handlers and redirects
|
||||||
|
HandleFunc("rtsp", func(url string) (core.Producer, error) { return nil, nil })
|
||||||
|
HandleFunc("rtmp", func(url string) (core.Producer, error) { return nil, nil })
|
||||||
|
RedirectFunc("http", func(url string) (string, error) { return "", nil })
|
||||||
|
|
||||||
|
t.Run("GET request returns schemes", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/api/schemes", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
apiSchemes(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
require.Equal(t, "application/json", w.Header().Get("Content-Type"))
|
||||||
|
|
||||||
|
var schemes []string
|
||||||
|
err := json.Unmarshal(w.Body.Bytes(), &schemes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, schemes)
|
||||||
|
|
||||||
|
// Check that our test schemes are in the response
|
||||||
|
require.Contains(t, schemes, "rtsp")
|
||||||
|
require.Contains(t, schemes, "rtmp")
|
||||||
|
require.Contains(t, schemes, "http")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApiSchemesNoDuplicates(t *testing.T) {
|
||||||
|
// Setup: Register a scheme in both handlers and redirects
|
||||||
|
HandleFunc("duplicate", func(url string) (core.Producer, error) { return nil, nil })
|
||||||
|
RedirectFunc("duplicate", func(url string) (string, error) { return "", nil })
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/schemes", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
apiSchemes(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var schemes []string
|
||||||
|
err := json.Unmarshal(w.Body.Bytes(), &schemes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Count occurrences of "duplicate"
|
||||||
|
count := 0
|
||||||
|
for _, scheme := range schemes {
|
||||||
|
if scheme == "duplicate" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should only appear once
|
||||||
|
require.Equal(t, 1, count, "scheme 'duplicate' should appear exactly once")
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package streams
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||||
@@ -15,6 +16,21 @@ func HandleFunc(scheme string, handler Handler) {
|
|||||||
handlers[scheme] = handler
|
handlers[scheme] = handler
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SupportedSchemes() []string {
|
||||||
|
uniqueKeys := make(map[string]struct{}, len(handlers)+len(redirects))
|
||||||
|
for scheme := range handlers {
|
||||||
|
uniqueKeys[scheme] = struct{}{}
|
||||||
|
}
|
||||||
|
for scheme := range redirects {
|
||||||
|
uniqueKeys[scheme] = struct{}{}
|
||||||
|
}
|
||||||
|
resultKeys := make([]string, 0, len(uniqueKeys))
|
||||||
|
for key := range uniqueKeys {
|
||||||
|
resultKeys = append(resultKeys, key)
|
||||||
|
}
|
||||||
|
return resultKeys
|
||||||
|
}
|
||||||
|
|
||||||
func HasProducer(url string) bool {
|
func HasProducer(url string) bool {
|
||||||
if i := strings.IndexByte(url, ':'); i > 0 {
|
if i := strings.IndexByte(url, ':'); i > 0 {
|
||||||
scheme := url[:i]
|
scheme := url[:i]
|
||||||
@@ -95,3 +111,24 @@ func GetConsumer(url string) (core.Consumer, func(), error) {
|
|||||||
|
|
||||||
return nil, nil, errors.New("streams: unsupported scheme: " + url)
|
return nil, nil, errors.New("streams: unsupported scheme: " + url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var insecure = map[string]bool{}
|
||||||
|
|
||||||
|
func MarkInsecure(scheme string) {
|
||||||
|
insecure[scheme] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var sanitize = regexp.MustCompile(`\s`)
|
||||||
|
|
||||||
|
func Validate(source string) error {
|
||||||
|
// TODO: Review the entire logic of insecure sources
|
||||||
|
if i := strings.IndexByte(source, ':'); i > 0 {
|
||||||
|
if insecure[source[:i]] {
|
||||||
|
return errors.New("streams: source from insecure producer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sanitize.MatchString(source) {
|
||||||
|
return errors.New("streams: source with spaces may be insecure")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
+20
-28
@@ -3,7 +3,6 @@ package streams
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -30,6 +29,7 @@ func Init() {
|
|||||||
api.HandleFunc("api/streams", apiStreams)
|
api.HandleFunc("api/streams", apiStreams)
|
||||||
api.HandleFunc("api/streams.dot", apiStreamsDOT)
|
api.HandleFunc("api/streams.dot", apiStreamsDOT)
|
||||||
api.HandleFunc("api/preload", apiPreload)
|
api.HandleFunc("api/preload", apiPreload)
|
||||||
|
api.HandleFunc("api/schemes", apiSchemes)
|
||||||
|
|
||||||
if cfg.Publish == nil && cfg.Preload == nil {
|
if cfg.Publish == nil && cfg.Preload == nil {
|
||||||
return
|
return
|
||||||
@@ -50,20 +50,14 @@ func Init() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var sanitize = regexp.MustCompile(`\s`)
|
func New(name string, sources ...string) (*Stream, error) {
|
||||||
|
|
||||||
// Validate - not allow creating dynamic streams with spaces in the source
|
|
||||||
func Validate(source string) error {
|
|
||||||
if sanitize.MatchString(source) {
|
|
||||||
return errors.New("streams: invalid dynamic source")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(name string, sources ...string) *Stream {
|
|
||||||
for _, source := range sources {
|
for _, source := range sources {
|
||||||
if Validate(source) != nil {
|
if !HasProducer(source) {
|
||||||
return nil
|
return nil, errors.New("streams: source not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Validate(source); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,10 +67,10 @@ func New(name string, sources ...string) *Stream {
|
|||||||
streams[name] = stream
|
streams[name] = stream
|
||||||
streamsMu.Unlock()
|
streamsMu.Unlock()
|
||||||
|
|
||||||
return stream
|
return stream, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Patch(name string, source string) *Stream {
|
func Patch(name string, source string) (*Stream, error) {
|
||||||
streamsMu.Lock()
|
streamsMu.Lock()
|
||||||
defer streamsMu.Unlock()
|
defer streamsMu.Unlock()
|
||||||
|
|
||||||
@@ -88,7 +82,7 @@ func Patch(name string, source string) *Stream {
|
|||||||
// link (alias) streams[name] to streams[rtspName]
|
// link (alias) streams[name] to streams[rtspName]
|
||||||
streams[name] = stream
|
streams[name] = stream
|
||||||
}
|
}
|
||||||
return stream
|
return stream, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,46 +91,44 @@ func Patch(name string, source string) *Stream {
|
|||||||
// link (alias) streams[name] to streams[source]
|
// link (alias) streams[name] to streams[source]
|
||||||
streams[name] = stream
|
streams[name] = stream
|
||||||
}
|
}
|
||||||
return stream
|
return stream, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if src has supported scheme
|
// check if src has supported scheme
|
||||||
if !HasProducer(source) {
|
if !HasProducer(source) {
|
||||||
return nil
|
return nil, errors.New("streams: source not supported")
|
||||||
}
|
}
|
||||||
|
|
||||||
if Validate(source) != nil {
|
if err := Validate(source); err != nil {
|
||||||
return nil
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// check an existing stream with this name
|
// check an existing stream with this name
|
||||||
if stream, ok := streams[name]; ok {
|
if stream, ok := streams[name]; ok {
|
||||||
stream.SetSource(source)
|
stream.SetSource(source)
|
||||||
return stream
|
return stream, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// create new stream with this name
|
// create new stream with this name
|
||||||
stream := NewStream(source)
|
stream := NewStream(source)
|
||||||
streams[name] = stream
|
streams[name] = stream
|
||||||
return stream
|
return stream, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetOrPatch(query url.Values) *Stream {
|
func GetOrPatch(query url.Values) (*Stream, error) {
|
||||||
// check if src param exists
|
// check if src param exists
|
||||||
source := query.Get("src")
|
source := query.Get("src")
|
||||||
if source == "" {
|
if source == "" {
|
||||||
return nil
|
return nil, errors.New("streams: source empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if src is stream name
|
// check if src is stream name
|
||||||
if stream := Get(source); stream != nil {
|
if stream := Get(source); stream != nil {
|
||||||
return stream
|
return stream, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if name param provided
|
// check if name param provided
|
||||||
if name := query.Get("name"); name != "" {
|
if name := query.Get("name"); name != "" {
|
||||||
log.Info().Msgf("[streams] create new stream url=%s", source)
|
|
||||||
|
|
||||||
return Patch(name, source)
|
return Patch(name, source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func asyncHandler(tr *ws.Transport, msg *ws.Message) (err error) {
|
|||||||
|
|
||||||
query := tr.Request.URL.Query()
|
query := tr.Request.URL.Query()
|
||||||
if name := query.Get("src"); name != "" {
|
if name := query.Get("src"); name != "" {
|
||||||
stream = streams.GetOrPatch(query)
|
stream, _ = streams.GetOrPatch(query)
|
||||||
mode = core.ModePassiveConsumer
|
mode = core.ModePassiveConsumer
|
||||||
log.Debug().Str("src", name).Msg("[webrtc] new consumer")
|
log.Debug().Str("src", name).Msg("[webrtc] new consumer")
|
||||||
} else if name = query.Get("dst"); name != "" {
|
} else if name = query.Get("dst"); name != "" {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/internal/alsa"
|
"github.com/AlexxIT/go2rtc/internal/alsa"
|
||||||
"github.com/AlexxIT/go2rtc/internal/api"
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||||
@@ -44,68 +46,67 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
app.Version = "1.9.11"
|
app.Version = "1.9.12"
|
||||||
|
|
||||||
// 1. Core modules: app, api/ws, streams
|
type module struct {
|
||||||
|
name string
|
||||||
|
init func()
|
||||||
|
}
|
||||||
|
|
||||||
app.Init() // init config and logs
|
modules := []module{
|
||||||
|
{"", app.Init}, // init config and logs
|
||||||
|
{"api", api.Init}, // init API before all others
|
||||||
|
{"ws", ws.Init}, // init WS API endpoint
|
||||||
|
{"", streams.Init},
|
||||||
|
// Main sources and servers
|
||||||
|
{"http", http.Init}, // rtsp source, HTTP server
|
||||||
|
{"rtsp", rtsp.Init}, // rtsp source, RTSP server
|
||||||
|
{"webrtc", webrtc.Init}, // webrtc source, WebRTC server
|
||||||
|
// Main API
|
||||||
|
{"mp4", mp4.Init}, // MP4 API
|
||||||
|
{"hls", hls.Init}, // HLS API
|
||||||
|
{"mjpeg", mjpeg.Init}, // MJPEG API
|
||||||
|
// Other sources and servers
|
||||||
|
{"hass", hass.Init}, // hass source, Hass API server
|
||||||
|
{"homekit", homekit.Init}, // homekit source, HomeKit server
|
||||||
|
{"onvif", onvif.Init}, // onvif source, ONVIF API server
|
||||||
|
{"rtmp", rtmp.Init}, // rtmp source, RTMP server
|
||||||
|
{"webtorrent", webtorrent.Init}, // webtorrent source, WebTorrent module
|
||||||
|
{"wyoming", wyoming.Init},
|
||||||
|
// Exec and script sources
|
||||||
|
{"echo", echo.Init},
|
||||||
|
{"exec", exec.Init},
|
||||||
|
{"expr", expr.Init},
|
||||||
|
{"ffmpeg", ffmpeg.Init},
|
||||||
|
// Hardware sources
|
||||||
|
{"alsa", alsa.Init},
|
||||||
|
{"v4l2", v4l2.Init},
|
||||||
|
// Other sources
|
||||||
|
{"bubble", bubble.Init},
|
||||||
|
{"doorbird", doorbird.Init},
|
||||||
|
{"dvrip", dvrip.Init},
|
||||||
|
{"eseecloud", eseecloud.Init},
|
||||||
|
{"flussonic", flussonic.Init},
|
||||||
|
{"gopro", gopro.Init},
|
||||||
|
{"isapi", isapi.Init},
|
||||||
|
{"ivideon", ivideon.Init},
|
||||||
|
{"mpegts", mpegts.Init},
|
||||||
|
{"nest", nest.Init},
|
||||||
|
{"ring", ring.Init},
|
||||||
|
{"roborock", roborock.Init},
|
||||||
|
{"tapo", tapo.Init},
|
||||||
|
{"yandex", yandex.Init},
|
||||||
|
// Helper modules
|
||||||
|
{"debug", debug.Init},
|
||||||
|
{"ngrok", ngrok.Init},
|
||||||
|
{"srtp", srtp.Init},
|
||||||
|
}
|
||||||
|
|
||||||
api.Init() // init API before all others
|
for _, m := range modules {
|
||||||
ws.Init() // init WS API endpoint
|
if app.Modules == nil || m.name == "" || slices.Contains(app.Modules, m.name) {
|
||||||
|
m.init()
|
||||||
streams.Init() // streams module
|
}
|
||||||
|
}
|
||||||
// 2. Main sources and servers
|
|
||||||
|
|
||||||
rtsp.Init() // rtsp source, RTSP server
|
|
||||||
webrtc.Init() // webrtc source, WebRTC server
|
|
||||||
|
|
||||||
// 3. Main API
|
|
||||||
|
|
||||||
mp4.Init() // MP4 API
|
|
||||||
hls.Init() // HLS API
|
|
||||||
mjpeg.Init() // MJPEG API
|
|
||||||
|
|
||||||
// 4. Other sources and servers
|
|
||||||
|
|
||||||
hass.Init() // hass source, Hass API server
|
|
||||||
onvif.Init() // onvif source, ONVIF API server
|
|
||||||
webtorrent.Init() // webtorrent source, WebTorrent module
|
|
||||||
wyoming.Init()
|
|
||||||
|
|
||||||
// 5. Other sources
|
|
||||||
|
|
||||||
rtmp.Init() // rtmp source
|
|
||||||
exec.Init() // exec source
|
|
||||||
ffmpeg.Init() // ffmpeg source
|
|
||||||
echo.Init() // echo source
|
|
||||||
ivideon.Init() // ivideon source
|
|
||||||
http.Init() // http/tcp source
|
|
||||||
dvrip.Init() // dvrip source
|
|
||||||
tapo.Init() // tapo source
|
|
||||||
isapi.Init() // isapi source
|
|
||||||
mpegts.Init() // mpegts passive source
|
|
||||||
roborock.Init() // roborock source
|
|
||||||
homekit.Init() // homekit source
|
|
||||||
ring.Init() // ring source
|
|
||||||
nest.Init() // nest source
|
|
||||||
bubble.Init() // bubble source
|
|
||||||
expr.Init() // expr source
|
|
||||||
gopro.Init() // gopro source
|
|
||||||
doorbird.Init() // doorbird source
|
|
||||||
v4l2.Init() // v4l2 source
|
|
||||||
alsa.Init() // alsa source
|
|
||||||
flussonic.Init()
|
|
||||||
eseecloud.Init()
|
|
||||||
yandex.Init()
|
|
||||||
|
|
||||||
// 6. Helper modules
|
|
||||||
|
|
||||||
ngrok.Init() // ngrok module
|
|
||||||
srtp.Init() // SRTP server
|
|
||||||
debug.Init() // debug API
|
|
||||||
|
|
||||||
// 7. Go
|
|
||||||
|
|
||||||
shell.RunUntilSignal()
|
shell.RunUntilSignal()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
## Useful links
|
||||||
|
|
||||||
|
- https://github.com/bauer-andreas/secure-video-specification
|
||||||
+13
-13
@@ -49,17 +49,17 @@ func ServiceCameraRTPStreamManagement() *hap.Service {
|
|||||||
val120, _ := tlv8.MarshalBase64(StreamingStatus{
|
val120, _ := tlv8.MarshalBase64(StreamingStatus{
|
||||||
Status: StreamingStatusAvailable,
|
Status: StreamingStatusAvailable,
|
||||||
})
|
})
|
||||||
val114, _ := tlv8.MarshalBase64(SupportedVideoStreamConfig{
|
val114, _ := tlv8.MarshalBase64(SupportedVideoStreamConfiguration{
|
||||||
Codecs: []VideoCodec{
|
Codecs: []VideoCodecConfiguration{
|
||||||
{
|
{
|
||||||
CodecType: VideoCodecTypeH264,
|
CodecType: VideoCodecTypeH264,
|
||||||
CodecParams: []VideoParams{
|
CodecParams: []VideoCodecParameters{
|
||||||
{
|
{
|
||||||
ProfileID: []byte{VideoCodecProfileMain},
|
ProfileID: []byte{VideoCodecProfileMain},
|
||||||
Level: []byte{VideoCodecLevel31, VideoCodecLevel40},
|
Level: []byte{VideoCodecLevel31, VideoCodecLevel40},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
VideoAttrs: []VideoAttrs{
|
VideoAttrs: []VideoCodecAttributes{
|
||||||
{Width: 1920, Height: 1080, Framerate: 30},
|
{Width: 1920, Height: 1080, Framerate: 30},
|
||||||
{Width: 1280, Height: 720, Framerate: 30}, // important for iPhones
|
{Width: 1280, Height: 720, Framerate: 30}, // important for iPhones
|
||||||
{Width: 320, Height: 240, Framerate: 15}, // apple watch
|
{Width: 320, Height: 240, Framerate: 15}, // apple watch
|
||||||
@@ -67,23 +67,23 @@ func ServiceCameraRTPStreamManagement() *hap.Service {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
val115, _ := tlv8.MarshalBase64(SupportedAudioStreamConfig{
|
val115, _ := tlv8.MarshalBase64(SupportedAudioStreamConfiguration{
|
||||||
Codecs: []AudioCodec{
|
Codecs: []AudioCodecConfiguration{
|
||||||
{
|
{
|
||||||
CodecType: AudioCodecTypeOpus,
|
CodecType: AudioCodecTypeOpus,
|
||||||
CodecParams: []AudioParams{
|
CodecParams: []AudioCodecParameters{
|
||||||
{
|
{
|
||||||
Channels: 1,
|
Channels: 1,
|
||||||
Bitrate: AudioCodecBitrateVariable,
|
BitrateMode: AudioCodecBitrateVariable,
|
||||||
SampleRate: []byte{AudioCodecSampleRate16Khz},
|
SampleRate: []byte{AudioCodecSampleRate16Khz},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ComfortNoise: 0,
|
ComfortNoiseSupport: 0,
|
||||||
})
|
})
|
||||||
val116, _ := tlv8.MarshalBase64(SupportedRTPConfig{
|
val116, _ := tlv8.MarshalBase64(SupportedRTPConfiguration{
|
||||||
CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80},
|
SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80},
|
||||||
})
|
})
|
||||||
|
|
||||||
service := &hap.Service{
|
service := &hap.Service{
|
||||||
|
|||||||
@@ -63,19 +63,19 @@ func TestAqaraG3(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "114",
|
name: "114",
|
||||||
value: "AaoBAQACEQEBAQIBAAAAAgECAwEABAEAAwsBAoAHAgI4BAMBHgAAAwsBAgAFAgLQAgMBHgAAAwsBAoACAgJoAQMBHgAAAwsBAuABAgIOAQMBHgAAAwsBAkABAgK0AAMBHgAAAwsBAgAFAgLAAwMBHgAAAwsBAgAEAgIAAwMBHgAAAwsBAoACAgLgAQMBHgAAAwsBAuABAgJoAQMBHgAAAwsBAkABAgLwAAMBHg==",
|
value: "AaoBAQACEQEBAQIBAAAAAgECAwEABAEAAwsBAoAHAgI4BAMBHgAAAwsBAgAFAgLQAgMBHgAAAwsBAoACAgJoAQMBHgAAAwsBAuABAgIOAQMBHgAAAwsBAkABAgK0AAMBHgAAAwsBAgAFAgLAAwMBHgAAAwsBAgAEAgIAAwMBHgAAAwsBAoACAgLgAQMBHgAAAwsBAuABAgJoAQMBHgAAAwsBAkABAgLwAAMBHg==",
|
||||||
actual: &SupportedVideoStreamConfig{},
|
actual: &SupportedVideoStreamConfiguration{},
|
||||||
expect: &SupportedVideoStreamConfig{
|
expect: &SupportedVideoStreamConfiguration{
|
||||||
Codecs: []VideoCodec{
|
Codecs: []VideoCodecConfiguration{
|
||||||
{
|
{
|
||||||
CodecType: VideoCodecTypeH264,
|
CodecType: VideoCodecTypeH264,
|
||||||
CodecParams: []VideoParams{
|
CodecParams: []VideoCodecParameters{
|
||||||
{
|
{
|
||||||
ProfileID: []byte{VideoCodecProfileMain},
|
ProfileID: []byte{VideoCodecProfileMain},
|
||||||
Level: []byte{VideoCodecLevel31, VideoCodecLevel40},
|
Level: []byte{VideoCodecLevel31, VideoCodecLevel40},
|
||||||
CVOEnabled: []byte{0},
|
CVOEnabled: []byte{0},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
VideoAttrs: []VideoAttrs{
|
VideoAttrs: []VideoCodecAttributes{
|
||||||
{Width: 1920, Height: 1080, Framerate: 30},
|
{Width: 1920, Height: 1080, Framerate: 30},
|
||||||
{Width: 1280, Height: 720, Framerate: 30},
|
{Width: 1280, Height: 720, Framerate: 30},
|
||||||
{Width: 640, Height: 360, Framerate: 30},
|
{Width: 640, Height: 360, Framerate: 30},
|
||||||
@@ -94,29 +94,29 @@ func TestAqaraG3(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "115",
|
name: "115",
|
||||||
value: "AQ4BAQICCQEBAQIBAAMBAQIBAA==",
|
value: "AQ4BAQICCQEBAQIBAAMBAQIBAA==",
|
||||||
actual: &SupportedAudioStreamConfig{},
|
actual: &SupportedAudioStreamConfiguration{},
|
||||||
expect: &SupportedAudioStreamConfig{
|
expect: &SupportedAudioStreamConfiguration{
|
||||||
Codecs: []AudioCodec{
|
Codecs: []AudioCodecConfiguration{
|
||||||
{
|
{
|
||||||
CodecType: AudioCodecTypeAACELD,
|
CodecType: AudioCodecTypeAACELD,
|
||||||
CodecParams: []AudioParams{
|
CodecParams: []AudioCodecParameters{
|
||||||
{
|
{
|
||||||
Channels: 1,
|
Channels: 1,
|
||||||
Bitrate: AudioCodecBitrateVariable,
|
BitrateMode: AudioCodecBitrateVariable,
|
||||||
SampleRate: []byte{AudioCodecSampleRate16Khz},
|
SampleRate: []byte{AudioCodecSampleRate16Khz},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ComfortNoise: 0,
|
ComfortNoiseSupport: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "116",
|
name: "116",
|
||||||
value: "AgEAAAACAQEAAAIBAg==",
|
value: "AgEAAAACAQEAAAIBAg==",
|
||||||
actual: &SupportedRTPConfig{},
|
actual: &SupportedRTPConfiguration{},
|
||||||
expect: &SupportedRTPConfig{
|
expect: &SupportedRTPConfiguration{
|
||||||
CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoAES_CM_256_HMAC_SHA1_80, CryptoNone},
|
SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoAES_CM_256_HMAC_SHA1_80, CryptoDisabled},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -130,18 +130,18 @@ func TestHomebridge(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "114",
|
name: "114",
|
||||||
value: "AcUBAQACHQEBAAAAAQEBAAABAQICAQAAAAIBAQAAAgECAwEAAwsBAkABAgK0AAMBHgAAAwsBAkABAgLwAAMBDwAAAwsBAkABAgLwAAMBHgAAAwsBAuABAgIOAQMBHgAAAwsBAuABAgJoAQMBHgAAAwsBAoACAgJoAQMBHgAAAwsBAoACAgLgAQMBHgAAAwsBAgAFAgLQAgMBHgAAAwsBAgAFAgLAAwMBHgAAAwsBAoAHAgI4BAMBHgAAAwsBAkAGAgKwBAMBHg==",
|
value: "AcUBAQACHQEBAAAAAQEBAAABAQICAQAAAAIBAQAAAgECAwEAAwsBAkABAgK0AAMBHgAAAwsBAkABAgLwAAMBDwAAAwsBAkABAgLwAAMBHgAAAwsBAuABAgIOAQMBHgAAAwsBAuABAgJoAQMBHgAAAwsBAoACAgJoAQMBHgAAAwsBAoACAgLgAQMBHgAAAwsBAgAFAgLQAgMBHgAAAwsBAgAFAgLAAwMBHgAAAwsBAoAHAgI4BAMBHgAAAwsBAkAGAgKwBAMBHg==",
|
||||||
actual: &SupportedVideoStreamConfig{},
|
actual: &SupportedVideoStreamConfiguration{},
|
||||||
expect: &SupportedVideoStreamConfig{
|
expect: &SupportedVideoStreamConfiguration{
|
||||||
Codecs: []VideoCodec{
|
Codecs: []VideoCodecConfiguration{
|
||||||
{
|
{
|
||||||
CodecType: VideoCodecTypeH264,
|
CodecType: VideoCodecTypeH264,
|
||||||
CodecParams: []VideoParams{
|
CodecParams: []VideoCodecParameters{
|
||||||
{
|
{
|
||||||
ProfileID: []byte{VideoCodecProfileConstrainedBaseline, VideoCodecProfileMain, VideoCodecProfileHigh},
|
ProfileID: []byte{VideoCodecProfileConstrainedBaseline, VideoCodecProfileMain, VideoCodecProfileHigh},
|
||||||
Level: []byte{VideoCodecLevel31, VideoCodecLevel32, VideoCodecLevel40},
|
Level: []byte{VideoCodecLevel31, VideoCodecLevel32, VideoCodecLevel40},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
VideoAttrs: []VideoAttrs{
|
VideoAttrs: []VideoCodecAttributes{
|
||||||
|
|
||||||
{Width: 320, Height: 180, Framerate: 30},
|
{Width: 320, Height: 180, Framerate: 30},
|
||||||
{Width: 320, Height: 240, Framerate: 15},
|
{Width: 320, Height: 240, Framerate: 15},
|
||||||
@@ -162,9 +162,9 @@ func TestHomebridge(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "116",
|
name: "116",
|
||||||
value: "AgEA",
|
value: "AgEA",
|
||||||
actual: &SupportedRTPConfig{},
|
actual: &SupportedRTPConfiguration{},
|
||||||
expect: &SupportedRTPConfig{
|
expect: &SupportedRTPConfiguration{
|
||||||
CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80},
|
SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -178,18 +178,18 @@ func TestScrypted(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "114",
|
name: "114",
|
||||||
value: "AVIBAQACEwEBAQIBAAAAAgEBAAACAQIDAQADCwECAA8CAnAIAwEeAAADCwECgAcCAjgEAwEeAAADCwECAAUCAtACAwEeAAADCwECQAECAvAAAwEP",
|
value: "AVIBAQACEwEBAQIBAAAAAgEBAAACAQIDAQADCwECAA8CAnAIAwEeAAADCwECgAcCAjgEAwEeAAADCwECAAUCAtACAwEeAAADCwECQAECAvAAAwEP",
|
||||||
actual: &SupportedVideoStreamConfig{},
|
actual: &SupportedVideoStreamConfiguration{},
|
||||||
expect: &SupportedVideoStreamConfig{
|
expect: &SupportedVideoStreamConfiguration{
|
||||||
Codecs: []VideoCodec{
|
Codecs: []VideoCodecConfiguration{
|
||||||
{
|
{
|
||||||
CodecType: VideoCodecTypeH264,
|
CodecType: VideoCodecTypeH264,
|
||||||
CodecParams: []VideoParams{
|
CodecParams: []VideoCodecParameters{
|
||||||
{
|
{
|
||||||
ProfileID: []byte{VideoCodecProfileMain},
|
ProfileID: []byte{VideoCodecProfileMain},
|
||||||
Level: []byte{VideoCodecLevel31, VideoCodecLevel32, VideoCodecLevel40},
|
Level: []byte{VideoCodecLevel31, VideoCodecLevel32, VideoCodecLevel40},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
VideoAttrs: []VideoAttrs{
|
VideoAttrs: []VideoCodecAttributes{
|
||||||
{Width: 3840, Height: 2160, Framerate: 30},
|
{Width: 3840, Height: 2160, Framerate: 30},
|
||||||
{Width: 1920, Height: 1080, Framerate: 30},
|
{Width: 1920, Height: 1080, Framerate: 30},
|
||||||
{Width: 1280, Height: 720, Framerate: 30},
|
{Width: 1280, Height: 720, Framerate: 30},
|
||||||
@@ -202,15 +202,15 @@ func TestScrypted(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "115",
|
name: "115",
|
||||||
value: "AScBAQMCIgEBAQIBAAMBAAAAAwEAAAADAQEAAAMBAQAAAwECAAADAQICAQA=",
|
value: "AScBAQMCIgEBAQIBAAMBAAAAAwEAAAADAQEAAAMBAQAAAwECAAADAQICAQA=",
|
||||||
actual: &SupportedAudioStreamConfig{},
|
actual: &SupportedAudioStreamConfiguration{},
|
||||||
expect: &SupportedAudioStreamConfig{
|
expect: &SupportedAudioStreamConfiguration{
|
||||||
Codecs: []AudioCodec{
|
Codecs: []AudioCodecConfiguration{
|
||||||
{
|
{
|
||||||
CodecType: AudioCodecTypeOpus,
|
CodecType: AudioCodecTypeOpus,
|
||||||
CodecParams: []AudioParams{
|
CodecParams: []AudioCodecParameters{
|
||||||
{
|
{
|
||||||
Channels: 1,
|
Channels: 1,
|
||||||
Bitrate: AudioCodecBitrateVariable,
|
BitrateMode: AudioCodecBitrateVariable,
|
||||||
SampleRate: []byte{
|
SampleRate: []byte{
|
||||||
AudioCodecSampleRate8Khz, AudioCodecSampleRate8Khz,
|
AudioCodecSampleRate8Khz, AudioCodecSampleRate8Khz,
|
||||||
AudioCodecSampleRate16Khz, AudioCodecSampleRate16Khz,
|
AudioCodecSampleRate16Khz, AudioCodecSampleRate16Khz,
|
||||||
@@ -220,15 +220,15 @@ func TestScrypted(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ComfortNoise: 0,
|
ComfortNoiseSupport: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "116",
|
name: "116",
|
||||||
value: "AgEAAAACAQI=",
|
value: "AgEAAAACAQI=",
|
||||||
actual: &SupportedRTPConfig{},
|
actual: &SupportedRTPConfiguration{},
|
||||||
expect: &SupportedRTPConfig{
|
expect: &SupportedRTPConfiguration{
|
||||||
CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoNone},
|
SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoDisabled},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,15 @@ package camera
|
|||||||
|
|
||||||
const TypeSupportedVideoStreamConfiguration = "114"
|
const TypeSupportedVideoStreamConfiguration = "114"
|
||||||
|
|
||||||
type SupportedVideoStreamConfig struct {
|
type SupportedVideoStreamConfiguration struct {
|
||||||
Codecs []VideoCodec `tlv8:"1"`
|
Codecs []VideoCodecConfiguration `tlv8:"1"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type VideoCodec struct {
|
type VideoCodecConfiguration struct {
|
||||||
CodecType byte `tlv8:"1"`
|
CodecType byte `tlv8:"1"`
|
||||||
CodecParams []VideoParams `tlv8:"2"`
|
CodecParams []VideoCodecParameters `tlv8:"2"`
|
||||||
VideoAttrs []VideoAttrs `tlv8:"3"`
|
VideoAttrs []VideoCodecAttributes `tlv8:"3"`
|
||||||
RTPParams []RTPParams `tlv8:"4"`
|
RTPParams []RTPParams `tlv8:"4"`
|
||||||
}
|
}
|
||||||
|
|
||||||
//goland:noinspection ALL
|
//goland:noinspection ALL
|
||||||
@@ -31,15 +31,15 @@ const (
|
|||||||
VideoCodecCvoSuppported = 1
|
VideoCodecCvoSuppported = 1
|
||||||
)
|
)
|
||||||
|
|
||||||
type VideoParams struct {
|
type VideoCodecParameters struct {
|
||||||
ProfileID []byte `tlv8:"1"` // 0 - baseline, 1 - main, 2 - high
|
ProfileID []byte `tlv8:"1"` // 0 - baseline, 1 - main, 2 - high
|
||||||
Level []byte `tlv8:"2"` // 0 - 3.1, 1 - 3.2, 2 - 4.0
|
Level []byte `tlv8:"2"` // 0 - 3.1, 1 - 3.2, 2 - 4.0
|
||||||
PacketizationMode byte `tlv8:"3"` // only 0 - non interleaved
|
PacketizationMode byte `tlv8:"3"` // only 0 - non interleaved
|
||||||
CVOEnabled []byte `tlv8:"4"` // 0 - not supported, 1 - supported
|
CVOEnabled []byte `tlv8:"4"` // 0 - not supported, 1 - supported
|
||||||
CVOID []byte `tlv8:"5"` // ???
|
CVOID []byte `tlv8:"5"` // ID for CVO RTP extensio
|
||||||
}
|
}
|
||||||
|
|
||||||
type VideoAttrs struct {
|
type VideoCodecAttributes struct {
|
||||||
Width uint16 `tlv8:"1"`
|
Width uint16 `tlv8:"1"`
|
||||||
Height uint16 `tlv8:"2"`
|
Height uint16 `tlv8:"2"`
|
||||||
Framerate uint8 `tlv8:"3"`
|
Framerate uint8 `tlv8:"3"`
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ package camera
|
|||||||
|
|
||||||
const TypeSupportedAudioStreamConfiguration = "115"
|
const TypeSupportedAudioStreamConfiguration = "115"
|
||||||
|
|
||||||
type SupportedAudioStreamConfig struct {
|
type SupportedAudioStreamConfiguration struct {
|
||||||
Codecs []AudioCodec `tlv8:"1"`
|
Codecs []AudioCodecConfiguration `tlv8:"1"`
|
||||||
ComfortNoise byte `tlv8:"2"`
|
ComfortNoiseSupport byte `tlv8:"2"`
|
||||||
}
|
}
|
||||||
|
|
||||||
//goland:noinspection ALL
|
//goland:noinspection ALL
|
||||||
@@ -31,16 +31,16 @@ const (
|
|||||||
RTPTimeAACLD24 = 40 // 24000/1000*40=960
|
RTPTimeAACLD24 = 40 // 24000/1000*40=960
|
||||||
)
|
)
|
||||||
|
|
||||||
type AudioCodec struct {
|
type AudioCodecConfiguration struct {
|
||||||
CodecType byte `tlv8:"1"`
|
CodecType byte `tlv8:"1"`
|
||||||
CodecParams []AudioParams `tlv8:"2"`
|
CodecParams []AudioCodecParameters `tlv8:"2"`
|
||||||
RTPParams []RTPParams `tlv8:"3"`
|
RTPParams []RTPParams `tlv8:"3"`
|
||||||
ComfortNoise []byte `tlv8:"4"`
|
ComfortNoise []byte `tlv8:"4"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AudioParams struct {
|
type AudioCodecParameters struct {
|
||||||
Channels uint8 `tlv8:"1"`
|
Channels uint8 `tlv8:"1"`
|
||||||
Bitrate byte `tlv8:"2"` // 0 - variable, 1 - constant
|
BitrateMode byte `tlv8:"2"` // 0 - variable, 1 - constant
|
||||||
SampleRate []byte `tlv8:"3"` // 0 - 8000, 1 - 16000, 2 - 24000
|
SampleRate []byte `tlv8:"3"` // 0 - 8000, 1 - 16000, 2 - 24000
|
||||||
RTPTime []uint8 `tlv8:"4"` // 20, 30, 40, 60
|
RTPTime []uint8 `tlv8:"4"` // 20, 30, 40, 60
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ const TypeSupportedRTPConfiguration = "116"
|
|||||||
const (
|
const (
|
||||||
CryptoAES_CM_128_HMAC_SHA1_80 = 0
|
CryptoAES_CM_128_HMAC_SHA1_80 = 0
|
||||||
CryptoAES_CM_256_HMAC_SHA1_80 = 1
|
CryptoAES_CM_256_HMAC_SHA1_80 = 1
|
||||||
CryptoNone = 2
|
CryptoDisabled = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
type SupportedRTPConfig struct {
|
type SupportedRTPConfiguration struct {
|
||||||
CryptoType []byte `tlv8:"2"`
|
SRTPCryptoType []byte `tlv8:"2"`
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,10 @@ package camera
|
|||||||
|
|
||||||
const TypeSelectedStreamConfiguration = "117"
|
const TypeSelectedStreamConfiguration = "117"
|
||||||
|
|
||||||
type SelectedStreamConfig struct {
|
type SelectedStreamConfiguration struct {
|
||||||
Control SessionControl `tlv8:"1"`
|
Control SessionControl `tlv8:"1"`
|
||||||
VideoCodec VideoCodec `tlv8:"2"`
|
VideoCodec VideoCodecConfiguration `tlv8:"2"`
|
||||||
AudioCodec AudioCodec `tlv8:"3"`
|
AudioCodec AudioCodecConfiguration `tlv8:"3"`
|
||||||
}
|
}
|
||||||
|
|
||||||
//goland:noinspection ALL
|
//goland:noinspection ALL
|
||||||
|
|||||||
@@ -2,25 +2,32 @@ package camera
|
|||||||
|
|
||||||
const TypeSetupEndpoints = "118"
|
const TypeSetupEndpoints = "118"
|
||||||
|
|
||||||
type SetupEndpoints struct {
|
type SetupEndpointsRequest struct {
|
||||||
SessionID string `tlv8:"1"`
|
SessionID string `tlv8:"1"`
|
||||||
Status []byte `tlv8:"2"`
|
Address Address `tlv8:"3"`
|
||||||
Address Addr `tlv8:"3"`
|
VideoCrypto SRTPCryptoSuite `tlv8:"4"`
|
||||||
VideoCrypto CryptoSuite `tlv8:"4"`
|
AudioCrypto SRTPCryptoSuite `tlv8:"5"`
|
||||||
AudioCrypto CryptoSuite `tlv8:"5"`
|
|
||||||
VideoSSRC []uint32 `tlv8:"6"`
|
|
||||||
AudioSSRC []uint32 `tlv8:"7"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Addr struct {
|
type SetupEndpointsResponse struct {
|
||||||
|
SessionID string `tlv8:"1"`
|
||||||
|
Status byte `tlv8:"2"`
|
||||||
|
Address Address `tlv8:"3"`
|
||||||
|
VideoCrypto SRTPCryptoSuite `tlv8:"4"`
|
||||||
|
AudioCrypto SRTPCryptoSuite `tlv8:"5"`
|
||||||
|
VideoSSRC uint32 `tlv8:"6"`
|
||||||
|
AudioSSRC uint32 `tlv8:"7"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Address struct {
|
||||||
IPVersion byte `tlv8:"1"`
|
IPVersion byte `tlv8:"1"`
|
||||||
IPAddr string `tlv8:"2"`
|
IPAddr string `tlv8:"2"`
|
||||||
VideoRTPPort uint16 `tlv8:"3"`
|
VideoRTPPort uint16 `tlv8:"3"`
|
||||||
AudioRTPPort uint16 `tlv8:"4"`
|
AudioRTPPort uint16 `tlv8:"4"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CryptoSuite struct {
|
type SRTPCryptoSuite struct {
|
||||||
CryptoType byte `tlv8:"1"`
|
CryptoSuite byte `tlv8:"1"`
|
||||||
MasterKey string `tlv8:"2"` // 16 (AES_CM_128) or 32 (AES_256_CM)
|
MasterKey string `tlv8:"2"` // 16 (AES_CM_128) or 32 (AES_256_CM)
|
||||||
MasterSalt string `tlv8:"3"` // 14 byte
|
MasterSalt string `tlv8:"3"` // 14 byte
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,6 @@ type StreamingStatus struct {
|
|||||||
//goland:noinspection ALL
|
//goland:noinspection ALL
|
||||||
const (
|
const (
|
||||||
StreamingStatusAvailable = 0
|
StreamingStatusAvailable = 0
|
||||||
StreamingStatusBusy = 1
|
StreamingStatusInUse = 1
|
||||||
StreamingStatusUnavailable = 2
|
StreamingStatusUnavailable = 2
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package camera
|
||||||
|
|
||||||
|
const TypeSupportedDataStreamTransportConfiguration = "130"
|
||||||
|
|
||||||
|
type SupportedDataStreamTransportConfiguration struct {
|
||||||
|
Configs []TransferTransportConfiguration `tlv8:"1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransferTransportConfiguration struct {
|
||||||
|
TransportType byte `tlv8:"1"`
|
||||||
|
}
|
||||||
@@ -2,13 +2,13 @@ package camera
|
|||||||
|
|
||||||
const TypeSetupDataStreamTransport = "131"
|
const TypeSetupDataStreamTransport = "131"
|
||||||
|
|
||||||
type SetupDataStreamRequest struct {
|
type SetupDataStreamTransportRequest struct {
|
||||||
SessionCommandType byte `tlv8:"1"`
|
SessionCommandType byte `tlv8:"1"`
|
||||||
TransportType byte `tlv8:"2"`
|
TransportType byte `tlv8:"2"`
|
||||||
ControllerKeySalt string `tlv8:"3"`
|
ControllerKeySalt string `tlv8:"3"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SetupDataStreamResponse struct {
|
type SetupDataStreamTransportResponse struct {
|
||||||
Status byte `tlv8:"1"`
|
Status byte `tlv8:"1"`
|
||||||
TransportTypeSessionParameters struct {
|
TransportTypeSessionParameters struct {
|
||||||
TCPListeningPort uint16 `tlv8:"1"`
|
TCPListeningPort uint16 `tlv8:"1"`
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package camera
|
||||||
|
|
||||||
|
const TypeSupportedCameraRecordingConfiguration = "205"
|
||||||
|
|
||||||
|
type SupportedCameraRecordingConfiguration struct {
|
||||||
|
PrebufferLength uint32 `tlv8:"1"`
|
||||||
|
EventTriggerOptions uint64 `tlv8:"2"`
|
||||||
|
MediaContainerConfigurations `tlv8:"3"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaContainerConfigurations struct {
|
||||||
|
MediaContainerType uint8 `tlv8:"1"`
|
||||||
|
MediaContainerParameters `tlv8:"2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaContainerParameters struct {
|
||||||
|
FragmentLength uint32 `tlv8:"1"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package camera
|
||||||
|
|
||||||
|
const TypeSupportedVideoRecordingConfiguration = "206"
|
||||||
|
|
||||||
|
type SupportedVideoRecordingConfiguration struct {
|
||||||
|
CodecConfigs []VideoRecordingCodecConfiguration `tlv8:"1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoRecordingCodecConfiguration struct {
|
||||||
|
CodecType uint8 `tlv8:"1"`
|
||||||
|
CodecParams VideoRecordingCodecParameters `tlv8:"2"`
|
||||||
|
CodecAttrs VideoCodecAttributes `tlv8:"3"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoRecordingCodecParameters struct {
|
||||||
|
ProfileID uint8 `tlv8:"1"`
|
||||||
|
Level uint8 `tlv8:"2"`
|
||||||
|
Bitrate uint32 `tlv8:"3"`
|
||||||
|
IFrameInterval uint32 `tlv8:"4"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package camera
|
||||||
|
|
||||||
|
const TypeSupportedAudioRecordingConfiguration = "207"
|
||||||
|
|
||||||
|
type SupportedAudioRecordingConfiguration struct {
|
||||||
|
CodecConfigs []AudioRecordingCodecConfiguration `tlv8:"1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioRecordingCodecConfiguration struct {
|
||||||
|
CodecType byte `tlv8:"1"`
|
||||||
|
CodecParams []AudioRecordingCodecParameters `tlv8:"2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioRecordingCodecParameters struct {
|
||||||
|
Channels uint8 `tlv8:"1"`
|
||||||
|
BitrateMode []byte `tlv8:"2"`
|
||||||
|
SampleRate []byte `tlv8:"3"`
|
||||||
|
MaxAudioBitrate []uint32 `tlv8:"4"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package camera
|
||||||
|
|
||||||
|
const TypeSelectedCameraRecordingConfiguration = "209"
|
||||||
|
|
||||||
|
type SelectedCameraRecordingConfiguration struct {
|
||||||
|
GeneralConfig SupportedCameraRecordingConfiguration `tlv8:"1"`
|
||||||
|
VideoConfig SupportedVideoRecordingConfiguration `tlv8:"2"`
|
||||||
|
AudioConfig SupportedAudioRecordingConfiguration `tlv8:"3"`
|
||||||
|
}
|
||||||
+11
-11
@@ -15,7 +15,7 @@ type Stream struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewStream(
|
func NewStream(
|
||||||
client *hap.Client, videoCodec *VideoCodec, audioCodec *AudioCodec,
|
client *hap.Client, videoCodec *VideoCodecConfiguration, audioCodec *AudioCodecConfiguration,
|
||||||
videoSession, audioSession *srtp.Session, bitrate int,
|
videoSession, audioSession *srtp.Session, bitrate int,
|
||||||
) (*Stream, error) {
|
) (*Stream, error) {
|
||||||
stream := &Stream{
|
stream := &Stream{
|
||||||
@@ -58,7 +58,7 @@ func NewStream(
|
|||||||
}
|
}
|
||||||
audioCodec.ComfortNoise = []byte{0}
|
audioCodec.ComfortNoise = []byte{0}
|
||||||
|
|
||||||
config := &SelectedStreamConfig{
|
config := &SelectedStreamConfiguration{
|
||||||
Control: SessionControl{
|
Control: SessionControl{
|
||||||
SessionID: stream.id,
|
SessionID: stream.id,
|
||||||
Command: SessionCommandStart,
|
Command: SessionCommandStart,
|
||||||
@@ -103,19 +103,19 @@ func (s *Stream) GetFreeStream() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) error {
|
func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) error {
|
||||||
req := SetupEndpoints{
|
req := SetupEndpointsRequest{
|
||||||
SessionID: s.id,
|
SessionID: s.id,
|
||||||
Address: Addr{
|
Address: Address{
|
||||||
IPVersion: 0,
|
IPVersion: 0,
|
||||||
IPAddr: videoSession.Local.Addr,
|
IPAddr: videoSession.Local.Addr,
|
||||||
VideoRTPPort: videoSession.Local.Port,
|
VideoRTPPort: videoSession.Local.Port,
|
||||||
AudioRTPPort: audioSession.Local.Port,
|
AudioRTPPort: audioSession.Local.Port,
|
||||||
},
|
},
|
||||||
VideoCrypto: CryptoSuite{
|
VideoCrypto: SRTPCryptoSuite{
|
||||||
MasterKey: string(videoSession.Local.MasterKey),
|
MasterKey: string(videoSession.Local.MasterKey),
|
||||||
MasterSalt: string(videoSession.Local.MasterSalt),
|
MasterSalt: string(videoSession.Local.MasterSalt),
|
||||||
},
|
},
|
||||||
AudioCrypto: CryptoSuite{
|
AudioCrypto: SRTPCryptoSuite{
|
||||||
MasterKey: string(audioSession.Local.MasterKey),
|
MasterKey: string(audioSession.Local.MasterKey),
|
||||||
MasterSalt: string(audioSession.Local.MasterSalt),
|
MasterSalt: string(audioSession.Local.MasterSalt),
|
||||||
},
|
},
|
||||||
@@ -129,7 +129,7 @@ func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var res SetupEndpoints
|
var res SetupEndpointsResponse
|
||||||
if err := s.client.GetCharacter(char); err != nil {
|
if err := s.client.GetCharacter(char); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -142,7 +142,7 @@ func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) err
|
|||||||
Port: res.Address.VideoRTPPort,
|
Port: res.Address.VideoRTPPort,
|
||||||
MasterKey: []byte(res.VideoCrypto.MasterKey),
|
MasterKey: []byte(res.VideoCrypto.MasterKey),
|
||||||
MasterSalt: []byte(res.VideoCrypto.MasterSalt),
|
MasterSalt: []byte(res.VideoCrypto.MasterSalt),
|
||||||
SSRC: res.VideoSSRC[0],
|
SSRC: res.VideoSSRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
audioSession.Remote = &srtp.Endpoint{
|
audioSession.Remote = &srtp.Endpoint{
|
||||||
@@ -150,13 +150,13 @@ func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) err
|
|||||||
Port: res.Address.AudioRTPPort,
|
Port: res.Address.AudioRTPPort,
|
||||||
MasterKey: []byte(res.AudioCrypto.MasterKey),
|
MasterKey: []byte(res.AudioCrypto.MasterKey),
|
||||||
MasterSalt: []byte(res.AudioCrypto.MasterSalt),
|
MasterSalt: []byte(res.AudioCrypto.MasterSalt),
|
||||||
SSRC: res.AudioSSRC[0],
|
SSRC: res.AudioSSRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stream) SetStreamConfig(config *SelectedStreamConfig) error {
|
func (s *Stream) SetStreamConfig(config *SelectedStreamConfiguration) error {
|
||||||
char := s.service.GetCharacter(TypeSelectedStreamConfiguration)
|
char := s.service.GetCharacter(TypeSelectedStreamConfiguration)
|
||||||
if err := char.Write(config); err != nil {
|
if err := char.Write(config); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -169,7 +169,7 @@ func (s *Stream) SetStreamConfig(config *SelectedStreamConfig) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stream) Close() error {
|
func (s *Stream) Close() error {
|
||||||
config := &SelectedStreamConfig{
|
config := &SelectedStreamConfiguration{
|
||||||
Control: SessionControl{
|
Control: SessionControl{
|
||||||
SessionID: s.id,
|
SessionID: s.id,
|
||||||
Command: SessionCommandEnd,
|
Command: SessionCommandEnd,
|
||||||
|
|||||||
+11
-5
@@ -18,7 +18,6 @@ import (
|
|||||||
"github.com/AlexxIT/go2rtc/pkg/hap/curve25519"
|
"github.com/AlexxIT/go2rtc/pkg/hap/curve25519"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/ed25519"
|
"github.com/AlexxIT/go2rtc/pkg/hap/ed25519"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/secure"
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||||
)
|
)
|
||||||
@@ -46,7 +45,7 @@ type Client struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(rawURL string) (*Client, error) {
|
func Dial(rawURL string) (*Client, error) {
|
||||||
u, err := url.Parse(rawURL)
|
u, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -61,6 +60,10 @@ func NewClient(rawURL string) (*Client, error) {
|
|||||||
ClientPrivate: DecodeKey(query.Get("client_private")),
|
ClientPrivate: DecodeKey(query.Get("client_private")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err = c.Dial(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +99,7 @@ func (c *Client) Dial() (err error) {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// TODO: close conn on error
|
||||||
if c.Conn, err = net.DialTimeout("tcp", c.DeviceAddress, ConnDialTimeout); err != nil {
|
if c.Conn, err = net.DialTimeout("tcp", c.DeviceAddress, ConnDialTimeout); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -124,7 +128,7 @@ func (c *Client) Dial() (err error) {
|
|||||||
EncryptedData string `tlv8:"5"`
|
EncryptedData string `tlv8:"5"`
|
||||||
State byte `tlv8:"6"`
|
State byte `tlv8:"6"`
|
||||||
}
|
}
|
||||||
if err = tlv8.UnmarshalReader(res.Body, &cipherM2); err != nil {
|
if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &cipherM2); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if cipherM2.State != StateM2 {
|
if cipherM2.State != StateM2 {
|
||||||
@@ -209,15 +213,17 @@ func (c *Client) Dial() (err error) {
|
|||||||
var plainM4 struct {
|
var plainM4 struct {
|
||||||
State byte `tlv8:"6"`
|
State byte `tlv8:"6"`
|
||||||
}
|
}
|
||||||
if err = tlv8.UnmarshalReader(res.Body, &plainM4); err != nil {
|
if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM4); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if plainM4.State != StateM4 {
|
if plainM4.State != StateM4 {
|
||||||
return newResponseError(cipherM3, plainM4)
|
return newResponseError(cipherM3, plainM4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rw := bufio.NewReadWriter(c.reader, bufio.NewWriter(c.Conn))
|
||||||
|
|
||||||
// like tls.Client wrapper over net.Conn
|
// like tls.Client wrapper over net.Conn
|
||||||
if c.Conn, err = secure.Client(c.Conn, sessionShared, true); err != nil {
|
if c.Conn, err = NewConn(c.Conn, rw, sessionShared, true); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// new reader for new conn
|
// new reader for new conn
|
||||||
|
|||||||
@@ -82,3 +82,20 @@ func ReadResponse(r *bufio.Reader, req *http.Request) (*http.Response, error) {
|
|||||||
|
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WriteEvent(w io.Writer, res *http.Response) error {
|
||||||
|
return res.Write(&eventWriter{w: w})
|
||||||
|
}
|
||||||
|
|
||||||
|
type eventWriter struct {
|
||||||
|
w io.Writer
|
||||||
|
done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *eventWriter) Write(p []byte) (n int, err error) {
|
||||||
|
if !e.done {
|
||||||
|
p = append([]byte("EVENT/1.0"), p[8:]...)
|
||||||
|
e.done = true
|
||||||
|
}
|
||||||
|
return e.w.Write(p)
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ func (c *Client) Pair(feature, pin string) (err error) {
|
|||||||
State byte `tlv8:"6"`
|
State byte `tlv8:"6"`
|
||||||
Error byte `tlv8:"7"`
|
Error byte `tlv8:"7"`
|
||||||
}
|
}
|
||||||
if err = tlv8.UnmarshalReader(res.Body, &plainM2); err != nil {
|
if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if plainM2.State != StateM2 {
|
if plainM2.State != StateM2 {
|
||||||
@@ -121,9 +121,7 @@ func (c *Client) Pair(feature, pin string) (err error) {
|
|||||||
username := []byte("Pair-Setup")
|
username := []byte("Pair-Setup")
|
||||||
|
|
||||||
// Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE)
|
// Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE)
|
||||||
pake, err := srp.NewSRP(
|
pake, err := srp.NewSRP("rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username))
|
||||||
"rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username),
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -132,6 +130,7 @@ func (c *Client) Pair(feature, pin string) (err error) {
|
|||||||
|
|
||||||
// username: "Pair-Setup", password: PIN (with dashes)
|
// username: "Pair-Setup", password: PIN (with dashes)
|
||||||
session := pake.NewClientSession(username, []byte(pin))
|
session := pake.NewClientSession(username, []byte(pin))
|
||||||
|
|
||||||
sessionShared, err := session.ComputeKey([]byte(plainM2.Salt), []byte(plainM2.SessionKey))
|
sessionShared, err := session.ComputeKey([]byte(plainM2.Salt), []byte(plainM2.SessionKey))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -159,7 +158,7 @@ func (c *Client) Pair(feature, pin string) (err error) {
|
|||||||
|
|
||||||
EncryptedData string `tlv8:"5"` // skip EncryptedData validation (for MFi devices)
|
EncryptedData string `tlv8:"5"` // skip EncryptedData validation (for MFi devices)
|
||||||
}
|
}
|
||||||
if err = tlv8.UnmarshalReader(res.Body, &plainM4); err != nil {
|
if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM4); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if plainM4.State != StateM4 {
|
if plainM4.State != StateM4 {
|
||||||
@@ -232,7 +231,7 @@ func (c *Client) Pair(feature, pin string) (err error) {
|
|||||||
State byte `tlv8:"6"`
|
State byte `tlv8:"6"`
|
||||||
Error byte `tlv8:"7"`
|
Error byte `tlv8:"7"`
|
||||||
}{}
|
}{}
|
||||||
if err = tlv8.UnmarshalReader(res.Body, &cipherM6); err != nil {
|
if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &cipherM6); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cipherM6.State != StateM6 || cipherM6.Error != 0 {
|
if cipherM6.State != StateM6 || cipherM6.Error != 0 {
|
||||||
@@ -296,7 +295,7 @@ func (c *Client) ListPairings() error {
|
|||||||
State byte `tlv8:"6"`
|
State byte `tlv8:"6"`
|
||||||
Permission byte `tlv8:"11"`
|
Permission byte `tlv8:"11"`
|
||||||
}
|
}
|
||||||
if err = tlv8.UnmarshalReader(res.Body, &plainM2); err != nil {
|
if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +328,7 @@ func (c *Client) PairingsAdd(clientID string, clientPublic []byte, admin bool) e
|
|||||||
State byte `tlv8:"6"`
|
State byte `tlv8:"6"`
|
||||||
Unknown byte `tlv8:"7"`
|
Unknown byte `tlv8:"7"`
|
||||||
}
|
}
|
||||||
if err = tlv8.UnmarshalReader(res.Body, &plainM2); err != nil {
|
if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,7 +353,7 @@ func (c *Client) DeletePairing(id string) error {
|
|||||||
var plainM2 struct {
|
var plainM2 struct {
|
||||||
State byte `tlv8:"6"`
|
State byte `tlv8:"6"`
|
||||||
}
|
}
|
||||||
if err = tlv8.UnmarshalReader(res.Body, &plainM2); err != nil {
|
if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if plainM2.State != StateM2 {
|
if plainM2.State != StateM2 {
|
||||||
|
|||||||
@@ -1,32 +1,50 @@
|
|||||||
package secure
|
package hap
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
|
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Conn struct {
|
type Conn struct {
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
|
rw *bufio.ReadWriter
|
||||||
rd *bufio.Reader
|
wmu sync.Mutex
|
||||||
wr *bufio.Writer
|
|
||||||
|
|
||||||
encryptKey []byte
|
encryptKey []byte
|
||||||
decryptKey []byte
|
decryptKey []byte
|
||||||
encryptCnt uint64
|
encryptCnt uint64
|
||||||
decryptCnt uint64
|
decryptCnt uint64
|
||||||
|
|
||||||
|
//ClientID string
|
||||||
SharedKey []byte
|
SharedKey []byte
|
||||||
|
|
||||||
|
recv int
|
||||||
|
send int
|
||||||
}
|
}
|
||||||
|
|
||||||
func Client(conn net.Conn, sharedKey []byte, isClient bool) (net.Conn, error) {
|
func (c *Conn) MarshalJSON() ([]byte, error) {
|
||||||
|
conn := core.Connection{
|
||||||
|
ID: core.ID(c),
|
||||||
|
FormatName: "homekit",
|
||||||
|
Protocol: "hap",
|
||||||
|
RemoteAddr: c.conn.RemoteAddr().String(),
|
||||||
|
Recv: c.recv,
|
||||||
|
Send: c.send,
|
||||||
|
}
|
||||||
|
return json.Marshal(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConn(conn net.Conn, rw *bufio.ReadWriter, sharedKey []byte, isClient bool) (*Conn, error) {
|
||||||
key1, err := hkdf.Sha512(sharedKey, "Control-Salt", "Control-Read-Encryption-Key")
|
key1, err := hkdf.Sha512(sharedKey, "Control-Salt", "Control-Read-Encryption-Key")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -39,8 +57,7 @@ func Client(conn net.Conn, sharedKey []byte, isClient bool) (net.Conn, error) {
|
|||||||
|
|
||||||
c := &Conn{
|
c := &Conn{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
rd: bufio.NewReaderSize(conn, 32*1024),
|
rw: rw,
|
||||||
wr: bufio.NewWriterSize(conn, 32*1024),
|
|
||||||
|
|
||||||
SharedKey: sharedKey,
|
SharedKey: sharedKey,
|
||||||
}
|
}
|
||||||
@@ -55,8 +72,8 @@ func Client(conn net.Conn, sharedKey []byte, isClient bool) (net.Conn, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// PacketSizeMax is the max length of encrypted packets
|
// packetSizeMax is the max length of encrypted packets
|
||||||
PacketSizeMax = 0x400
|
packetSizeMax = 0x400
|
||||||
|
|
||||||
VerifySize = 2
|
VerifySize = 2
|
||||||
NonceSize = 8
|
NonceSize = 8
|
||||||
@@ -64,19 +81,19 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (c *Conn) Read(b []byte) (n int, err error) {
|
func (c *Conn) Read(b []byte) (n int, err error) {
|
||||||
if cap(b) < PacketSizeMax {
|
if cap(b) < packetSizeMax {
|
||||||
return 0, errors.New("hap: read buffer is too small")
|
return 0, errors.New("hap: read buffer is too small")
|
||||||
}
|
}
|
||||||
|
|
||||||
verify := make([]byte, 2) // verify = plain message size
|
verify := make([]byte, VerifySize) // verify = plain message size
|
||||||
if _, err = io.ReadFull(c.rd, verify); err != nil {
|
if _, err = io.ReadFull(c.rw, verify); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
n = int(binary.LittleEndian.Uint16(verify))
|
n = int(binary.LittleEndian.Uint16(verify))
|
||||||
ciphertext := make([]byte, n+Overhead)
|
|
||||||
|
|
||||||
if _, err = io.ReadFull(c.rd, ciphertext); err != nil {
|
ciphertext := make([]byte, n+Overhead)
|
||||||
|
if _, err = io.ReadFull(c.rw, ciphertext); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,22 +102,27 @@ func (c *Conn) Read(b []byte) (n int, err error) {
|
|||||||
c.decryptCnt++
|
c.decryptCnt++
|
||||||
|
|
||||||
_, err = chacha20poly1305.DecryptAndVerify(c.decryptKey, b[:0], nonce, ciphertext, verify)
|
_, err = chacha20poly1305.DecryptAndVerify(c.decryptKey, b[:0], nonce, ciphertext, verify)
|
||||||
|
|
||||||
|
c.recv += n
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Conn) Write(b []byte) (n int, err error) {
|
func (c *Conn) Write(b []byte) (n int, err error) {
|
||||||
buf := make([]byte, 0, PacketSizeMax+Overhead)
|
c.wmu.Lock()
|
||||||
|
defer c.wmu.Unlock()
|
||||||
|
|
||||||
|
buf := make([]byte, 0, packetSizeMax+Overhead)
|
||||||
nonce := make([]byte, NonceSize)
|
nonce := make([]byte, NonceSize)
|
||||||
verify := make([]byte, VerifySize)
|
verify := make([]byte, VerifySize)
|
||||||
|
|
||||||
for len(b) > 0 {
|
for len(b) > 0 {
|
||||||
size := len(b)
|
size := len(b)
|
||||||
if size > PacketSizeMax {
|
if size > packetSizeMax {
|
||||||
size = PacketSizeMax
|
size = packetSizeMax
|
||||||
}
|
}
|
||||||
|
|
||||||
binary.LittleEndian.PutUint16(verify, uint16(size))
|
binary.LittleEndian.PutUint16(verify, uint16(size))
|
||||||
if _, err = c.wr.Write(verify); err != nil {
|
if _, err = c.rw.Write(verify); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +134,7 @@ func (c *Conn) Write(b []byte) (n int, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err = c.wr.Write(buf[:size+Overhead]); err != nil {
|
if _, err = c.rw.Write(buf[:size+Overhead]); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +142,9 @@ func (c *Conn) Write(b []byte) (n int, err error) {
|
|||||||
n += size
|
n += size
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.wr.Flush()
|
err = c.rw.Flush()
|
||||||
|
|
||||||
|
c.send += n
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
+27
-6
@@ -4,16 +4,18 @@ package hds
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||||
|
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
|
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/secure"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Client(conn net.Conn, key []byte, salt string, controller bool) (*Conn, error) {
|
func NewConn(conn net.Conn, key []byte, salt string, controller bool) (*Conn, error) {
|
||||||
writeKey, err := hkdf.Sha512(key, salt, "HDS-Write-Encryption-Key")
|
writeKey, err := hkdf.Sha512(key, salt, "HDS-Write-Encryption-Key")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -49,6 +51,21 @@ type Conn struct {
|
|||||||
encryptKey []byte
|
encryptKey []byte
|
||||||
decryptCnt uint64
|
decryptCnt uint64
|
||||||
encryptCnt uint64
|
encryptCnt uint64
|
||||||
|
|
||||||
|
recv int
|
||||||
|
send int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conn) MarshalJSON() ([]byte, error) {
|
||||||
|
conn := core.Connection{
|
||||||
|
ID: core.ID(c),
|
||||||
|
FormatName: "homekit",
|
||||||
|
Protocol: "hds",
|
||||||
|
RemoteAddr: c.conn.RemoteAddr().String(),
|
||||||
|
Recv: c.recv,
|
||||||
|
Send: c.send,
|
||||||
|
}
|
||||||
|
return json.Marshal(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Conn) Read(p []byte) (n int, err error) {
|
func (c *Conn) Read(p []byte) (n int, err error) {
|
||||||
@@ -59,16 +76,18 @@ func (c *Conn) Read(p []byte) (n int, err error) {
|
|||||||
|
|
||||||
n = int(binary.BigEndian.Uint32(verify) & 0xFFFFFF)
|
n = int(binary.BigEndian.Uint32(verify) & 0xFFFFFF)
|
||||||
|
|
||||||
ciphertext := make([]byte, n+secure.Overhead)
|
ciphertext := make([]byte, n+hap.Overhead)
|
||||||
if _, err = io.ReadFull(c.rd, ciphertext); err != nil {
|
if _, err = io.ReadFull(c.rd, ciphertext); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
nonce := make([]byte, secure.NonceSize)
|
nonce := make([]byte, hap.NonceSize)
|
||||||
binary.LittleEndian.PutUint64(nonce, c.decryptCnt)
|
binary.LittleEndian.PutUint64(nonce, c.decryptCnt)
|
||||||
c.decryptCnt++
|
c.decryptCnt++
|
||||||
|
|
||||||
_, err = chacha20poly1305.DecryptAndVerify(c.decryptKey, p[:0], nonce, ciphertext, verify)
|
_, err = chacha20poly1305.DecryptAndVerify(c.decryptKey, p[:0], nonce, ciphertext, verify)
|
||||||
|
|
||||||
|
c.recv += n
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,11 +100,11 @@ func (c *Conn) Write(b []byte) (n int, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
nonce := make([]byte, secure.NonceSize)
|
nonce := make([]byte, hap.NonceSize)
|
||||||
binary.LittleEndian.PutUint64(nonce, c.encryptCnt)
|
binary.LittleEndian.PutUint64(nonce, c.encryptCnt)
|
||||||
c.encryptCnt++
|
c.encryptCnt++
|
||||||
|
|
||||||
buf := make([]byte, n+secure.Overhead)
|
buf := make([]byte, n+hap.Overhead)
|
||||||
if _, err = chacha20poly1305.EncryptAndSeal(c.encryptKey, buf[:0], nonce, b, verify); err != nil {
|
if _, err = chacha20poly1305.EncryptAndSeal(c.encryptKey, buf[:0], nonce, b, verify); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -95,6 +114,8 @@ func (c *Conn) Write(b []byte) (n int, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = c.wr.Flush()
|
err = c.wr.Flush()
|
||||||
|
|
||||||
|
c.send += n
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+273
-44
@@ -6,29 +6,23 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
|
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/curve25519"
|
"github.com/AlexxIT/go2rtc/pkg/hap/curve25519"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/ed25519"
|
"github.com/AlexxIT/go2rtc/pkg/hap/ed25519"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/secure"
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
||||||
|
"github.com/tadglines/go-pkgs/crypto/srp"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HandlerFunc func(net.Conn) error
|
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Pin string
|
Pin string
|
||||||
DeviceID string
|
DeviceID string
|
||||||
DevicePrivate []byte
|
DevicePrivate []byte
|
||||||
|
|
||||||
GetPair func(conn net.Conn, id string) []byte
|
// GetClientPublic may be nil, so client validation will be disabled
|
||||||
AddPair func(conn net.Conn, id string, public []byte, permissions byte)
|
GetClientPublic func(id string) []byte
|
||||||
|
|
||||||
Handler HandlerFunc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ServerPublic() []byte {
|
func (s *Server) ServerPublic() []byte {
|
||||||
@@ -49,37 +43,240 @@ func (s *Server) SetupHash() string {
|
|||||||
return base64.StdEncoding.EncodeToString(b[:4])
|
return base64.StdEncoding.EncodeToString(b[:4])
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Conn) error {
|
func (s *Server) PairSetup(req *http.Request, rw *bufio.ReadWriter) (id string, publicKey []byte, err error) {
|
||||||
// Request from iPhone
|
// STEP 1. Request from iPhone
|
||||||
var plainM1 struct {
|
var plainM1 struct {
|
||||||
PublicKey string `tlv8:"3"`
|
State byte `tlv8:"6"`
|
||||||
State byte `tlv8:"6"`
|
Method byte `tlv8:"0"`
|
||||||
|
Flags uint32 `tlv8:"19"`
|
||||||
}
|
}
|
||||||
if err := tlv8.UnmarshalReader(io.LimitReader(rw, req.ContentLength), &plainM1); err != nil {
|
if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM1); err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
if plainM1.State != StateM1 {
|
if plainM1.State != StateM1 {
|
||||||
return newRequestError(plainM1)
|
err = newRequestError(plainM1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := []byte("Pair-Setup")
|
||||||
|
|
||||||
|
// Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE)
|
||||||
|
pake, err := srp.NewSRP("rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pake.SaltLength = 16
|
||||||
|
|
||||||
|
salt, verifier, err := pake.ComputeVerifier([]byte(s.Pin))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session := pake.NewServerSession(username, salt, verifier)
|
||||||
|
|
||||||
|
// STEP 2. Response to iPhone
|
||||||
|
plainM2 := struct {
|
||||||
|
State byte `tlv8:"6"`
|
||||||
|
PublicKey string `tlv8:"3"`
|
||||||
|
Salt string `tlv8:"2"`
|
||||||
|
}{
|
||||||
|
State: StateM2,
|
||||||
|
PublicKey: string(session.GetB()),
|
||||||
|
Salt: string(salt),
|
||||||
|
}
|
||||||
|
body, err := tlv8.Marshal(plainM2)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 3. Request from iPhone
|
||||||
|
if req, err = http.ReadRequest(rw.Reader); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var plainM3 struct {
|
||||||
|
State byte `tlv8:"6"`
|
||||||
|
PublicKey string `tlv8:"3"`
|
||||||
|
Proof string `tlv8:"4"`
|
||||||
|
}
|
||||||
|
if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM3); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if plainM3.State != StateM3 {
|
||||||
|
err = newRequestError(plainM3)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// important to compute key before verify client
|
||||||
|
sessionShared, err := session.ComputeKey([]byte(plainM3.PublicKey))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !session.VerifyClientAuthenticator([]byte(plainM3.Proof)) {
|
||||||
|
err = errors.New("hap: VerifyClientAuthenticator")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
proof := session.ComputeAuthenticator([]byte(plainM3.Proof)) // server proof
|
||||||
|
|
||||||
|
// STEP 4. Response to iPhone
|
||||||
|
payloadM4 := struct {
|
||||||
|
State byte `tlv8:"6"`
|
||||||
|
Proof string `tlv8:"4"`
|
||||||
|
}{
|
||||||
|
State: StateM4,
|
||||||
|
Proof: string(proof),
|
||||||
|
}
|
||||||
|
if body, err = tlv8.Marshal(payloadM4); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 5. Request from iPhone
|
||||||
|
if req, err = http.ReadRequest(rw.Reader); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cipherM5 struct {
|
||||||
|
State byte `tlv8:"6"`
|
||||||
|
EncryptedData string `tlv8:"5"`
|
||||||
|
}
|
||||||
|
if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &cipherM5); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cipherM5.State != StateM5 {
|
||||||
|
err = newRequestError(cipherM5)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// decrypt message using session shared
|
||||||
|
encryptKey, err := hkdf.Sha512(sessionShared, "Pair-Setup-Encrypt-Salt", "Pair-Setup-Encrypt-Info")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := chacha20poly1305.Decrypt(encryptKey, "PS-Msg05", []byte(cipherM5.EncryptedData))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// unpack message from TLV8
|
||||||
|
var plainM5 struct {
|
||||||
|
Identifier string `tlv8:"1"`
|
||||||
|
PublicKey string `tlv8:"3"`
|
||||||
|
Signature string `tlv8:"10"`
|
||||||
|
}
|
||||||
|
if err = tlv8.Unmarshal(b, &plainM5); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. verify client ID and Public
|
||||||
|
remoteSign, err := hkdf.Sha512(
|
||||||
|
sessionShared, "Pair-Setup-Controller-Sign-Salt", "Pair-Setup-Controller-Sign-Info",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b = Append(remoteSign, plainM5.Identifier, plainM5.PublicKey)
|
||||||
|
if !ed25519.ValidateSignature([]byte(plainM5.PublicKey), b, []byte(plainM5.Signature)) {
|
||||||
|
err = errors.New("hap: ValidateSignature")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. generate signature to our ID and Public
|
||||||
|
localSign, err := hkdf.Sha512(
|
||||||
|
sessionShared, "Pair-Setup-Accessory-Sign-Salt", "Pair-Setup-Accessory-Sign-Info",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b = Append(localSign, s.DeviceID, s.ServerPublic()) // ServerPublic
|
||||||
|
signature, err := ed25519.Signature(s.DevicePrivate, b)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. pack our ID and Public
|
||||||
|
plainM6 := struct {
|
||||||
|
Identifier string `tlv8:"1"`
|
||||||
|
PublicKey string `tlv8:"3"`
|
||||||
|
Signature string `tlv8:"10"`
|
||||||
|
}{
|
||||||
|
Identifier: s.DeviceID,
|
||||||
|
PublicKey: string(s.ServerPublic()),
|
||||||
|
Signature: string(signature),
|
||||||
|
}
|
||||||
|
if b, err = tlv8.Marshal(plainM6); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. encrypt message
|
||||||
|
b, err = chacha20poly1305.Encrypt(encryptKey, "PS-Msg06", b)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 6. Response to iPhone
|
||||||
|
cipherM6 := struct {
|
||||||
|
State byte `tlv8:"6"`
|
||||||
|
EncryptedData string `tlv8:"5"`
|
||||||
|
}{
|
||||||
|
State: StateM6,
|
||||||
|
EncryptedData: string(b),
|
||||||
|
}
|
||||||
|
if body, err = tlv8.Marshal(cipherM6); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id = plainM5.Identifier
|
||||||
|
publicKey = []byte(plainM5.PublicKey)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter) (id string, sessionKey []byte, err error) {
|
||||||
|
// Request from iPhone
|
||||||
|
var plainM1 struct {
|
||||||
|
State byte `tlv8:"6"`
|
||||||
|
PublicKey string `tlv8:"3"`
|
||||||
|
}
|
||||||
|
if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM1); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if plainM1.State != StateM1 {
|
||||||
|
err = newRequestError(plainM1)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate the key pair
|
// Generate the key pair
|
||||||
sessionPublic, sessionPrivate := curve25519.GenerateKeyPair()
|
sessionPublic, sessionPrivate := curve25519.GenerateKeyPair()
|
||||||
sessionShared, err := curve25519.SharedSecret(sessionPrivate, []byte(plainM1.PublicKey))
|
sessionShared, err := curve25519.SharedSecret(sessionPrivate, []byte(plainM1.PublicKey))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
encryptKey, err := hkdf.Sha512(
|
encryptKey, err := hkdf.Sha512(
|
||||||
sessionShared, "Pair-Verify-Encrypt-Salt", "Pair-Verify-Encrypt-Info",
|
sessionShared, "Pair-Verify-Encrypt-Salt", "Pair-Verify-Encrypt-Info",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
b := Append(sessionPublic, s.DeviceID, plainM1.PublicKey)
|
b := Append(sessionPublic, s.DeviceID, plainM1.PublicKey)
|
||||||
signature, err := ed25519.Signature(s.DevicePrivate, b)
|
signature, err := ed25519.Signature(s.DevicePrivate, b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP M2. Response to iPhone
|
// STEP M2. Response to iPhone
|
||||||
@@ -91,12 +288,12 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co
|
|||||||
Signature: string(signature),
|
Signature: string(signature),
|
||||||
}
|
}
|
||||||
if b, err = tlv8.Marshal(plainM2); err != nil {
|
if b, err = tlv8.Marshal(plainM2); err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
b, err = chacha20poly1305.Encrypt(encryptKey, "PV-Msg02", b)
|
b, err = chacha20poly1305.Encrypt(encryptKey, "PV-Msg02", b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cipherM2 := struct {
|
cipherM2 := struct {
|
||||||
@@ -110,30 +307,32 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co
|
|||||||
}
|
}
|
||||||
body, err := tlv8.Marshal(cipherM2)
|
body, err := tlv8.Marshal(cipherM2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP M3. Request from iPhone
|
// STEP M3. Request from iPhone
|
||||||
if req, err = http.ReadRequest(rw.Reader); err != nil {
|
if req, err = http.ReadRequest(rw.Reader); err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var cipherM3 struct {
|
var cipherM3 struct {
|
||||||
EncryptedData string `tlv8:"5"`
|
|
||||||
State byte `tlv8:"6"`
|
State byte `tlv8:"6"`
|
||||||
|
EncryptedData string `tlv8:"5"`
|
||||||
}
|
}
|
||||||
if err = tlv8.UnmarshalReader(req.Body, &cipherM3); err != nil {
|
if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &cipherM3); err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
if cipherM3.State != StateM3 {
|
if cipherM3.State != StateM3 {
|
||||||
return newRequestError(cipherM3)
|
err = newRequestError(cipherM3)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if b, err = chacha20poly1305.Decrypt(encryptKey, "PV-Msg03", []byte(cipherM3.EncryptedData)); err != nil {
|
b, err = chacha20poly1305.Decrypt(encryptKey, "PV-Msg03", []byte(cipherM3.EncryptedData))
|
||||||
return err
|
if err != nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var plainM3 struct {
|
var plainM3 struct {
|
||||||
@@ -141,17 +340,21 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co
|
|||||||
Signature string `tlv8:"10"`
|
Signature string `tlv8:"10"`
|
||||||
}
|
}
|
||||||
if err = tlv8.Unmarshal(b, &plainM3); err != nil {
|
if err = tlv8.Unmarshal(b, &plainM3); err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
clientPublic := s.GetPair(conn, plainM3.Identifier)
|
if s.GetClientPublic != nil {
|
||||||
if clientPublic == nil {
|
clientPublic := s.GetClientPublic(plainM3.Identifier)
|
||||||
return fmt.Errorf("hap: PairVerify from: %s, with unknown client_id: %s", conn.RemoteAddr(), plainM3.Identifier)
|
if clientPublic == nil {
|
||||||
}
|
err = errors.New("hap: PairVerify with unknown client_id: " + plainM3.Identifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
b = Append(plainM1.PublicKey, plainM3.Identifier, sessionPublic)
|
b = Append(plainM1.PublicKey, plainM3.Identifier, sessionPublic)
|
||||||
if !ed25519.ValidateSignature(clientPublic, b, []byte(plainM3.Signature)) {
|
if !ed25519.ValidateSignature(clientPublic, b, []byte(plainM3.Signature)) {
|
||||||
return errors.New("new: ValidateSignature")
|
err = errors.New("hap: ValidateSignature")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP M4. Response to iPhone
|
// STEP M4. Response to iPhone
|
||||||
@@ -161,15 +364,41 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co
|
|||||||
State: StateM4,
|
State: StateM4,
|
||||||
}
|
}
|
||||||
if body, err = tlv8.Marshal(payloadM4); err != nil {
|
if body, err = tlv8.Marshal(payloadM4); err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if conn, err = secure.Client(conn, sessionShared, false); err != nil {
|
id = plainM3.Identifier
|
||||||
return err
|
sessionKey = sessionShared
|
||||||
}
|
|
||||||
|
|
||||||
return s.Handler(conn)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WriteResponse(w *bufio.Writer, statusCode int, contentType string, body []byte) error {
|
||||||
|
header := fmt.Sprintf(
|
||||||
|
"HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %d\r\n\r\n",
|
||||||
|
statusCode, http.StatusText(statusCode), contentType, len(body),
|
||||||
|
)
|
||||||
|
body = append([]byte(header), body...)
|
||||||
|
if _, err := w.Write(body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
//func WriteBackoff(rw *bufio.ReadWriter) error {
|
||||||
|
// plainM2 := struct {
|
||||||
|
// State byte `tlv8:"6"`
|
||||||
|
// Error byte `tlv8:"7"`
|
||||||
|
// }{
|
||||||
|
// State: StateM2,
|
||||||
|
// Error: 3, // BackoffError
|
||||||
|
// }
|
||||||
|
// body, err := tlv8.Marshal(plainM2)
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
// return WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body)
|
||||||
|
//}
|
||||||
|
|||||||
@@ -1,252 +0,0 @@
|
|||||||
package hap
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"crypto/sha512"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/ed25519"
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
|
||||||
"github.com/tadglines/go-pkgs/crypto/srp"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
PairMethodSetup = iota
|
|
||||||
PairMethodSetupWithAuth
|
|
||||||
PairMethodVerify
|
|
||||||
PairMethodAdd
|
|
||||||
PairMethodRemove
|
|
||||||
PairMethodList
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Server) PairSetup(req *http.Request, rw *bufio.ReadWriter, conn net.Conn) error {
|
|
||||||
if req.Header.Get("Content-Type") != MimeTLV8 {
|
|
||||||
return errors.New("hap: wrong content type")
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 1. Request from iPhone
|
|
||||||
var plainM1 struct {
|
|
||||||
Method byte `tlv8:"0"`
|
|
||||||
State byte `tlv8:"6"`
|
|
||||||
Flags uint32 `tlv8:"19"`
|
|
||||||
}
|
|
||||||
if err := tlv8.UnmarshalReader(io.LimitReader(rw, req.ContentLength), &plainM1); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if plainM1.State != StateM1 {
|
|
||||||
return newRequestError(plainM1)
|
|
||||||
}
|
|
||||||
|
|
||||||
username := []byte("Pair-Setup")
|
|
||||||
|
|
||||||
// Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE)
|
|
||||||
pake, err := srp.NewSRP(
|
|
||||||
"rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
pake.SaltLength = 16
|
|
||||||
|
|
||||||
salt, verifier, err := pake.ComputeVerifier([]byte(s.Pin))
|
|
||||||
|
|
||||||
session := pake.NewServerSession(username, salt, verifier)
|
|
||||||
|
|
||||||
// STEP 2. Response to iPhone
|
|
||||||
plainM2 := struct {
|
|
||||||
Salt string `tlv8:"2"`
|
|
||||||
PublicKey string `tlv8:"3"`
|
|
||||||
State byte `tlv8:"6"`
|
|
||||||
}{
|
|
||||||
State: StateM2,
|
|
||||||
PublicKey: string(session.GetB()),
|
|
||||||
Salt: string(salt),
|
|
||||||
}
|
|
||||||
body, err := tlv8.Marshal(plainM2)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 3. Request from iPhone
|
|
||||||
if req, err = http.ReadRequest(rw.Reader); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var plainM3 struct {
|
|
||||||
SessionKey string `tlv8:"3"`
|
|
||||||
Proof string `tlv8:"4"`
|
|
||||||
State byte `tlv8:"6"`
|
|
||||||
}
|
|
||||||
if err = tlv8.UnmarshalReader(req.Body, &plainM3); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if plainM3.State != StateM3 {
|
|
||||||
return newRequestError(plainM3)
|
|
||||||
}
|
|
||||||
|
|
||||||
// important to compute key before verify client
|
|
||||||
sessionShared, err := session.ComputeKey([]byte(plainM3.SessionKey))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !session.VerifyClientAuthenticator([]byte(plainM3.Proof)) {
|
|
||||||
return errors.New("hap: VerifyClientAuthenticator")
|
|
||||||
}
|
|
||||||
|
|
||||||
proof := session.ComputeAuthenticator([]byte(plainM3.Proof)) // server proof
|
|
||||||
|
|
||||||
// STEP 4. Response to iPhone
|
|
||||||
payloadM4 := struct {
|
|
||||||
Proof string `tlv8:"4"`
|
|
||||||
State byte `tlv8:"6"`
|
|
||||||
}{
|
|
||||||
Proof: string(proof),
|
|
||||||
State: StateM4,
|
|
||||||
}
|
|
||||||
if body, err = tlv8.Marshal(payloadM4); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 5. Request from iPhone
|
|
||||||
if req, err = http.ReadRequest(rw.Reader); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var cipherM5 struct {
|
|
||||||
EncryptedData string `tlv8:"5"`
|
|
||||||
State byte `tlv8:"6"`
|
|
||||||
}
|
|
||||||
if err = tlv8.UnmarshalReader(req.Body, &cipherM5); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if cipherM5.State != StateM5 {
|
|
||||||
return newRequestError(cipherM5)
|
|
||||||
}
|
|
||||||
|
|
||||||
// decrypt message using session shared
|
|
||||||
encryptKey, err := hkdf.Sha512(sessionShared, "Pair-Setup-Encrypt-Salt", "Pair-Setup-Encrypt-Info")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := chacha20poly1305.Decrypt(encryptKey, "PS-Msg05", []byte(cipherM5.EncryptedData))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// unpack message from TLV8
|
|
||||||
var plainM5 struct {
|
|
||||||
Identifier string `tlv8:"1"`
|
|
||||||
PublicKey string `tlv8:"3"`
|
|
||||||
Signature string `tlv8:"10"`
|
|
||||||
}
|
|
||||||
if err = tlv8.Unmarshal(b, &plainM5); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. verify client ID and Public
|
|
||||||
remoteSign, err := hkdf.Sha512(
|
|
||||||
sessionShared, "Pair-Setup-Controller-Sign-Salt", "Pair-Setup-Controller-Sign-Info",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
b = Append(remoteSign, plainM5.Identifier, plainM5.PublicKey)
|
|
||||||
if !ed25519.ValidateSignature([]byte(plainM5.PublicKey), b, []byte(plainM5.Signature)) {
|
|
||||||
return errors.New("hap: ValidateSignature")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. generate signature to our ID and Public
|
|
||||||
localSign, err := hkdf.Sha512(
|
|
||||||
sessionShared, "Pair-Setup-Accessory-Sign-Salt", "Pair-Setup-Accessory-Sign-Info",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
b = Append(localSign, s.DeviceID, s.ServerPublic()) // ServerPublic
|
|
||||||
signature, err := ed25519.Signature(s.DevicePrivate, b)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. pack our ID and Public
|
|
||||||
plainM6 := struct {
|
|
||||||
Identifier string `tlv8:"1"`
|
|
||||||
PublicKey string `tlv8:"3"`
|
|
||||||
Signature string `tlv8:"10"`
|
|
||||||
}{
|
|
||||||
Identifier: s.DeviceID,
|
|
||||||
PublicKey: string(s.ServerPublic()),
|
|
||||||
Signature: string(signature),
|
|
||||||
}
|
|
||||||
if b, err = tlv8.Marshal(plainM6); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. encrypt message
|
|
||||||
b, err = chacha20poly1305.Encrypt(encryptKey, "PS-Msg06", b)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 6. Response to iPhone
|
|
||||||
cipherM6 := struct {
|
|
||||||
EncryptedData string `tlv8:"5"`
|
|
||||||
State byte `tlv8:"6"`
|
|
||||||
}{
|
|
||||||
State: StateM6,
|
|
||||||
EncryptedData: string(b),
|
|
||||||
}
|
|
||||||
if body, err = tlv8.Marshal(cipherM6); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.AddPair(conn, plainM5.Identifier, []byte(plainM5.PublicKey), PermissionAdmin)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func WriteResponse(w *bufio.Writer, statusCode int, contentType string, body []byte) error {
|
|
||||||
header := fmt.Sprintf(
|
|
||||||
"HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %d\r\n\r\n",
|
|
||||||
statusCode, http.StatusText(statusCode), contentType, len(body),
|
|
||||||
)
|
|
||||||
body = append([]byte(header), body...)
|
|
||||||
if _, err := w.Write(body); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return w.Flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
func WriteBackoff(rw *bufio.ReadWriter) error {
|
|
||||||
plainM2 := struct {
|
|
||||||
State byte `tlv8:"6"`
|
|
||||||
Error byte `tlv8:"7"`
|
|
||||||
}{
|
|
||||||
State: StateM2,
|
|
||||||
Error: 3, // BackoffError
|
|
||||||
}
|
|
||||||
body, err := tlv8.Marshal(plainM2)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body)
|
|
||||||
}
|
|
||||||
+21
-2
@@ -112,6 +112,10 @@ func appendValue(b []byte, tag byte, value reflect.Value) ([]byte, error) {
|
|||||||
v := value.Uint()
|
v := value.Uint()
|
||||||
return append(b, tag, 4, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)), nil
|
return append(b, tag, 4, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)), nil
|
||||||
|
|
||||||
|
case reflect.Uint64:
|
||||||
|
v := value.Uint()
|
||||||
|
return binary.LittleEndian.AppendUint64(append(b, tag, 8), v), nil
|
||||||
|
|
||||||
case reflect.Float32:
|
case reflect.Float32:
|
||||||
v := math.Float32bits(float32(value.Float()))
|
v := math.Float32bits(float32(value.Float()))
|
||||||
return append(b, tag, 4, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)), nil
|
return append(b, tag, 4, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)), nil
|
||||||
@@ -170,11 +174,20 @@ func UnmarshalBase64(in any, out any) error {
|
|||||||
return Unmarshal(data, out)
|
return Unmarshal(data, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func UnmarshalReader(r io.Reader, v any) error {
|
func UnmarshalReader(r io.Reader, n int64, v any) error {
|
||||||
data, err := io.ReadAll(r)
|
var data []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if n > 0 {
|
||||||
|
data = make([]byte, n)
|
||||||
|
_, err = io.ReadFull(r, data)
|
||||||
|
} else {
|
||||||
|
data, err = io.ReadAll(r)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return Unmarshal(data, v)
|
return Unmarshal(data, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +314,12 @@ func unmarshalValue(v []byte, value reflect.Value) error {
|
|||||||
}
|
}
|
||||||
value.SetUint(uint64(v[0]) | uint64(v[1])<<8 | uint64(v[2])<<16 | uint64(v[3])<<24)
|
value.SetUint(uint64(v[0]) | uint64(v[1])<<8 | uint64(v[2])<<16 | uint64(v[3])<<24)
|
||||||
|
|
||||||
|
case reflect.Uint64:
|
||||||
|
if len(v) != 8 {
|
||||||
|
return errors.New("tlv8: wrong size: " + value.Type().Name())
|
||||||
|
}
|
||||||
|
value.SetUint(binary.LittleEndian.Uint64(v))
|
||||||
|
|
||||||
case reflect.Float32:
|
case reflect.Float32:
|
||||||
f := math.Float32frombits(binary.LittleEndian.Uint32(v))
|
f := math.Float32frombits(binary.LittleEndian.Uint32(v))
|
||||||
value.SetFloat(float64(f))
|
value.SetFloat(float64(f))
|
||||||
|
|||||||
+15
-11
@@ -49,7 +49,7 @@ func NewConsumer(conn net.Conn, server *srtp.Server) *Consumer {
|
|||||||
Connection: core.Connection{
|
Connection: core.Connection{
|
||||||
ID: core.NewID(),
|
ID: core.NewID(),
|
||||||
FormatName: "homekit",
|
FormatName: "homekit",
|
||||||
Protocol: "udp",
|
Protocol: "rtp",
|
||||||
RemoteAddr: conn.RemoteAddr().String(),
|
RemoteAddr: conn.RemoteAddr().String(),
|
||||||
Medias: medias,
|
Medias: medias,
|
||||||
Transport: conn,
|
Transport: conn,
|
||||||
@@ -59,7 +59,11 @@ func NewConsumer(conn net.Conn, server *srtp.Server) *Consumer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Consumer) SetOffer(offer *camera.SetupEndpoints) {
|
func (c *Consumer) SessionID() string {
|
||||||
|
return c.sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumer) SetOffer(offer *camera.SetupEndpointsRequest) {
|
||||||
c.sessionID = offer.SessionID
|
c.sessionID = offer.SessionID
|
||||||
c.videoSession = &srtp.Session{
|
c.videoSession = &srtp.Session{
|
||||||
Remote: &srtp.Endpoint{
|
Remote: &srtp.Endpoint{
|
||||||
@@ -79,32 +83,32 @@ func (c *Consumer) SetOffer(offer *camera.SetupEndpoints) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Consumer) GetAnswer() *camera.SetupEndpoints {
|
func (c *Consumer) GetAnswer() *camera.SetupEndpointsResponse {
|
||||||
c.videoSession.Local = c.srtpEndpoint()
|
c.videoSession.Local = c.srtpEndpoint()
|
||||||
c.audioSession.Local = c.srtpEndpoint()
|
c.audioSession.Local = c.srtpEndpoint()
|
||||||
|
|
||||||
return &camera.SetupEndpoints{
|
return &camera.SetupEndpointsResponse{
|
||||||
SessionID: c.sessionID,
|
SessionID: c.sessionID,
|
||||||
Status: []byte{0},
|
Status: camera.StreamingStatusAvailable,
|
||||||
Address: camera.Addr{
|
Address: camera.Address{
|
||||||
IPAddr: c.videoSession.Local.Addr,
|
IPAddr: c.videoSession.Local.Addr,
|
||||||
VideoRTPPort: c.videoSession.Local.Port,
|
VideoRTPPort: c.videoSession.Local.Port,
|
||||||
AudioRTPPort: c.audioSession.Local.Port,
|
AudioRTPPort: c.audioSession.Local.Port,
|
||||||
},
|
},
|
||||||
VideoCrypto: camera.CryptoSuite{
|
VideoCrypto: camera.SRTPCryptoSuite{
|
||||||
MasterKey: string(c.videoSession.Local.MasterKey),
|
MasterKey: string(c.videoSession.Local.MasterKey),
|
||||||
MasterSalt: string(c.videoSession.Local.MasterSalt),
|
MasterSalt: string(c.videoSession.Local.MasterSalt),
|
||||||
},
|
},
|
||||||
AudioCrypto: camera.CryptoSuite{
|
AudioCrypto: camera.SRTPCryptoSuite{
|
||||||
MasterKey: string(c.audioSession.Local.MasterKey),
|
MasterKey: string(c.audioSession.Local.MasterKey),
|
||||||
MasterSalt: string(c.audioSession.Local.MasterSalt),
|
MasterSalt: string(c.audioSession.Local.MasterSalt),
|
||||||
},
|
},
|
||||||
VideoSSRC: []uint32{c.videoSession.Local.SSRC},
|
VideoSSRC: c.videoSession.Local.SSRC,
|
||||||
AudioSSRC: []uint32{c.audioSession.Local.SSRC},
|
AudioSSRC: c.audioSession.Local.SSRC,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Consumer) SetConfig(conf *camera.SelectedStreamConfig) bool {
|
func (c *Consumer) SetConfig(conf *camera.SelectedStreamConfiguration) bool {
|
||||||
if c.sessionID != conf.Control.SessionID {
|
if c.sessionID != conf.Control.SessionID {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-10
@@ -13,7 +13,7 @@ var videoCodecs = [...]string{core.CodecH264}
|
|||||||
var videoProfiles = [...]string{"4200", "4D00", "6400"}
|
var videoProfiles = [...]string{"4200", "4D00", "6400"}
|
||||||
var videoLevels = [...]string{"1F", "20", "28"}
|
var videoLevels = [...]string{"1F", "20", "28"}
|
||||||
|
|
||||||
func videoToMedia(codecs []camera.VideoCodec) *core.Media {
|
func videoToMedia(codecs []camera.VideoCodecConfiguration) *core.Media {
|
||||||
media := &core.Media{
|
media := &core.Media{
|
||||||
Kind: core.KindVideo, Direction: core.DirectionRecvonly,
|
Kind: core.KindVideo, Direction: core.DirectionRecvonly,
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@ func videoToMedia(codecs []camera.VideoCodec) *core.Media {
|
|||||||
var audioCodecs = [...]string{core.CodecPCMU, core.CodecPCMA, core.CodecELD, core.CodecOpus}
|
var audioCodecs = [...]string{core.CodecPCMU, core.CodecPCMA, core.CodecELD, core.CodecOpus}
|
||||||
var audioSampleRates = [...]uint32{8000, 16000, 24000}
|
var audioSampleRates = [...]uint32{8000, 16000, 24000}
|
||||||
|
|
||||||
func audioToMedia(codecs []camera.AudioCodec) *core.Media {
|
func audioToMedia(codecs []camera.AudioCodecConfiguration) *core.Media {
|
||||||
media := &core.Media{
|
media := &core.Media{
|
||||||
Kind: core.KindAudio, Direction: core.DirectionRecvonly,
|
Kind: core.KindAudio, Direction: core.DirectionRecvonly,
|
||||||
}
|
}
|
||||||
@@ -67,10 +67,10 @@ func audioToMedia(codecs []camera.AudioCodec) *core.Media {
|
|||||||
return media
|
return media
|
||||||
}
|
}
|
||||||
|
|
||||||
func trackToVideo(track *core.Receiver, video0 *camera.VideoCodec) *camera.VideoCodec {
|
func trackToVideo(track *core.Receiver, video0 *camera.VideoCodecConfiguration, maxWidth, maxHeight int) *camera.VideoCodecConfiguration {
|
||||||
profileID := video0.CodecParams[0].ProfileID[0]
|
profileID := video0.CodecParams[0].ProfileID[0]
|
||||||
level := video0.CodecParams[0].Level[0]
|
level := video0.CodecParams[0].Level[0]
|
||||||
attrs := video0.VideoAttrs[0]
|
var attrs camera.VideoCodecAttributes
|
||||||
|
|
||||||
if track != nil {
|
if track != nil {
|
||||||
profile := h264.GetProfileLevelID(track.Codec.FmtpLine)
|
profile := h264.GetProfileLevelID(track.Codec.FmtpLine)
|
||||||
@@ -90,25 +90,28 @@ func trackToVideo(track *core.Receiver, video0 *camera.VideoCodec) *camera.Video
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range video0.VideoAttrs {
|
for _, s := range video0.VideoAttrs {
|
||||||
|
if (maxWidth > 0 && int(s.Width) > maxWidth) || (maxHeight > 0 && int(s.Height) > maxHeight) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if s.Width > attrs.Width || s.Height > attrs.Height {
|
if s.Width > attrs.Width || s.Height > attrs.Height {
|
||||||
attrs = s
|
attrs = s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &camera.VideoCodec{
|
return &camera.VideoCodecConfiguration{
|
||||||
CodecType: video0.CodecType,
|
CodecType: video0.CodecType,
|
||||||
CodecParams: []camera.VideoParams{
|
CodecParams: []camera.VideoCodecParameters{
|
||||||
{
|
{
|
||||||
ProfileID: []byte{profileID},
|
ProfileID: []byte{profileID},
|
||||||
Level: []byte{level},
|
Level: []byte{level},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
VideoAttrs: []camera.VideoAttrs{attrs},
|
VideoAttrs: []camera.VideoCodecAttributes{attrs},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func trackToAudio(track *core.Receiver, audio0 *camera.AudioCodec) *camera.AudioCodec {
|
func trackToAudio(track *core.Receiver, audio0 *camera.AudioCodecConfiguration) *camera.AudioCodecConfiguration {
|
||||||
codecType := audio0.CodecType
|
codecType := audio0.CodecType
|
||||||
channels := audio0.CodecParams[0].Channels
|
channels := audio0.CodecParams[0].Channels
|
||||||
sampleRate := audio0.CodecParams[0].SampleRate[0]
|
sampleRate := audio0.CodecParams[0].SampleRate[0]
|
||||||
@@ -131,9 +134,9 @@ func trackToAudio(track *core.Receiver, audio0 *camera.AudioCodec) *camera.Audio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &camera.AudioCodec{
|
return &camera.AudioCodecConfiguration{
|
||||||
CodecType: codecType,
|
CodecType: codecType,
|
||||||
CodecParams: []camera.AudioParams{
|
CodecParams: []camera.AudioCodecParameters{
|
||||||
{
|
{
|
||||||
Channels: channels,
|
Channels: channels,
|
||||||
SampleRate: []byte{sampleRate},
|
SampleRate: []byte{sampleRate},
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Debug(v any) {
|
||||||
|
switch v := v.(type) {
|
||||||
|
case *http.Request:
|
||||||
|
if v == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v.ContentLength != 0 {
|
||||||
|
b, err := io.ReadAll(v.Body)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
v.Body = io.NopCloser(bytes.NewReader(b))
|
||||||
|
log.Printf("[homekit] request: %s %s\n%s", v.Method, v.RequestURI, b)
|
||||||
|
} else {
|
||||||
|
log.Printf("[homekit] request: %s %s <nobody>", v.Method, v.RequestURI)
|
||||||
|
}
|
||||||
|
case *http.Response:
|
||||||
|
if v == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v.Header.Get("Content-Type") == "image/jpeg" {
|
||||||
|
log.Printf("[homekit] response: %d <jpeg>", v.StatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v.ContentLength != 0 {
|
||||||
|
b, err := io.ReadAll(v.Body)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
v.Body = io.NopCloser(bytes.NewReader(b))
|
||||||
|
log.Printf("[homekit] response: %s %d\n%s", v.Proto, v.StatusCode, b)
|
||||||
|
} else {
|
||||||
|
log.Printf("[homekit] response: %s %d <nobody>", v.Proto, v.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-19
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||||
@@ -22,36 +21,25 @@ type Client struct {
|
|||||||
hap *hap.Client
|
hap *hap.Client
|
||||||
srtp *srtp.Server
|
srtp *srtp.Server
|
||||||
|
|
||||||
videoConfig camera.SupportedVideoStreamConfig
|
videoConfig camera.SupportedVideoStreamConfiguration
|
||||||
audioConfig camera.SupportedAudioStreamConfig
|
audioConfig camera.SupportedAudioStreamConfiguration
|
||||||
|
|
||||||
videoSession *srtp.Session
|
videoSession *srtp.Session
|
||||||
audioSession *srtp.Session
|
audioSession *srtp.Session
|
||||||
|
|
||||||
stream *camera.Stream
|
stream *camera.Stream
|
||||||
|
|
||||||
Bitrate int // in bits/s
|
MaxWidth int
|
||||||
|
MaxHeight int
|
||||||
|
Bitrate int // in bits/s
|
||||||
}
|
}
|
||||||
|
|
||||||
func Dial(rawURL string, server *srtp.Server) (*Client, error) {
|
func Dial(rawURL string, server *srtp.Server) (*Client, error) {
|
||||||
u, err := url.Parse(rawURL)
|
conn, err := hap.Dial(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
query := u.Query()
|
|
||||||
conn := &hap.Client{
|
|
||||||
DeviceAddress: u.Host,
|
|
||||||
DeviceID: query.Get("device_id"),
|
|
||||||
DevicePublic: hap.DecodeKey(query.Get("device_public")),
|
|
||||||
ClientID: query.Get("client_id"),
|
|
||||||
ClientPrivate: hap.DecodeKey(query.Get("client_private")),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = conn.Dial(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &Client{
|
client := &Client{
|
||||||
Connection: core.Connection{
|
Connection: core.Connection{
|
||||||
ID: core.NewID(),
|
ID: core.NewID(),
|
||||||
@@ -129,7 +117,7 @@ func (c *Client) Start() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
videoTrack := c.trackByKind(core.KindVideo)
|
videoTrack := c.trackByKind(core.KindVideo)
|
||||||
videoCodec := trackToVideo(videoTrack, &c.videoConfig.Codecs[0])
|
videoCodec := trackToVideo(videoTrack, &c.videoConfig.Codecs[0], c.MaxWidth, c.MaxHeight)
|
||||||
|
|
||||||
audioTrack := c.trackByKind(core.KindAudio)
|
audioTrack := c.trackByKind(core.KindAudio)
|
||||||
audioCodec := trackToAudio(audioTrack, &c.audioConfig.Codecs[0])
|
audioCodec := trackToAudio(audioTrack, &c.audioConfig.Codecs[0])
|
||||||
|
|||||||
+33
-27
@@ -4,31 +4,30 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/camera"
|
"github.com/AlexxIT/go2rtc/pkg/hap/camera"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/hds"
|
"github.com/AlexxIT/go2rtc/pkg/hap/hds"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/secure"
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ProxyHandler(pair ServerPair, dial func() (net.Conn, error)) hap.HandlerFunc {
|
type ServerProxy interface {
|
||||||
|
ServerPair
|
||||||
|
AddConn(conn any)
|
||||||
|
DelConn(conn any)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProxyHandler(srv ServerProxy, acc net.Conn) HandlerFunc {
|
||||||
return func(con net.Conn) error {
|
return func(con net.Conn) error {
|
||||||
defer con.Close()
|
defer con.Close()
|
||||||
|
|
||||||
acc, err := dial()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer acc.Close()
|
|
||||||
|
|
||||||
pr := &Proxy{
|
pr := &Proxy{
|
||||||
con: con.(*secure.Conn),
|
con: con.(*hap.Conn),
|
||||||
acc: acc.(*secure.Conn),
|
acc: acc.(*hap.Conn),
|
||||||
res: make(chan *http.Response),
|
res: make(chan *http.Response),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,17 +35,17 @@ func ProxyHandler(pair ServerPair, dial func() (net.Conn, error)) hap.HandlerFun
|
|||||||
go pr.handleAcc()
|
go pr.handleAcc()
|
||||||
|
|
||||||
// controller => accessory
|
// controller => accessory
|
||||||
return pr.handleCon(pair)
|
return pr.handleCon(srv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Proxy struct {
|
type Proxy struct {
|
||||||
con *secure.Conn
|
con *hap.Conn
|
||||||
acc *secure.Conn
|
acc *hap.Conn
|
||||||
res chan *http.Response
|
res chan *http.Response
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Proxy) handleCon(pair ServerPair) error {
|
func (p *Proxy) handleCon(srv ServerProxy) error {
|
||||||
var hdsCharIID uint64
|
var hdsCharIID uint64
|
||||||
|
|
||||||
rd := bufio.NewReader(p.con)
|
rd := bufio.NewReader(p.con)
|
||||||
@@ -61,7 +60,7 @@ func (p *Proxy) handleCon(pair ServerPair) error {
|
|||||||
switch {
|
switch {
|
||||||
case req.Method == "POST" && req.URL.Path == hap.PathPairings:
|
case req.Method == "POST" && req.URL.Path == hap.PathPairings:
|
||||||
var res *http.Response
|
var res *http.Response
|
||||||
if res, err = handlePairings(p.con, req, pair); err != nil {
|
if res, err = handlePairings(req, srv); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err = res.Write(p.con); err != nil {
|
if err = res.Write(p.con); err != nil {
|
||||||
@@ -74,7 +73,7 @@ func (p *Proxy) handleCon(pair ServerPair) error {
|
|||||||
_ = json.Unmarshal(body, &v)
|
_ = json.Unmarshal(body, &v)
|
||||||
for _, char := range v.Value {
|
for _, char := range v.Value {
|
||||||
if char.IID == hdsCharIID {
|
if char.IID == hdsCharIID {
|
||||||
var hdsReq camera.SetupDataStreamRequest
|
var hdsReq camera.SetupDataStreamTransportRequest
|
||||||
_ = tlv8.UnmarshalBase64(char.Value, &hdsReq)
|
_ = tlv8.UnmarshalBase64(char.Value, &hdsReq)
|
||||||
hdsConSalt = hdsReq.ControllerKeySalt
|
hdsConSalt = hdsReq.ControllerKeySalt
|
||||||
break
|
break
|
||||||
@@ -110,14 +109,14 @@ func (p *Proxy) handleCon(pair ServerPair) error {
|
|||||||
_ = json.Unmarshal(body, &v)
|
_ = json.Unmarshal(body, &v)
|
||||||
for i, char := range v.Value {
|
for i, char := range v.Value {
|
||||||
if char.IID == hdsCharIID {
|
if char.IID == hdsCharIID {
|
||||||
var hdsRes camera.SetupDataStreamResponse
|
var hdsRes camera.SetupDataStreamTransportResponse
|
||||||
_ = tlv8.UnmarshalBase64(char.Value, &hdsRes)
|
_ = tlv8.UnmarshalBase64(char.Value, &hdsRes)
|
||||||
|
|
||||||
hdsAccSalt := hdsRes.AccessoryKeySalt
|
hdsAccSalt := hdsRes.AccessoryKeySalt
|
||||||
hdsPort := int(hdsRes.TransportTypeSessionParameters.TCPListeningPort)
|
hdsPort := int(hdsRes.TransportTypeSessionParameters.TCPListeningPort)
|
||||||
|
|
||||||
// swtich accPort to conPort
|
// swtich accPort to conPort
|
||||||
hdsPort, err = p.listenHDS(hdsPort, hdsConSalt+hdsAccSalt)
|
hdsPort, err = p.listenHDS(srv, hdsPort, hdsConSalt+hdsAccSalt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -149,7 +148,7 @@ func (p *Proxy) handleAcc() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if res.Proto == hap.ProtoEvent {
|
if res.Proto == hap.ProtoEvent {
|
||||||
if err = res.Write(p.con); err != nil {
|
if err = hap.WriteEvent(p.con, res); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -166,7 +165,8 @@ func (p *Proxy) handleAcc() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Proxy) listenHDS(accPort int, salt string) (int, error) {
|
func (p *Proxy) listenHDS(srv ServerProxy, accPort int, salt string) (int, error) {
|
||||||
|
// The TCP port range for HDS must be >= 32768.
|
||||||
ln, err := net.ListenTCP("tcp", nil)
|
ln, err := net.ListenTCP("tcp", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -175,30 +175,36 @@ func (p *Proxy) listenHDS(accPort int, salt string) (int, error) {
|
|||||||
go func() {
|
go func() {
|
||||||
defer ln.Close()
|
defer ln.Close()
|
||||||
|
|
||||||
|
_ = ln.SetDeadline(time.Now().Add(30 * time.Second))
|
||||||
|
|
||||||
// raw controller conn
|
// raw controller conn
|
||||||
con, err := ln.Accept()
|
conn1, err := ln.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer con.Close()
|
|
||||||
|
defer conn1.Close()
|
||||||
|
|
||||||
// secured controller conn (controlle=false because we are accessory)
|
// secured controller conn (controlle=false because we are accessory)
|
||||||
con, err = hds.Client(con, p.con.SharedKey, salt, false)
|
con, err := hds.NewConn(conn1, p.con.SharedKey, salt, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
srv.AddConn(con)
|
||||||
|
defer srv.DelConn(con)
|
||||||
|
|
||||||
accIP := p.acc.RemoteAddr().(*net.TCPAddr).IP
|
accIP := p.acc.RemoteAddr().(*net.TCPAddr).IP
|
||||||
|
|
||||||
// raw accessory conn
|
// raw accessory conn
|
||||||
acc, err := net.Dial("tcp", fmt.Sprintf("%s:%d", accIP, accPort))
|
conn2, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: accIP, Port: accPort})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer acc.Close()
|
defer conn2.Close()
|
||||||
|
|
||||||
// secured accessory conn (controller=true because we are controller)
|
// secured accessory conn (controller=true because we are controller)
|
||||||
acc, err = hds.Client(acc, p.acc.SharedKey, salt, true)
|
acc, err := hds.NewConn(conn2, p.acc.SharedKey, salt, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-47
@@ -15,15 +15,17 @@ import (
|
|||||||
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type HandlerFunc func(net.Conn) error
|
||||||
|
|
||||||
type Server interface {
|
type Server interface {
|
||||||
ServerPair
|
ServerPair
|
||||||
ServerAccessory
|
ServerAccessory
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerPair interface {
|
type ServerPair interface {
|
||||||
GetPair(conn net.Conn, id string) []byte
|
GetPair(id string) []byte
|
||||||
AddPair(conn net.Conn, id string, public []byte, permissions byte)
|
AddPair(id string, public []byte, permissions byte)
|
||||||
DelPair(conn net.Conn, id string)
|
DelPair(id string)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerAccessory interface {
|
type ServerAccessory interface {
|
||||||
@@ -33,11 +35,11 @@ type ServerAccessory interface {
|
|||||||
GetImage(conn net.Conn, width, height int) []byte
|
GetImage(conn net.Conn, width, height int) []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func ServerHandler(server Server) hap.HandlerFunc {
|
func ServerHandler(server Server) HandlerFunc {
|
||||||
return handleRequest(func(conn net.Conn, req *http.Request) (*http.Response, error) {
|
return handleRequest(func(conn net.Conn, req *http.Request) (*http.Response, error) {
|
||||||
switch req.URL.Path {
|
switch req.URL.Path {
|
||||||
case hap.PathPairings:
|
case hap.PathPairings:
|
||||||
return handlePairings(conn, req, server)
|
return handlePairings(req, server)
|
||||||
|
|
||||||
case hap.PathAccessories:
|
case hap.PathAccessories:
|
||||||
body := hap.JSONAccessories{Value: server.GetAccessories(conn)}
|
body := hap.JSONAccessories{Value: server.GetAccessories(conn)}
|
||||||
@@ -103,7 +105,7 @@ func ServerHandler(server Server) hap.HandlerFunc {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleRequest(handle func(conn net.Conn, req *http.Request) (*http.Response, error)) hap.HandlerFunc {
|
func handleRequest(handle func(conn net.Conn, req *http.Request) (*http.Response, error)) HandlerFunc {
|
||||||
return func(conn net.Conn) error {
|
return func(conn net.Conn) error {
|
||||||
rw := bufio.NewReaderSize(conn, 16*1024)
|
rw := bufio.NewReaderSize(conn, 16*1024)
|
||||||
wr := bufio.NewWriterSize(conn, 16*1024)
|
wr := bufio.NewWriterSize(conn, 16*1024)
|
||||||
@@ -130,7 +132,7 @@ func handleRequest(handle func(conn net.Conn, req *http.Request) (*http.Response
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handlePairings(conn net.Conn, req *http.Request, pair ServerPair) (*http.Response, error) {
|
func handlePairings(req *http.Request, srv ServerPair) (*http.Response, error) {
|
||||||
cmd := struct {
|
cmd := struct {
|
||||||
Method byte `tlv8:"0"`
|
Method byte `tlv8:"0"`
|
||||||
Identifier string `tlv8:"1"`
|
Identifier string `tlv8:"1"`
|
||||||
@@ -139,15 +141,15 @@ func handlePairings(conn net.Conn, req *http.Request, pair ServerPair) (*http.Re
|
|||||||
Permissions byte `tlv8:"11"`
|
Permissions byte `tlv8:"11"`
|
||||||
}{}
|
}{}
|
||||||
|
|
||||||
if err := tlv8.UnmarshalReader(req.Body, &cmd); err != nil {
|
if err := tlv8.UnmarshalReader(req.Body, req.ContentLength, &cmd); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
switch cmd.Method {
|
switch cmd.Method {
|
||||||
case 3: // add
|
case 3: // add
|
||||||
pair.AddPair(conn, cmd.Identifier, []byte(cmd.PublicKey), cmd.Permissions)
|
srv.AddPair(cmd.Identifier, []byte(cmd.PublicKey), cmd.Permissions)
|
||||||
case 4: // delete
|
case 4: // delete
|
||||||
pair.DelPair(conn, cmd.Identifier)
|
srv.DelPair(cmd.Identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
body := struct {
|
body := struct {
|
||||||
@@ -190,40 +192,3 @@ func makeResponse(mime string, v any) (*http.Response, error) {
|
|||||||
}
|
}
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//func debug(v any) {
|
|
||||||
// switch v := v.(type) {
|
|
||||||
// case *http.Request:
|
|
||||||
// if v == nil {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// if v.ContentLength != 0 {
|
|
||||||
// b, err := io.ReadAll(v.Body)
|
|
||||||
// if err != nil {
|
|
||||||
// panic(err)
|
|
||||||
// }
|
|
||||||
// v.Body = io.NopCloser(bytes.NewReader(b))
|
|
||||||
// log.Printf("[homekit] request: %s %s\n%s", v.Method, v.RequestURI, b)
|
|
||||||
// } else {
|
|
||||||
// log.Printf("[homekit] request: %s %s <nobody>", v.Method, v.RequestURI)
|
|
||||||
// }
|
|
||||||
// case *http.Response:
|
|
||||||
// if v == nil {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// if v.Header.Get("Content-Type") == "image/jpeg" {
|
|
||||||
// log.Printf("[homekit] response: %d <jpeg>", v.StatusCode)
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// if v.ContentLength != 0 {
|
|
||||||
// b, err := io.ReadAll(v.Body)
|
|
||||||
// if err != nil {
|
|
||||||
// panic(err)
|
|
||||||
// }
|
|
||||||
// v.Body = io.NopCloser(bytes.NewReader(b))
|
|
||||||
// log.Printf("[homekit] response: %d\n%s", v.StatusCode, b)
|
|
||||||
// } else {
|
|
||||||
// log.Printf("[homekit] response: %d <nobody>", v.StatusCode)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|||||||
+14
-61
@@ -3,10 +3,9 @@ package onvif
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"html"
|
"html"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -32,26 +31,18 @@ func NewClient(rawURL string) (*Client, error) {
|
|||||||
baseURL := "http://" + u.Host
|
baseURL := "http://" + u.Host
|
||||||
|
|
||||||
client := &Client{url: u}
|
client := &Client{url: u}
|
||||||
if u.Path == "" {
|
client.deviceURL = baseURL + GetPath(u.Path, PathDevice)
|
||||||
client.deviceURL = baseURL + PathDevice
|
|
||||||
} else {
|
|
||||||
client.deviceURL = baseURL + u.Path
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := client.DeviceRequest(DeviceGetCapabilities)
|
b, err := client.DeviceRequest(DeviceGetCapabilities)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
client.mediaURL = FindTagValue(b, "Media.+?XAddr")
|
s := FindTagValue(b, "Media.+?XAddr")
|
||||||
if client.mediaURL == "" {
|
client.mediaURL = baseURL + GetPath(s, "/onvif/media_service")
|
||||||
client.mediaURL = baseURL + "/onvif/media_service"
|
|
||||||
}
|
|
||||||
|
|
||||||
client.imaginURL = FindTagValue(b, "Imaging.+?XAddr")
|
s = FindTagValue(b, "Imaging.+?XAddr")
|
||||||
if client.imaginURL == "" {
|
client.imaginURL = baseURL + GetPath(s, "/onvif/imaging_service")
|
||||||
client.imaginURL = baseURL + "/onvif/imaging_service"
|
|
||||||
}
|
|
||||||
|
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
@@ -183,62 +174,24 @@ func (c *Client) MediaRequest(operation string) ([]byte, error) {
|
|||||||
return c.Request(c.mediaURL, operation)
|
return c.Request(c.mediaURL, operation)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Request(rawUrl, body string) ([]byte, error) {
|
func (c *Client) Request(url, body string) ([]byte, error) {
|
||||||
if rawUrl == "" {
|
if url == "" {
|
||||||
return nil, errors.New("onvif: unsupported service")
|
return nil, errors.New("onvif: unsupported service")
|
||||||
}
|
}
|
||||||
|
|
||||||
e := NewEnvelopeWithUser(c.url.User)
|
e := NewEnvelopeWithUser(c.url.User)
|
||||||
e.Append(body)
|
e.Append(body)
|
||||||
|
|
||||||
u, err := url.Parse(rawUrl)
|
client := &http.Client{Timeout: time.Second * 5000}
|
||||||
|
res, err := client.Post(url, `application/soap+xml;charset=utf-8`, bytes.NewReader(e.Bytes()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
host := u.Host
|
if res.StatusCode != http.StatusOK {
|
||||||
if u.Port() == "" {
|
return nil, errors.New("onvif: wrong response " + res.Status)
|
||||||
host += ":80"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := net.DialTimeout("tcp", host, 5*time.Second)
|
return io.ReadAll(res.Body)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
reqBody := e.Bytes()
|
|
||||||
rawReq := fmt.Appendf(nil, "POST %s HTTP/1.1\r\n"+
|
|
||||||
"Host: %s\r\n"+
|
|
||||||
"Content-Type: application/soap+xml;charset=utf-8\r\n"+
|
|
||||||
"Content-Length: %d\r\n"+
|
|
||||||
"Connection: close\r\n"+
|
|
||||||
"\r\n", u.Path, u.Host, len(reqBody))
|
|
||||||
rawReq = append(rawReq, reqBody...)
|
|
||||||
|
|
||||||
if _, err = conn.Write(rawReq); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rawRes, err := io.ReadAll(conn)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look for XML in complete response
|
|
||||||
if i := bytes.Index(rawRes, []byte("<?xml")); i > 0 {
|
|
||||||
return rawRes[i:], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// No XML found - might be an error response
|
|
||||||
if i := bytes.Index(rawRes, []byte("\r\n\r\n")); i > 0 {
|
|
||||||
if bytes.Contains(rawRes[:i], []byte("chunked")) {
|
|
||||||
return nil, errors.New("onvif: TODO: support chunked encoding")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return body after headers
|
|
||||||
return rawRes[i+4:], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return rawRes, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package onvif
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -129,3 +130,14 @@ func GetPosixTZ(current time.Time) string {
|
|||||||
|
|
||||||
return prefix + fmt.Sprintf("%02d:%02d", offset/60, offset%60)
|
return prefix + fmt.Sprintf("%02d:%02d", offset/60, offset%60)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetPath(urlOrPath, defPath string) string {
|
||||||
|
if urlOrPath == "" || urlOrPath[0] == '/' {
|
||||||
|
return defPath
|
||||||
|
}
|
||||||
|
u, err := url.Parse(urlOrPath)
|
||||||
|
if err != nil {
|
||||||
|
return defPath
|
||||||
|
}
|
||||||
|
return GetPath(u.Path, defPath)
|
||||||
|
}
|
||||||
|
|||||||
+11
-4
@@ -140,6 +140,12 @@ func (c *Client) newDectypter(res *http.Response, brand, username, password stri
|
|||||||
username = "admin"
|
username = "admin"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.Contains(exchange, `username="none"`) {
|
||||||
|
// https://nvd.nist.gov/vuln/detail/CVE-2022-37255
|
||||||
|
username = "none"
|
||||||
|
password = "TPL075526460603"
|
||||||
|
}
|
||||||
|
|
||||||
key := md5.Sum([]byte(nonce + ":" + password))
|
key := md5.Sum([]byte(nonce + ":" + password))
|
||||||
iv := md5.Sum([]byte(username + ":" + nonce))
|
iv := md5.Sum([]byte(username + ":" + nonce))
|
||||||
|
|
||||||
@@ -158,8 +164,9 @@ func (c *Client) newDectypter(res *http.Response, brand, username, password stri
|
|||||||
cbc.CryptBlocks(b, b)
|
cbc.CryptBlocks(b, b)
|
||||||
|
|
||||||
// unpad
|
// unpad
|
||||||
padSize := int(b[len(b)-1])
|
n := len(b)
|
||||||
return b[:len(b)-padSize]
|
padSize := int(b[n-1])
|
||||||
|
return b[:n-padSize]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,12 +299,12 @@ func dial(req *http.Request, brand, username, password string) (net.Conn, *http.
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
_, _ = io.Copy(io.Discard, res.Body) // discard leftovers
|
_, _ = io.Copy(io.Discard, res.Body) // discard leftovers
|
||||||
_ = res.Body.Close() // ignore response body
|
_ = res.Body.Close() // ignore response body
|
||||||
|
|
||||||
auth := res.Header.Get("WWW-Authenticate")
|
auth := res.Header.Get("WWW-Authenticate")
|
||||||
|
|
||||||
if res.StatusCode != http.StatusUnauthorized || !strings.HasPrefix(auth, "Digest") {
|
if res.StatusCode != http.StatusUnauthorized || !strings.HasPrefix(auth, "Digest") {
|
||||||
return nil, nil, fmt.Errorf("Expected StatusCode to be %d, received %d", http.StatusUnauthorized, res.StatusCode)
|
return nil, nil, errors.New("tapo: wrond status: " + res.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if brand == "tapo" && password == "" {
|
if brand == "tapo" && password == "" {
|
||||||
|
|||||||
+319
-323
@@ -1,41 +1,37 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>go2rtc - Add Stream</title>
|
<title>go2rtc - Add Stream</title>
|
||||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
|
||||||
<style>
|
<style>
|
||||||
body {
|
main > button {
|
||||||
margin: 0;
|
background-color: #444;
|
||||||
padding: 0;
|
color: white;
|
||||||
display: flex;
|
cursor: pointer;
|
||||||
flex-direction: column;
|
padding: 14px;
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module {
|
main > div {
|
||||||
display: none;
|
display: none;
|
||||||
padding: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
table tbody td {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<script src="main.js"></script>
|
<script src="main.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function drawTable(table, data) {
|
function drawTable(table, data) {
|
||||||
const cols = ['id', 'name', 'info', 'url', 'location'];
|
const cols = ['id', 'name', 'info', 'url', 'location'];
|
||||||
const th = (row) => cols.reduce((html, k) => k in row ? `${html}<th>${k}</th>` : html, '<tr>') + '</tr>';
|
const th = (row) => cols.reduce((html, k) => k in row ? `${html}<th>${k}</th>` : html, '<tr>') + '</tr>';
|
||||||
const td = (row) => cols.reduce((html, k) => k in row ? `${html}<td style="word-break: break-word;white-space: normal;">${row[k]}</td>` : html, '<tr>') + '</tr>';
|
const td = (row) => cols.reduce((html, k) => k in row ? `${html}<td style="word-break: break-word; white-space: normal;">${row[k]}</td>` : html, '<tr>') + '</tr>';
|
||||||
|
|
||||||
const thead = th(data.sources[0]);
|
const thead = th(data.sources[0]);
|
||||||
const tbody = data.sources.reduce((html, source) => `${html}${td(source)}`, '');
|
const tbody = data.sources.reduce((html, source) => `${html}${td(source)}`, '');
|
||||||
@@ -57,330 +53,330 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<main>
|
||||||
<button id="stream">Temporary stream</button>
|
<button id="stream">Temporary stream</button>
|
||||||
<div class="module">
|
<div>
|
||||||
<form id="stream-form" style="padding: 10px">
|
<form id="stream-form">
|
||||||
<input type="text" name="name" placeholder="name">
|
<input type="text" name="name" placeholder="name">
|
||||||
<input type="text" name="src" placeholder="url">
|
<input type="text" name="src" placeholder="url" required size="30">
|
||||||
<input type="submit" value="add">
|
<button type="submit">add</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('stream').addEventListener('click', async ev => {
|
document.getElementById('stream').addEventListener('click', async ev => {
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('stream-form').addEventListener('submit', async ev => {
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
const url = new URL('api/streams', location.href);
|
|
||||||
url.searchParams.set('name', ev.target.elements['name'].value);
|
|
||||||
url.searchParams.set('src', ev.target.elements['src'].value);
|
|
||||||
|
|
||||||
const r = await fetch(url, {method: 'PUT'});
|
|
||||||
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<button id="alsa">ALSA (Linux audio)</button>
|
|
||||||
<div class="module">
|
|
||||||
<table id="alsa-table"></table>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
document.getElementById('alsa').addEventListener('click', async ev => {
|
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
|
||||||
await getSources('alsa-table', 'api/alsa');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<button id="homekit">Apple HomeKit</button>
|
|
||||||
<div class="module">
|
|
||||||
<form id="homekit-pair" style="margin-bottom: 10px">
|
|
||||||
<input type="text" name="id" placeholder="stream id" size="20">
|
|
||||||
<input type="text" name="url" placeholder="url" size="40">
|
|
||||||
<input type="text" name="pin" placeholder="pin" size="10">
|
|
||||||
<input type="submit" value="Pair">
|
|
||||||
</form>
|
|
||||||
<form id="homekit-unpair" style="margin-bottom: 10px">
|
|
||||||
<input type="text" name="id" placeholder="stream id" size="20">
|
|
||||||
<input type="submit" value="Unpair">
|
|
||||||
</form>
|
|
||||||
<table id="homekit-table"></table>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
async function reloadHomeKit() {
|
|
||||||
await getSources('homekit-table', 'api/homekit');
|
|
||||||
|
|
||||||
const rows = document.querySelectorAll('#homekit-table tr');
|
|
||||||
rows.forEach((row, i) => {
|
|
||||||
let commands = '';
|
|
||||||
if (row.children[2].innerText.indexOf('status=1') > 0) {
|
|
||||||
commands += '<a href="#">pair</a>';
|
|
||||||
} else if (i > 0 && row.children[3].innerText) {
|
|
||||||
commands += '<a href="#">unpair</a>';
|
|
||||||
}
|
|
||||||
row.innerHTML += `<td>${commands}</td>`;
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('homekit').addEventListener('click', async ev => {
|
document.getElementById('stream-form').addEventListener('submit', async ev => {
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
ev.preventDefault();
|
||||||
await reloadHomeKit();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('homekit-table').addEventListener('click', ev => {
|
const url = new URL('api/streams', location.href);
|
||||||
if (ev.target.innerText === 'pair') {
|
url.searchParams.set('name', ev.target.elements['name'].value);
|
||||||
const form = document.querySelector('#homekit-pair');
|
url.searchParams.set('src', ev.target.elements['src'].value);
|
||||||
const row = ev.target.closest('tr');
|
|
||||||
form.children[0].value = row.children[0].innerText;
|
|
||||||
form.children[1].value = row.children[2].innerText;
|
|
||||||
} else if (ev.target.innerText === 'unpair') {
|
|
||||||
const form = document.querySelector('#homekit-unpair');
|
|
||||||
const row = ev.target.closest('tr');
|
|
||||||
form.children[0].value = row.children[3].innerText;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('homekit-pair').addEventListener('submit', async ev => {
|
const r = await fetch(url, {method: 'PUT'});
|
||||||
ev.preventDefault();
|
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
|
||||||
|
});
|
||||||
const body = new FormData(ev.target);
|
</script>
|
||||||
body.set('url', body.get('url') + '&pin=' + body.get('pin'));
|
|
||||||
body.delete('pin');
|
|
||||||
|
|
||||||
const r = await fetch('api/homekit', {method: 'POST', body: body});
|
|
||||||
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
|
|
||||||
|
|
||||||
await reloadHomeKit();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('homekit-unpair').addEventListener('submit', async ev => {
|
|
||||||
ev.preventDefault();
|
|
||||||
const r = await fetch('api/homekit', {method: 'DELETE', body: new FormData(ev.target)});
|
|
||||||
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
|
|
||||||
|
|
||||||
await reloadHomeKit();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<button id="dvrip">DVRIP</button>
|
<button id="alsa">ALSA (Linux audio)</button>
|
||||||
<div class="module">
|
<div>
|
||||||
<table id="dvrip-table"></table>
|
<table id="alsa-table"></table>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('dvrip').addEventListener('click', async ev => {
|
document.getElementById('alsa').addEventListener('click', async ev => {
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
await getSources('dvrip-table', 'api/dvrip');
|
await getSources('alsa-table', 'api/alsa');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<button id="devices">FFmpeg Devices (USB)</button>
|
<button id="homekit">Apple HomeKit</button>
|
||||||
<div class="module">
|
<div>
|
||||||
<table id="devices-table"></table>
|
<form id="homekit-pair">
|
||||||
</div>
|
<input type="text" name="id" placeholder="stream id" required>
|
||||||
<script>
|
<input type="text" name="src" placeholder="src" required size="30">
|
||||||
document.getElementById('devices').addEventListener('click', async ev => {
|
<input type="text" name="pin" placeholder="pin" required size="10">
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
<button type="submit">pair</button>
|
||||||
await getSources('devices-table', 'api/ffmpeg/devices');
|
</form>
|
||||||
});
|
<form id="homekit-unpair">
|
||||||
</script>
|
<input type="text" name="id" placeholder="stream id" required>
|
||||||
|
<button type="submit">unpair</button>
|
||||||
|
</form>
|
||||||
|
<table id="homekit-table"></table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
async function reloadHomeKit() {
|
||||||
|
await getSources('homekit-table', 'api/discovery/homekit');
|
||||||
|
|
||||||
|
const rows = document.querySelectorAll('#homekit-table tr');
|
||||||
<button id="hardware">FFmpeg Hardware</button>
|
rows.forEach((row, i) => {
|
||||||
<div class="module">
|
let commands = '';
|
||||||
<table id="hardware-table"></table>
|
if (row.children[2].innerText.indexOf('status=1') > 0) {
|
||||||
</div>
|
commands += '<a href="#">pair</a>';
|
||||||
<script>
|
} else if (i > 0 && row.children[3].innerText) {
|
||||||
document.getElementById('hardware').addEventListener('click', async ev => {
|
commands += '<a href="#">unpair</a>';
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
}
|
||||||
await getSources('hardware-table', 'api/ffmpeg/hardware');
|
row.innerHTML += i > 0 ? `<td>${commands}</td>` : '<th>commands</th>';
|
||||||
});
|
});
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<button id="nest">Google Nest</button>
|
|
||||||
<div class="module">
|
|
||||||
<form id="nest-form" style="margin-bottom: 10px">
|
|
||||||
<input type="text" name="client_id" placeholder="client_id">
|
|
||||||
<input type="text" name="client_secret" placeholder="client_secret">
|
|
||||||
<input type="text" name="refresh_token" placeholder="refresh_token">
|
|
||||||
<input type="text" name="project_id" placeholder="project_id">
|
|
||||||
<input type="submit" value="Login">
|
|
||||||
</form>
|
|
||||||
<table id="nest-table"></table>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
document.getElementById('nest').addEventListener('click', async ev => {
|
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('nest-form').addEventListener('submit', async ev => {
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
const query = new URLSearchParams(new FormData(ev.target));
|
|
||||||
const url = new URL('api/nest?' + query.toString(), location.href);
|
|
||||||
|
|
||||||
const r = await fetch(url, {cache: 'no-cache'});
|
|
||||||
await getSources('nest-table', r);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button id="ring">Ring</button>
|
|
||||||
<div class="module">
|
|
||||||
<form id="ring-credentials-form" style="margin-bottom: 10px">
|
|
||||||
<input type="email" name="email" placeholder="email">
|
|
||||||
<input type="password" name="password" placeholder="password">
|
|
||||||
<div id="tfa-field" style="display: none">
|
|
||||||
<input type="text" name="code" placeholder="2FA code">
|
|
||||||
<div id="tfa-prompt"></div>
|
|
||||||
</div>
|
|
||||||
<input type="submit" value="Login">
|
|
||||||
</form>
|
|
||||||
<form id="ring-token-form" style="margin-bottom: 10px">
|
|
||||||
<input type="text" name="refresh_token" placeholder="refresh_token">
|
|
||||||
<input type="submit" value="Login">
|
|
||||||
</form>
|
|
||||||
<table id="ring-table"></table>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
document.getElementById('ring').addEventListener('click', async ev => {
|
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleRingAuth(ev) {
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
const table = document.getElementById('ring-table');
|
|
||||||
table.innerText = 'loading...';
|
|
||||||
|
|
||||||
const query = new URLSearchParams(new FormData(ev.target));
|
|
||||||
const url = new URL('api/ring?' + query.toString(), location.href);
|
|
||||||
|
|
||||||
const r = await fetch(url, {cache: 'no-cache'});
|
|
||||||
|
|
||||||
if (!r.ok) {
|
|
||||||
table.innerText = (await r.text()) || 'Unknown error';
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await r.json();
|
document.getElementById('homekit').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
|
await reloadHomeKit();
|
||||||
|
});
|
||||||
|
|
||||||
table.innerText = '';
|
document.getElementById('homekit-table').addEventListener('click', ev => {
|
||||||
|
if (ev.target.innerText === 'pair') {
|
||||||
|
const form = document.querySelector('#homekit-pair');
|
||||||
|
const row = ev.target.closest('tr');
|
||||||
|
form.children[0].value = row.children[0].innerText;
|
||||||
|
form.children[1].value = row.children[2].innerText;
|
||||||
|
} else if (ev.target.innerText === 'unpair') {
|
||||||
|
const form = document.querySelector('#homekit-unpair');
|
||||||
|
const row = ev.target.closest('tr');
|
||||||
|
form.children[0].value = row.children[3].innerText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (data.needs_2fa) {
|
document.getElementById('homekit-pair').addEventListener('submit', async ev => {
|
||||||
document.getElementById('tfa-field').style.display = 'block';
|
ev.preventDefault();
|
||||||
document.getElementById('tfa-prompt').textContent = data.prompt || 'Enter 2FA code';
|
|
||||||
return;
|
const params = new URLSearchParams(new FormData(ev.target));
|
||||||
|
const r = await fetch('api/homekit', {method: 'POST', body: params});
|
||||||
|
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
|
||||||
|
|
||||||
|
await reloadHomeKit();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('homekit-unpair').addEventListener('submit', async ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
const params = new URLSearchParams(new FormData(ev.target));
|
||||||
|
const r = await fetch('api/homekit?' + params.toString(), {method: 'DELETE'});
|
||||||
|
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
|
||||||
|
|
||||||
|
await reloadHomeKit();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<button id="dvrip">DVRIP</button>
|
||||||
|
<div>
|
||||||
|
<table id="dvrip-table"></table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('dvrip').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
|
await getSources('dvrip-table', 'api/dvrip');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<button id="devices">FFmpeg Devices (USB)</button>
|
||||||
|
<div>
|
||||||
|
<table id="devices-table"></table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('devices').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
|
await getSources('devices-table', 'api/ffmpeg/devices');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<button id="hardware">FFmpeg Hardware</button>
|
||||||
|
<div>
|
||||||
|
<table id="hardware-table"></table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('hardware').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
|
await getSources('hardware-table', 'api/ffmpeg/hardware');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<button id="nest">Google Nest</button>
|
||||||
|
<div>
|
||||||
|
<form id="nest-form">
|
||||||
|
<input type="text" name="client_id" placeholder="client_id" required>
|
||||||
|
<input type="text" name="client_secret" placeholder="client_secret" required>
|
||||||
|
<input type="text" name="refresh_token" placeholder="refresh_token" required>
|
||||||
|
<input type="text" name="project_id" placeholder="project_id" required>
|
||||||
|
<button type="submit">login</button>
|
||||||
|
</form>
|
||||||
|
<table id="nest-table"></table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('nest').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('nest-form').addEventListener('submit', async ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
const query = new URLSearchParams(new FormData(ev.target));
|
||||||
|
const url = new URL('api/nest?' + query.toString(), location.href);
|
||||||
|
|
||||||
|
const r = await fetch(url, {cache: 'no-cache'});
|
||||||
|
await getSources('nest-table', r);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button id="gopro">GoPro</button>
|
||||||
|
<div>
|
||||||
|
<table id="gopro-table"></table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('gopro').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
|
await getSources('gopro-table', 'api/gopro');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<button id="hass">Home Assistant</button>
|
||||||
|
<div>
|
||||||
|
<table id="hass-table"></table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('hass').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
|
await getSources('hass-table', 'api/hass');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<button id="onvif">ONVIF</button>
|
||||||
|
<div>
|
||||||
|
<form id="onvif-form">
|
||||||
|
<input type="text" name="src" placeholder="onvif://user:pass@192.168.1.123:80" required size="30">
|
||||||
|
<button type="submit">test</button>
|
||||||
|
</form>
|
||||||
|
<table id="onvif-table"></table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('onvif').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
|
await getSources('onvif-table', 'api/onvif');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('onvif-form').addEventListener('submit', async ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
const url = new URL('api/onvif', location.href);
|
||||||
|
url.searchParams.set('src', ev.target.elements['src'].value);
|
||||||
|
|
||||||
|
await getSources('onvif-table', url.toString());
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<button id="ring">Ring</button>
|
||||||
|
<div>
|
||||||
|
<form id="ring-credentials-form">
|
||||||
|
<input type="email" name="email" placeholder="email" required>
|
||||||
|
<input type="password" name="password" placeholder="password" required>
|
||||||
|
<div id="tfa-field" style="display: none">
|
||||||
|
<input type="text" name="code" placeholder="2FA code">
|
||||||
|
<div id="tfa-prompt"></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit">login</button>
|
||||||
|
</form>
|
||||||
|
<form id="ring-token-form">
|
||||||
|
<input type="text" name="refresh_token" placeholder="refresh_token" required>
|
||||||
|
<button type="submit">login</button>
|
||||||
|
</form>
|
||||||
|
<table id="ring-table"></table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('ring').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleRingAuth(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
const table = document.getElementById('ring-table');
|
||||||
|
table.innerText = 'loading...';
|
||||||
|
|
||||||
|
const query = new URLSearchParams(new FormData(ev.target));
|
||||||
|
const url = new URL('api/ring?' + query.toString(), location.href);
|
||||||
|
|
||||||
|
const r = await fetch(url, {cache: 'no-cache'});
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
table.innerText = (await r.text()) || 'Unknown error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await r.json();
|
||||||
|
|
||||||
|
table.innerText = '';
|
||||||
|
|
||||||
|
if (data.needs_2fa) {
|
||||||
|
document.getElementById('tfa-field').style.display = 'block';
|
||||||
|
document.getElementById('tfa-prompt').textContent = data.prompt || 'Enter 2FA code';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawTable(table, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
drawTable(table, data);
|
document.getElementById('ring-credentials-form').addEventListener('submit', handleRingAuth);
|
||||||
}
|
document.getElementById('ring-token-form').addEventListener('submit', handleRingAuth);
|
||||||
|
</script>
|
||||||
document.getElementById('ring-credentials-form').addEventListener('submit', handleRingAuth);
|
|
||||||
document.getElementById('ring-token-form').addEventListener('submit', handleRingAuth);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button id="gopro">GoPro</button>
|
|
||||||
<div class="module">
|
|
||||||
<table id="gopro-table"></table>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
document.getElementById('gopro').addEventListener('click', async ev => {
|
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
|
||||||
await getSources('gopro-table', 'api/gopro');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<button id="hass">Home Assistant</button>
|
<button id="roborock">Roborock</button>
|
||||||
<div class="module">
|
<div>
|
||||||
<table id="hass-table"></table>
|
<form id="roborock-form">
|
||||||
</div>
|
<input type="text" name="username" placeholder="username" required>
|
||||||
<script>
|
<input type="password" name="password" placeholder="password" required>
|
||||||
document.getElementById('hass').addEventListener('click', async ev => {
|
<button type="submit">login</button>
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
</form>
|
||||||
await getSources('hass-table', 'api/hass');
|
<table id="roborock-table">
|
||||||
});
|
</table>
|
||||||
</script>
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('roborock').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
|
await getSources('roborock-table', 'api/roborock');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('roborock-form').addEventListener('submit', async ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const r = await fetch('api/roborock', {method: 'POST', body: new FormData(ev.target)});
|
||||||
|
await getSources('roborock-table', r);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<button id="onvif">ONVIF</button>
|
<button id="v4l2">V4L2 (Linux video)</button>
|
||||||
<div class="module">
|
<div>
|
||||||
<form id="onvif-form" style="padding: 10px">
|
<table id="v4l2-table"></table>
|
||||||
<input type="text" name="src" placeholder="onvif://user:pass@192.168.1.123:80" size="50">
|
</div>
|
||||||
<input type="submit" value="test">
|
<script>
|
||||||
</form>
|
document.getElementById('v4l2').addEventListener('click', async ev => {
|
||||||
<table id="onvif-table"></table>
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
</div>
|
await getSources('v4l2-table', 'api/v4l2');
|
||||||
<script>
|
});
|
||||||
document.getElementById('onvif').addEventListener('click', async ev => {
|
</script>
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
|
||||||
await getSources('onvif-table', 'api/onvif');
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('onvif-form').addEventListener('submit', async ev => {
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
const url = new URL('api/onvif', location.href);
|
|
||||||
url.searchParams.set('src', ev.target.elements['src'].value);
|
|
||||||
|
|
||||||
await getSources('onvif-table', url.toString());
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<button id="roborock">Roborock</button>
|
<button id="webtorrent">WebTorrent Shares</button>
|
||||||
<div class="module">
|
<div>
|
||||||
<form id="roborock-form" style="margin-bottom: 10px">
|
<table id="webtorrent-table"></table>
|
||||||
<input type="text" name="username" placeholder="username">
|
</div>
|
||||||
<input type="password" name="password" placeholder="password">
|
<script>
|
||||||
<input type="submit" value="Login">
|
document.getElementById('webtorrent').addEventListener('click', async ev => {
|
||||||
</form>
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
<table id="roborock-table">
|
await getSources('webtorrent-table', 'api/webtorrent');
|
||||||
</table>
|
});
|
||||||
</div>
|
</script>
|
||||||
<script>
|
</main>
|
||||||
document.getElementById('roborock').addEventListener('click', async ev => {
|
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
|
||||||
await getSources('roborock-table', 'api/roborock');
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('roborock-form').addEventListener('submit', async ev => {
|
|
||||||
ev.preventDefault();
|
|
||||||
const r = await fetch('api/roborock', {method: 'POST', body: new FormData(ev.target)});
|
|
||||||
await getSources('roborock-table', r);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<button id="v4l2">V4L2 (Linux video)</button>
|
|
||||||
<div class="module">
|
|
||||||
<table id="v4l2-table"></table>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
document.getElementById('v4l2').addEventListener('click', async ev => {
|
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
|
||||||
await getSources('v4l2-table', 'api/v4l2');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<button id="webtorrent">WebTorrent Shares</button>
|
|
||||||
<div class="module">
|
|
||||||
<table id="webtorrent-table"></table>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
document.getElementById('webtorrent').addEventListener('click', async ev => {
|
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
|
||||||
await getSources('webtorrent-table', 'api/webtorrent');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,41 +1,36 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>go2rtc - File Editor</title>
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
<title>go2rtc - Config</title>
|
||||||
<script src="https://unpkg.com/ace-builds@1.33.1/src-min/ace.js"></script>
|
<script src="https://unpkg.com/ace-builds@1.33.1/src-min/ace.js"></script>
|
||||||
<style>
|
<style>
|
||||||
body {
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
background-color: white;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body, #config {
|
html, body, #config {
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="main.js"></script>
|
|
||||||
<div>
|
|
||||||
<button id="save">Save & Restart</button>
|
|
||||||
</div>
|
|
||||||
<br>
|
|
||||||
<div id="config"></div>
|
|
||||||
<script>
|
|
||||||
let dump;
|
|
||||||
|
|
||||||
|
<script src="main.js"></script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div>
|
||||||
|
<button id="save">Save & Restart</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<div id="config"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* global ace */
|
||||||
ace.config.set('basePath', 'https://unpkg.com/ace-builds@1.33.1/src-min/');
|
ace.config.set('basePath', 'https://unpkg.com/ace-builds@1.33.1/src-min/');
|
||||||
const editor = ace.edit('config', {
|
const editor = ace.edit('config', {
|
||||||
mode: 'ace/mode/yaml',
|
mode: 'ace/mode/yaml',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let dump;
|
||||||
|
|
||||||
document.getElementById('save').addEventListener('click', async () => {
|
document.getElementById('save').addEventListener('click', async () => {
|
||||||
let r = await fetch('api/config', {cache: 'no-cache'});
|
let r = await fetch('api/config', {cache: 'no-cache'});
|
||||||
if (r.ok && dump !== await r.text()) {
|
if (r.ok && dump !== await r.text()) {
|
||||||
@@ -67,5 +62,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+34
-45
@@ -1,61 +1,49 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
|
||||||
<link rel="apple-touch-icon" href="https://alexxit.github.io/go2rtc/icons/apple-touch-icon-180x180.png" sizes="180x180">
|
|
||||||
<link rel="icon" href="https://alexxit.github.io/go2rtc/icons/favicon.ico">
|
|
||||||
<link rel="manifest" href="https://alexxit.github.io/go2rtc/manifest.json">
|
|
||||||
<title>go2rtc</title>
|
<title>go2rtc</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
.controls {
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
table tbody td {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.info {
|
||||||
display: flex;
|
color: #888;
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls > label {
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<script src="main.js"></script>
|
<script src="main.js"></script>
|
||||||
<div class="info"></div>
|
|
||||||
<div class="controls">
|
<main>
|
||||||
<button>stream</button>
|
<div class="controls">
|
||||||
<label><input type="checkbox" name="webrtc" checked>webrtc</label>
|
<button>stream</button>
|
||||||
<label><input type="checkbox" name="mse" checked>mse</label>
|
modes
|
||||||
<label><input type="checkbox" name="hls" checked>hls</label>
|
<label><input type="checkbox" name="webrtc" checked>webrtc</label>
|
||||||
<label><input type="checkbox" name="mjpeg" checked>mjpeg</label>
|
<label><input type="checkbox" name="mse" checked>mse</label>
|
||||||
</div>
|
<label><input type="checkbox" name="hls" checked>hls</label>
|
||||||
<table>
|
<label><input type="checkbox" name="mjpeg" checked>mjpeg</label>
|
||||||
<thead>
|
</div>
|
||||||
<tr>
|
<table>
|
||||||
<th><label><input id="selectall" type="checkbox">Name</label></th>
|
<thead>
|
||||||
<th>Online</th>
|
<tr>
|
||||||
<th>Commands</th>
|
<th><label><input id="selectall" type="checkbox">name</label></th>
|
||||||
</tr>
|
<th>online</th>
|
||||||
</thead>
|
<th>commands</th>
|
||||||
<tbody id="streams">
|
</tr>
|
||||||
</tbody>
|
</thead>
|
||||||
</table>
|
<tbody id="streams">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="info"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const templates = [
|
const templates = [
|
||||||
'<a href="stream.html?src={name}">stream</a>',
|
'<a href="stream.html?src={name}">stream</a>',
|
||||||
@@ -159,10 +147,11 @@
|
|||||||
const url = new URL('api', location.href);
|
const url = new URL('api', location.href);
|
||||||
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => {
|
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => {
|
||||||
const info = document.querySelector('.info');
|
const info = document.querySelector('.info');
|
||||||
info.innerText = `Version: ${data.version}, Config: ${data.config_path}`;
|
info.innerText = `version: ${data.version} / config: ${data.config_path}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
reload();
|
reload();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+168
-163
@@ -1,27 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>go2rtc - links</title>
|
<title>go2rtc - links</title>
|
||||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
|
||||||
<style>
|
<style>
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
div {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
div > li {
|
div > li {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
@@ -36,28 +19,33 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="main.js"></script>
|
|
||||||
<div id="links"></div>
|
|
||||||
<script>
|
|
||||||
const src = new URLSearchParams(location.search).get('src').replace(/[<">]/g, ''); // sanitize
|
|
||||||
|
|
||||||
document.getElementById('links').innerHTML = `
|
<script src="main.js"></script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div id="links"></div>
|
||||||
|
<script>
|
||||||
|
const src = new URLSearchParams(location.search).get('src').replace(/[<">]/g, ''); // sanitize
|
||||||
|
|
||||||
|
const links = document.getElementById('links');
|
||||||
|
|
||||||
|
links.innerHTML = `
|
||||||
<h2>Any codec in source</h2>
|
<h2>Any codec in source</h2>
|
||||||
<li><a href="stream.html?src=${src}">stream.html</a> with auto-select mode / browsers: all / codecs: H264, H265*, MJPEG, JPEG, AAC, PCMU, PCMA, OPUS</li>
|
<li><a href="stream.html?src=${src}">stream.html</a> with auto-select mode / browsers: all / codecs: H264, H265*, MJPEG, JPEG, AAC, PCMU, PCMA, OPUS</li>
|
||||||
<li><a href="api/streams?src=${src}">info.json</a> page with active connections</li>
|
<li><a href="api/streams?src=${src}">info.json</a> page with active connections</li>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const url = new URL('api', location.href);
|
const url = new URL('api', location.href);
|
||||||
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => {
|
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => {
|
||||||
let rtsp = location.host + ':8554';
|
let rtsp = location.host + ':8554';
|
||||||
try {
|
try {
|
||||||
const host = data.host.match(/^[^:]+/)[0];
|
const host = data.host.match(/^[^:]+/)[0];
|
||||||
const port = data.rtsp.listen.match(/[0-9]+$/)[0];
|
const port = data.rtsp.listen.match(/[0-9]+$/)[0];
|
||||||
rtsp = `${host}:${port}`;
|
rtsp = `${host}:${port}`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('links').innerHTML += `
|
links.innerHTML += `
|
||||||
<li><a href="rtsp://${rtsp}/${src}">rtsp</a> with only one video and one audio / codecs: any</li>
|
<li><a href="rtsp://${rtsp}/${src}">rtsp</a> with only one video and one audio / codecs: any</li>
|
||||||
<li><a href="rtsp://${rtsp}/${src}?mp4">rtsp</a> for MP4 recording (Hass or Frigate) / codecs: H264, H265, AAC</li>
|
<li><a href="rtsp://${rtsp}/${src}?mp4">rtsp</a> for MP4 recording (Hass or Frigate) / codecs: H264, H265, AAC</li>
|
||||||
<li><a href="rtsp://${rtsp}/${src}?video=all&audio=all">rtsp</a> with all tracks / codecs: any</li>
|
<li><a href="rtsp://${rtsp}/${src}?video=all&audio=all">rtsp</a> with all tracks / codecs: any</li>
|
||||||
@@ -80,148 +68,165 @@
|
|||||||
<li><a href="api/stream.mjpeg?src=${src}">stream.mjpeg</a> MJPEG stream / browsers: all / codecs: MJPEG, JPEG</li>
|
<li><a href="api/stream.mjpeg?src=${src}">stream.mjpeg</a> MJPEG stream / browsers: all / codecs: MJPEG, JPEG</li>
|
||||||
<li><a href="api/frame.jpeg?src=${src}">frame.jpeg</a> snapshot in JPEG-format / browsers: all / codecs: MJPEG, JPEG</li>
|
<li><a href="api/frame.jpeg?src=${src}">frame.jpeg</a> snapshot in JPEG-format / browsers: all / codecs: MJPEG, JPEG</li>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2>Play audio</h2>
|
<h2>Play audio</h2>
|
||||||
<label><input type="radio" name="play" value="file" checked>file - play remote (https://example.com/song.mp3) or local (/media/song.mp3) file</label><br>
|
<label><input type="radio" name="play" value="file" checked>
|
||||||
<label><input type="radio" name="play" value="live">live - play remote live stream (radio, etc.)</label><br>
|
file - play remote (https://example.com/song.mp3) or local (/media/song.mp3) file
|
||||||
<label><input type="radio" name="play" value="text">text - play Text To Speech (if your FFmpeg support this)</label><br>
|
</label>
|
||||||
<br>
|
<label><input type="radio" name="play" value="live">
|
||||||
<input id="play-url" type="text" placeholder="path / url / text">
|
live - play remote live stream (radio, etc.)
|
||||||
<a id="play-send" href="#">send</a> / cameras with two way audio support
|
</label>
|
||||||
</div>
|
<label><input type="radio" name="play" value="text">
|
||||||
<script>
|
text - play Text To Speech (if your FFmpeg support this)
|
||||||
document.getElementById('play-send').addEventListener('click', ev => {
|
</label>
|
||||||
ev.preventDefault();
|
<br>
|
||||||
// action - file / live / text
|
<input id="play-url" type="text" placeholder="path / url / text">
|
||||||
const action = document.querySelector('input[name="play"]:checked').value;
|
<button id="play-send">send</button>
|
||||||
const url = new URL('api/ffmpeg', location.href);
|
/ cameras with two way audio support
|
||||||
url.searchParams.set('dst', src);
|
</div>
|
||||||
url.searchParams.set(action, document.getElementById('play-url').value);
|
<script>
|
||||||
fetch(url, {method: 'POST'});
|
document.getElementById('play-send').addEventListener('click', ev => {
|
||||||
});
|
ev.preventDefault();
|
||||||
</script>
|
// action - file / live / text
|
||||||
|
const action = document.querySelector('input[name="play"]:checked').value;
|
||||||
|
const url = new URL('api/ffmpeg', location.href);
|
||||||
|
url.searchParams.set('dst', src);
|
||||||
|
url.searchParams.set(action, document.getElementById('play-url').value);
|
||||||
|
fetch(url, {method: 'POST'});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2>Publish stream</h2>
|
<h2>Publish stream</h2>
|
||||||
<pre>YouTube: rtmps://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx
|
<pre>YouTube: rtmps://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx
|
||||||
Telegram: rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx</pre>
|
Telegram: rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx</pre>
|
||||||
<input id="pub-url" type="text" placeholder="url">
|
<input id="pub-url" type="text" placeholder="url">
|
||||||
<a id="pub-send" href="#">send</a> / Telegram RTMPS server
|
<button id="pub-send">send</button>
|
||||||
</div>
|
/ Telegram RTMPS server
|
||||||
<script>
|
</div>
|
||||||
document.getElementById('pub-send').addEventListener('click', ev => {
|
<script>
|
||||||
ev.preventDefault();
|
document.getElementById('pub-send').addEventListener('click', ev => {
|
||||||
const url = new URL('api/streams', location.href);
|
ev.preventDefault();
|
||||||
url.searchParams.set('src', src);
|
const url = new URL('api/streams', location.href);
|
||||||
url.searchParams.set('dst', document.getElementById('pub-url').value);
|
url.searchParams.set('src', src);
|
||||||
fetch(url, {method: 'POST'});
|
url.searchParams.set('dst', document.getElementById('pub-url').value);
|
||||||
});
|
fetch(url, {method: 'POST'});
|
||||||
</script>
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<div id="webrtc">
|
<div id="webrtc">
|
||||||
<h2>WebRTC Magic</h2>
|
<h2>WebRTC Magic</h2>
|
||||||
<label><input type="radio" name="webrtc" value="video+audio" checked>video+audio = simple viewer</label><br>
|
<label><input type="radio" name="webrtc" value="video+audio" checked>
|
||||||
<label><input type="radio" name="webrtc" value="video+audio+microphone">video+audio+microphone = two way audio from camera</label><br>
|
video+audio = simple viewer
|
||||||
<label><input type="radio" name="webrtc" value="camera+microphone">camera+microphone = stream from browser</label><br>
|
</label>
|
||||||
<label><input type="radio" name="webrtc" value="display+speaker">display+speaker = broadcast software</label><br>
|
<label><input type="radio" name="webrtc" value="video+audio+microphone">
|
||||||
|
video+audio+microphone = two way audio from camera
|
||||||
|
</label>
|
||||||
|
<label><input type="radio" name="webrtc" value="camera+microphone">
|
||||||
|
camera+microphone = stream from browser
|
||||||
|
</label>
|
||||||
|
<label><input type="radio" name="webrtc" value="display+speaker">
|
||||||
|
display+speaker = broadcast software
|
||||||
|
</label>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
<li><a id="local" href="webrtc.html?src=">webrtc.html</a> local WebRTC viewer</li>
|
<li><a id="local" href="webrtc.html?src=">webrtc.html</a> local WebRTC viewer</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<a id="shareadd" href="#">share link</a>
|
<a id="shareadd" href="#">share link</a>
|
||||||
<a id="shareget" href="#">copy link</a>
|
<a id="shareget" href="#">copy link</a>
|
||||||
<a id="sharedel" href="#">delete</a>
|
<a id="sharedel" href="#">delete</a>
|
||||||
external WebRTC viewer
|
external WebRTC viewer
|
||||||
</li>
|
</li>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
function webrtcLinksUpdate() {
|
function webrtcLinksUpdate() {
|
||||||
const media = document.querySelector('input[name="webrtc"]:checked').value;
|
const media = document.querySelector('input[name="webrtc"]:checked').value;
|
||||||
|
|
||||||
const direction = media.indexOf('video') >= 0 || media === 'audio' ? 'src' : 'dst';
|
const direction = media.indexOf('video') >= 0 || media === 'audio' ? 'src' : 'dst';
|
||||||
document.getElementById('local').href = `webrtc.html?${direction}=${src}&media=${media}`;
|
document.getElementById('local').href = `webrtc.html?${direction}=${src}&media=${media}`;
|
||||||
|
|
||||||
const share = document.getElementById('shareget');
|
const share = document.getElementById('shareget');
|
||||||
share.href = `https://alexxit.github.io/go2rtc/#${share.dataset.auth}&media=${media}`;
|
share.href = `https://alexxit.github.io/go2rtc/#${share.dataset.auth}&media=${media}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function share(method) {
|
function share(method) {
|
||||||
const url = new URL('api/webtorrent', location.href);
|
const url = new URL('api/webtorrent', location.href);
|
||||||
url.searchParams.set('src', src);
|
url.searchParams.set('src', src);
|
||||||
return fetch(url, {method: method, cache: 'no-cache'});
|
return fetch(url, {method: method, cache: 'no-cache'});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onshareadd(r) {
|
function onshareadd(r) {
|
||||||
document.getElementById('shareget').dataset['auth'] = `share=${r.share}&pwd=${r.pwd}`;
|
document.getElementById('shareget').dataset['auth'] = `share=${r.share}&pwd=${r.pwd}`;
|
||||||
|
|
||||||
document.getElementById('shareadd').style.display = 'none';
|
document.getElementById('shareadd').style.display = 'none';
|
||||||
document.getElementById('shareget').style.display = '';
|
document.getElementById('shareget').style.display = '';
|
||||||
document.getElementById('sharedel').style.display = '';
|
document.getElementById('sharedel').style.display = '';
|
||||||
|
|
||||||
|
webrtcLinksUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onsharedel() {
|
||||||
|
document.getElementById('shareadd').style.display = '';
|
||||||
|
document.getElementById('shareget').style.display = 'none';
|
||||||
|
document.getElementById('sharedel').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyTextToClipboard(text) {
|
||||||
|
// https://web.dev/patterns/clipboard/copy-text
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(text).catch(err => {
|
||||||
|
console.error(err.name, err.message);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err.name, err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('shareadd').addEventListener('click', ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
share('POST').then(r => r.json()).then(r => onshareadd(r));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('shareget').addEventListener('click', ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
copyTextToClipboard(ev.target.href);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('sharedel').addEventListener('click', ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
share('DELETE').then(() => onsharedel());
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('webrtc').addEventListener('click', ev => {
|
||||||
|
if (ev.target.tagName === 'INPUT') webrtcLinksUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
share('GET').then(r => {
|
||||||
|
if (r.ok) r.json().then(r => onshareadd(r));
|
||||||
|
else onsharedel();
|
||||||
|
});
|
||||||
|
|
||||||
webrtcLinksUpdate();
|
webrtcLinksUpdate();
|
||||||
}
|
</script>
|
||||||
|
</main>
|
||||||
function onsharedel() {
|
|
||||||
document.getElementById('shareadd').style.display = '';
|
|
||||||
document.getElementById('shareget').style.display = 'none';
|
|
||||||
document.getElementById('sharedel').style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyTextToClipboard(text) {
|
|
||||||
// https://web.dev/patterns/clipboard/copy-text
|
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
|
||||||
navigator.clipboard.writeText(text).catch(err => {
|
|
||||||
console.error(err.name, err.message);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const textarea = document.createElement('textarea');
|
|
||||||
textarea.value = text;
|
|
||||||
textarea.style.opacity = '0';
|
|
||||||
document.body.appendChild(textarea);
|
|
||||||
|
|
||||||
textarea.focus();
|
|
||||||
textarea.select();
|
|
||||||
|
|
||||||
try {
|
|
||||||
document.execCommand('copy');
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err.name, err.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('shareadd').addEventListener('click', ev => {
|
|
||||||
ev.preventDefault();
|
|
||||||
share('POST').then(r => r.json()).then(r => onshareadd(r));
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('shareget').addEventListener('click', ev => {
|
|
||||||
ev.preventDefault();
|
|
||||||
copyTextToClipboard(ev.target.href);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('sharedel').addEventListener('click', ev => {
|
|
||||||
ev.preventDefault();
|
|
||||||
share('DELETE').then(() => onsharedel());
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('webrtc').addEventListener('click', ev => {
|
|
||||||
if (ev.target.tagName === 'INPUT') webrtcLinksUpdate();
|
|
||||||
});
|
|
||||||
|
|
||||||
share('GET').then(r => {
|
|
||||||
if (r.ok) r.json().then(r => onshareadd(r));
|
|
||||||
else onsharedel();
|
|
||||||
});
|
|
||||||
|
|
||||||
webrtcLinksUpdate();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+42
-46
@@ -1,69 +1,64 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>go2rtc - Logs</title>
|
<title>go2rtc - Logs</title>
|
||||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
|
||||||
<style>
|
<style>
|
||||||
body {
|
main > div {
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
background-color: white;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
table tbody {
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
table tbody td {
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info {
|
|
||||||
color: #0174DF;
|
|
||||||
}
|
|
||||||
|
|
||||||
.debug {
|
|
||||||
color: #808080;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #DF0101;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.trace {
|
.trace {
|
||||||
color: #585858;
|
color: #585858 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug {
|
||||||
|
color: #808080 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
color: #0174DF !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.warn {
|
.warn {
|
||||||
color: #FF9966;
|
color: #FF9966 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #DF0101 !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<script src="main.js"></script>
|
<script src="main.js"></script>
|
||||||
<div>
|
|
||||||
<button id="clean">Clean</button>
|
<main>
|
||||||
<button id="update">Auto Update: ON</button>
|
<div>
|
||||||
<button id="reverse">Reverse Log Order: OFF</button>
|
<button id="clean">Clean</button>
|
||||||
</div>
|
<button id="update">Auto Update: ON</button>
|
||||||
<br>
|
<button id="reverse">Reverse Log Order: OFF</button>
|
||||||
<table>
|
</div>
|
||||||
<thead>
|
<table>
|
||||||
<tr>
|
<thead>
|
||||||
<th style="width: 100px">Time</th>
|
<tr>
|
||||||
<th style="width: 40px">Level</th>
|
<th style="width: 100px">Time</th>
|
||||||
<th>Message</th>
|
<th style="width: 40px">Level</th>
|
||||||
</tr>
|
<th>Message</th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody id="log">
|
</thead>
|
||||||
</tbody>
|
<tbody id="log">
|
||||||
</table>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('clean').addEventListener('click', async () => {
|
document.getElementById('clean').addEventListener('click', async () => {
|
||||||
const r = await fetch('api/log', {method: 'DELETE'});
|
const r = await fetch('api/log', {method: 'DELETE'});
|
||||||
@@ -145,5 +140,6 @@
|
|||||||
if (autoUpdateEnabled) reload();
|
if (autoUpdateEnabled) reload();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+114
-180
@@ -1,200 +1,134 @@
|
|||||||
// main menu
|
document.head.innerHTML += `
|
||||||
document.body.innerHTML = `
|
|
||||||
<style>
|
<style>
|
||||||
ul {
|
body {
|
||||||
list-style: none;
|
display: flex;
|
||||||
margin: 0 auto;
|
flex-direction: column;
|
||||||
}
|
font-family: Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
/* navigation block */
|
||||||
text-decoration: none;
|
nav {
|
||||||
font-family: 'Lora', serif;
|
background-color: #333;
|
||||||
transition: .5s linear;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
i {
|
nav a {
|
||||||
margin-right: 10px;
|
float: left;
|
||||||
}
|
display: block;
|
||||||
|
color: #f2f2f2;
|
||||||
|
text-align: center;
|
||||||
|
padding: 14px 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
nav {
|
nav a:hover {
|
||||||
display: block;
|
background-color: #ddd;
|
||||||
margin: 0 auto 10px;
|
color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav ul {
|
/* main block */
|
||||||
padding: 1em 0;
|
main {
|
||||||
background: #ECDAD6;
|
padding: 10px;
|
||||||
}
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
nav a {
|
/* checkbox */
|
||||||
padding: 1em;
|
label {
|
||||||
background: rgba(177, 152, 145, .3);
|
display: flex;
|
||||||
border-right: 1px solid #b19891;
|
gap: 5px;
|
||||||
color: #695753;
|
align-items: center;
|
||||||
}
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
nav a:hover {
|
input[type="checkbox"] {
|
||||||
background: #b19891;
|
width: 18px;
|
||||||
}
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
nav li {
|
/* form */
|
||||||
display: inline;
|
form {
|
||||||
}
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
input[type="text"] {
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
padding: 10px;
|
||||||
background-color: white;
|
border: 1px solid #ccc;
|
||||||
}
|
border-radius: 4px;
|
||||||
table {
|
font-size: 16px;
|
||||||
background-color: white;
|
}
|
||||||
text-align: left;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
table thead {
|
|
||||||
background: #CFCFCF;
|
|
||||||
background: linear-gradient(to bottom, #dbdbdb 0%, #d3d3d3 66%, #CFCFCF 100%);
|
|
||||||
border-bottom: 3px solid black;
|
|
||||||
}
|
|
||||||
table thead th {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: black;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
table td, table th {
|
|
||||||
border: 1px solid black;
|
|
||||||
padding: 5px 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode styles */
|
button {
|
||||||
body.dark-mode {
|
padding: 10px 20px;
|
||||||
background-color: #121212;
|
border: 1px solid #ccc;
|
||||||
color: #e0e0e0;
|
border-radius: 4px;
|
||||||
}
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
body.dark-mode nav ul {
|
/* table */
|
||||||
background: #333;
|
table {
|
||||||
}
|
width: 100%;
|
||||||
|
background-color: white;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
body.dark-mode a {
|
th, td {
|
||||||
background: rgba(45, 45, 45, .8);
|
padding: 12px 15px;
|
||||||
border-right: 1px solid #2c2c2c;
|
text-align: left;
|
||||||
color: #c7c7c7;
|
border-bottom: 1px solid #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode a:hover {
|
th {
|
||||||
background: #555;
|
background-color: #444;
|
||||||
}
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
body.dark-mode a:visited {
|
tr:nth-child(even) {
|
||||||
color: #999;
|
background-color: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode table {
|
tr:hover {
|
||||||
background-color: #222;
|
background-color: #edf7ff;
|
||||||
color: #ddd;
|
transition: background-color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode table thead {
|
/* table on mobile */
|
||||||
background: linear-gradient(to bottom, #444 0%, #3d3d3d 66%, #333 100%);
|
@media (max-width: 480px) {
|
||||||
border-bottom: 3px solid #888;
|
table, thead, tbody, th, td, tr {
|
||||||
}
|
display: block;
|
||||||
body.dark-mode table thead th {
|
}
|
||||||
font-size: 15px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #ddd;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
body.dark-mode table td, body.dark-mode table th {
|
|
||||||
border: 1px solid #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode button {
|
th, td {
|
||||||
background: rgba(255, 255, 255, .1);
|
box-sizing: border-box;
|
||||||
border: 1px solid #444;
|
width: 100% !important;
|
||||||
color: #ccc;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode input,
|
tr {
|
||||||
body.dark-mode select,
|
margin-bottom: 10px;
|
||||||
body.dark-mode textarea {
|
border-radius: 4px;
|
||||||
background-color: #333;
|
}
|
||||||
color: #e0e0e0;
|
}
|
||||||
border: 1px solid #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode input::placeholder,
|
|
||||||
body.dark-mode textarea::placeholder {
|
|
||||||
color: #bbb;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode hr {
|
|
||||||
border-top: 1px solid #444;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
<nav>
|
`;
|
||||||
<ul>
|
|
||||||
<li><a href="index.html">Streams</a></li>
|
document.body.innerHTML = `
|
||||||
<li><a href="add.html">Add</a></li>
|
<header>
|
||||||
<li><a href="editor.html">Config</a></li>
|
<nav>
|
||||||
<li><a href="log.html">Log</a></li>
|
<a href="index.html"><b>go2rtc</b></a>
|
||||||
<li><a href="network.html">Net</a></li>
|
<a href="add.html">add</a>
|
||||||
<li><a href="#" id="darkModeToggle">
|
<a href="config.html">config</a>
|
||||||
🌙
|
<a href="log.html">log</a>
|
||||||
</a>
|
<a href="net.html">net</a>
|
||||||
</li>
|
</nav>
|
||||||
</ul>
|
</header>
|
||||||
</nav>
|
|
||||||
` + document.body.innerHTML;
|
` + document.body.innerHTML;
|
||||||
|
|
||||||
const sunIcon = '☀️';
|
|
||||||
const moonIcon = '🌕';
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const darkModeToggle = document.getElementById('darkModeToggle');
|
|
||||||
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
|
|
||||||
const isDarkModeEnabled = () => document.body.classList.contains('dark-mode');
|
|
||||||
|
|
||||||
// Update the toggle button based on the dark mode state
|
|
||||||
const updateToggleButton = () => {
|
|
||||||
if (isDarkModeEnabled()) {
|
|
||||||
darkModeToggle.innerHTML = sunIcon;
|
|
||||||
darkModeToggle.setAttribute('aria-label', 'Enable light mode');
|
|
||||||
} else {
|
|
||||||
darkModeToggle.innerHTML = moonIcon;
|
|
||||||
darkModeToggle.setAttribute('aria-label', 'Enable dark mode');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateDarkMode = () => {
|
|
||||||
if (localStorage.getItem('darkMode') === 'enabled' || prefersDarkScheme.matches && localStorage.getItem('darkMode') !== 'disabled') {
|
|
||||||
document.body.classList.add('dark-mode');
|
|
||||||
} else {
|
|
||||||
document.body.classList.remove('dark-mode');
|
|
||||||
}
|
|
||||||
updateEditorTheme();
|
|
||||||
updateToggleButton();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update the editor theme based on the dark mode state
|
|
||||||
const updateEditorTheme = () => {
|
|
||||||
if (typeof editor !== 'undefined') {
|
|
||||||
editor.setTheme(isDarkModeEnabled() ? 'ace/theme/tomorrow_night_eighties' : 'ace/theme/github');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initial update for dark mode and toggle button
|
|
||||||
updateDarkMode();
|
|
||||||
|
|
||||||
// Listen for changes in the system's color scheme preference
|
|
||||||
prefersDarkScheme.addEventListener('change', updateDarkMode); // Modern approach
|
|
||||||
|
|
||||||
// Toggle dark mode and update local storage on button click
|
|
||||||
darkModeToggle.addEventListener('click', () => {
|
|
||||||
const enabled = document.body.classList.toggle('dark-mode');
|
|
||||||
localStorage.setItem('darkMode', enabled ? 'enabled' : 'disabled');
|
|
||||||
updateToggleButton(); // Update the button after toggling
|
|
||||||
updateEditorTheme();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -2,31 +2,21 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>go2rtc - Network</title>
|
<title>go2rtc - Network</title>
|
||||||
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
|
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
body {
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
background-color: white;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body, #network {
|
html, body, #network {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#network {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="network"></div>
|
|
||||||
<script src="main.js"></script>
|
<script src="main.js"></script>
|
||||||
|
|
||||||
|
<div id="network"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
/* global vis */
|
/* global vis */
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
@@ -79,5 +69,6 @@
|
|||||||
update();
|
update();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user