Live video on the AT Protocol
1package config
2
3import (
4 "crypto/rsa"
5 "crypto/x509"
6 "encoding/pem"
7 "errors"
8 "flag"
9 "fmt"
10 "io"
11 "net"
12 "os"
13 "path/filepath"
14 "runtime"
15 "strconv"
16 "strings"
17 "time"
18
19 "github.com/lestrrat-go/jwx/v2/jwk"
20 "github.com/peterbourgon/ff/v3"
21 "golang.org/x/exp/rand"
22 "stream.place/streamplace/pkg/aqtime"
23 "stream.place/streamplace/pkg/constants"
24 "stream.place/streamplace/pkg/crypto/aqpub"
25)
26
27const SPDataDir = "$SP_DATA_DIR"
28const SegmentsDir = "segments"
29
30type BuildFlags struct {
31 Version string
32 BuildTime int64
33 UUID string
34}
35
36func (b BuildFlags) BuildTimeStr() string {
37 ts := time.Unix(b.BuildTime, 0)
38 return ts.UTC().Format(time.RFC3339)
39}
40
41func (b BuildFlags) BuildTimeStrExpo() string {
42 ts := time.Unix(b.BuildTime, 0)
43 return ts.UTC().Format("2006-01-02T15:04:05.000Z")
44}
45
46type CLI struct {
47 AdminAccount string
48 Build *BuildFlags
49 DataDir string
50 DBPath string
51 EthAccountAddr string
52 EthKeystorePath string
53 EthPassword string
54 FirebaseServiceAccount string
55 GitLabURL string
56 HTTPAddr string
57 HTTPInternalAddr string
58 HTTPSAddr string
59 RtmpsAddr string
60 Secure bool
61 NoMist bool
62 MistAdminPort int
63 MistHTTPPort int
64 MistRTMPPort int
65 SigningKeyPath string
66 TAURL string
67 TLSCertPath string
68 TLSKeyPath string
69 PKCS11ModulePath string
70 PKCS11Pin string
71 PKCS11TokenSlot string
72 PKCS11TokenLabel string
73 PKCS11TokenSerial string
74 PKCS11KeypairLabel string
75 PKCS11KeypairID string
76 StreamerName string
77 RelayHost string
78 Debug map[string]map[string]int
79 AllowedStreams []string
80 WideOpen bool
81 Peers []string
82 Redirects []string
83 TestStream bool
84 FrontendProxy string
85 AppBundleID string
86 NoFirehose bool
87 PrintChat bool
88 Color string
89 LivepeerGatewayURL string
90 WHIPTest string
91 Thumbnail bool
92 SmearAudio bool
93 ExternalSigning bool
94 RTMPServerAddon string
95 TracingEndpoint string
96 PublicHost string
97 RateLimitPerSecond int
98 RateLimitBurst int
99 RateLimitWebsocket int
100 JWK jwk.Key
101 AccessJWK jwk.Key
102 dataDirFlags []*string
103}
104
105var StreamplaceSchemePrefix = "streamplace://"
106
107func (cli *CLI) OwnInternalURL() string {
108 // No errors because we know it's valid from AddrFlag
109 host, port, _ := net.SplitHostPort(cli.HTTPInternalAddr)
110 ip := net.ParseIP(host)
111 if ip.IsUnspecified() {
112 host = "127.0.0.1"
113 }
114 addr := net.JoinHostPort(host, port)
115 return fmt.Sprintf("http://%s", addr)
116}
117
118func (cli *CLI) ParseSigningKey() (*rsa.PrivateKey, error) {
119 bs, err := os.ReadFile(cli.SigningKeyPath)
120 if err != nil {
121 return nil, err
122 }
123 block, _ := pem.Decode(bs)
124 if block == nil {
125 return nil, fmt.Errorf("no RSA key found in signing key")
126 }
127 key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
128 if err != nil {
129 return nil, err
130 }
131 return key, nil
132}
133
134func RandomTrailer(length int) string {
135 const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
136
137 res := make([]byte, length)
138 for i := 0; i < length; i++ {
139 res[i] = charset[rand.Intn(len(charset))]
140 }
141 return string(res)
142}
143
144func DefaultDataDir() string {
145 home, err := os.UserHomeDir()
146 if err != nil {
147 // not fatal unless the user doesn't set one later
148 return ""
149 }
150 return filepath.Join(home, ".streamplace")
151}
152
153func (cli *CLI) Parse(fs *flag.FlagSet, args []string) error {
154 err := ff.Parse(
155 fs, os.Args[1:],
156 ff.WithEnvVarPrefix("SP"),
157 )
158 if err != nil {
159 return err
160 }
161 if cli.DataDir == "" {
162 return fmt.Errorf("could not determine default data dir (no $HOME) and none provided, please set --data-dir")
163 }
164 for _, dest := range cli.dataDirFlags {
165 *dest = strings.Replace(*dest, SPDataDir, cli.DataDir, 1)
166 }
167 return nil
168}
169
170func (cli *CLI) DataFilePath(fpath []string) string {
171 if cli.DataDir == "" {
172 panic("no data dir configured")
173 }
174 // windows does not like colons
175 safe := []string{}
176 for _, p := range fpath {
177 safe = append(safe, strings.ReplaceAll(p, ":", "-"))
178 }
179 fpath = append([]string{cli.DataDir}, safe...)
180 fdpath := filepath.Join(fpath...)
181 return fdpath
182}
183
184// does a file exist in our data dir?
185func (cli *CLI) DataFileExists(fpath []string) (bool, error) {
186 ddpath := cli.DataFilePath(fpath)
187 _, err := os.Stat(ddpath)
188 if err == nil {
189 return true, nil
190 }
191 if errors.Is(err, os.ErrNotExist) {
192 return false, nil
193 }
194 return false, err
195}
196
197// write a file to our data dir
198func (cli *CLI) DataFileWrite(fpath []string, r io.Reader, overwrite bool) error {
199 fd, err := cli.DataFileCreate(fpath, overwrite)
200 if err != nil {
201 return err
202 }
203 defer fd.Close()
204 _, err = io.Copy(fd, r)
205 if err != nil {
206 return err
207 }
208
209 return nil
210}
211
212// create a file in our data dir. don't forget to close it!
213func (cli *CLI) DataFileCreate(fpath []string, overwrite bool) (*os.File, error) {
214 ddpath := cli.DataFilePath(fpath)
215 if !overwrite {
216 exists, err := cli.DataFileExists(fpath)
217 if err != nil {
218 return nil, err
219 }
220 if exists {
221 return nil, fmt.Errorf("refusing to overwrite file that exists: %s", ddpath)
222 }
223 }
224 if len(fpath) > 1 {
225 dirs, _ := filepath.Split(ddpath)
226 err := os.MkdirAll(dirs, os.ModePerm)
227 if err != nil {
228 return nil, fmt.Errorf("error creating subdirectories for %s: %w", ddpath, err)
229 }
230 }
231 return os.Create(ddpath)
232}
233
234// get a path to a segment file in our database
235func (cli *CLI) SegmentFilePath(user string, file string) (string, error) {
236 ext := filepath.Ext(file)
237 base := strings.TrimSuffix(file, ext)
238 aqt, err := aqtime.FromString(base)
239 if err != nil {
240 return "", err
241 }
242 fname := fmt.Sprintf("%s%s", aqt.FileSafeString(), ext)
243 yr, mon, day, hr, min, _, _ := aqt.Parts()
244 return cli.DataFilePath([]string{SegmentsDir, user, yr, mon, day, hr, min, fname}), nil
245}
246
247// get a path to a segment file in our database
248func (cli *CLI) HLSDir(user string) (string, error) {
249 return cli.DataFilePath([]string{SegmentsDir, "hls", user}), nil
250}
251
252// create a segment file in our database
253func (cli *CLI) SegmentFileCreate(user string, aqt aqtime.AQTime, ext string) (*os.File, error) {
254 fname := fmt.Sprintf("%s.%s", aqt.FileSafeString(), ext)
255 yr, mon, day, hr, min, _, _ := aqt.Parts()
256 return cli.DataFileCreate([]string{SegmentsDir, user, yr, mon, day, hr, min, fname}, false)
257}
258
259// read a file from our data dir
260func (cli *CLI) DataFileRead(fpath []string, w io.Writer) error {
261 ddpath := cli.DataFilePath(fpath)
262
263 fd, err := os.Open(ddpath)
264 if err != nil {
265 return err
266 }
267 _, err = io.Copy(w, fd)
268 if err != nil {
269 return err
270 }
271
272 return nil
273}
274
275func (cli *CLI) DataDirFlag(fs *flag.FlagSet, dest *string, name, defaultValue, usage string) {
276 cli.dataDirFlags = append(cli.dataDirFlags, dest)
277 *dest = filepath.Join(SPDataDir, defaultValue)
278 usage = fmt.Sprintf(`%s (default: "%s")`, usage, *dest)
279 fs.Func(name, usage, func(s string) error {
280 *dest = s
281 return nil
282 })
283}
284
285func (cli *CLI) HasMist() bool {
286 return runtime.GOOS == "linux"
287}
288
289// type for comma-separated ethereum addresses
290func (cli *CLI) AddressSliceFlag(fs *flag.FlagSet, dest *[]aqpub.Pub, name, defaultValue, usage string) {
291 *dest = []aqpub.Pub{}
292 usage = fmt.Sprintf(`%s (default: "%s")`, usage, *dest)
293 fs.Func(name, usage, func(s string) error {
294 if s == "" {
295 return nil
296 }
297 strs := strings.Split(s, ",")
298 for _, str := range strs {
299 pub, err := aqpub.FromHexString(str)
300 if err != nil {
301 return err
302 }
303 *dest = append(*dest, pub)
304 }
305 return nil
306 })
307}
308
309func (cli *CLI) StringSliceFlag(fs *flag.FlagSet, dest *[]string, name, defaultValue, usage string) {
310 *dest = []string{}
311 usage = fmt.Sprintf(`%s (default: "%s")`, usage, *dest)
312 fs.Func(name, usage, func(s string) error {
313 if s == "" {
314 return nil
315 }
316 strs := strings.Split(s, ",")
317 *dest = append(*dest, strs...)
318 return nil
319 })
320}
321
322// debug flag for turning func=ToHLS:3,file=gstreamer.go:4 into {"func": {"ToHLS": 3}, "file": {"gstreamer.go": 4}}
323func (cli *CLI) DebugFlag(fs *flag.FlagSet, dest *map[string]map[string]int, name, defaultValue, usage string) {
324 *dest = map[string]map[string]int{}
325 fs.Func(name, usage, func(s string) error {
326 if s == "" {
327 return nil
328 }
329 pairs := strings.Split(s, ",")
330 for _, pair := range pairs {
331 scoreSplit := strings.Split(pair, ":")
332 if len(scoreSplit) != 2 {
333 return fmt.Errorf("invalid debug flag: %s", pair)
334 }
335 score, err := strconv.Atoi(scoreSplit[1])
336 if err != nil {
337 return fmt.Errorf("invalid debug flag: %s", pair)
338 }
339 selectorSplit := strings.Split(scoreSplit[0], "=")
340 if len(selectorSplit) != 2 {
341 return fmt.Errorf("invalid debug flag: %s", pair)
342 }
343 _, ok := (*dest)[selectorSplit[0]]
344 if !ok {
345 (*dest)[selectorSplit[0]] = map[string]int{}
346 }
347 (*dest)[selectorSplit[0]][selectorSplit[1]] = score
348 }
349
350 return nil
351 })
352}
353
354func (cli *CLI) StreamIsAllowed(did string) error {
355 if cli.WideOpen {
356 return nil
357 }
358 // if the user set no test streams, anyone can stream
359 openServer := len(cli.AllowedStreams) == 0 || (cli.TestStream && len(cli.AllowedStreams) == 1)
360 // but only valid atproto accounts! did:key is only allowed for our local test stream
361 isDIDKey := strings.HasPrefix(did, constants.DID_KEY_PREFIX)
362 if openServer && !isDIDKey {
363 return nil
364 }
365 for _, a := range cli.AllowedStreams {
366 if a == did {
367 return nil
368 }
369 }
370 return fmt.Errorf("user is not allowed to stream")
371}