├── .github
├── FUNDING.yml
└── workflows
│ └── build.yml
├── .gitignore
├── .goreleaser.yml
├── Dockerfile
├── LICENSE
├── README.md
├── auth
└── auth.go
├── cmd
├── command.go
├── hash.go
└── serve.go
├── config
├── config.go
├── error.go
├── ip.go
├── ipdns
│ ├── dns.go
│ ├── provider.go
│ └── static.go
├── loglevel.go
├── loglevel_test.go
└── mode
│ ├── mode.go
│ └── mode_test.go
├── docs
├── .nojekyll
├── CNAME
├── README.md
├── _sidebar.md
├── apple-touch-icon.png
├── config.md
├── development.md
├── faq.md
├── favicon-16x16.png
├── favicon-32x32.png
├── favicon.ico
├── index.html
├── install.md
├── logo.png
├── logo.svg
├── nat-traversal.md
└── proxy.md
├── go.mod
├── go.sum
├── logger
└── logger.go
├── main.go
├── router
└── router.go
├── screego.config.development
├── screego.config.example
├── server
├── server.go
└── server_test.go
├── turn
├── none.go
├── portrange.go
└── server.go
├── ui
├── .gitignore
├── .prettierrc
├── index.html
├── package.json
├── public
│ ├── apple-touch-icon.png
│ ├── favicon.ico
│ ├── logo.svg
│ └── og-banner.png
├── serve.go
├── src
│ ├── LoginForm.tsx
│ ├── NumberField.tsx
│ ├── Room.tsx
│ ├── RoomManage.tsx
│ ├── Router.tsx
│ ├── SettingDialog.tsx
│ ├── Video.tsx
│ ├── global.css
│ ├── index.tsx
│ ├── logo.png
│ ├── message.ts
│ ├── settings.ts
│ ├── url.ts
│ ├── useConfig.ts
│ ├── useRoom.ts
│ ├── useRoomID.ts
│ └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.mts
└── yarn.lock
├── users
├── util
├── password.go
└── sillyname.go
└── ws
├── client.go
├── event.go
├── event_clientanswer.go
├── event_clientice.go
├── event_connected.go
├── event_create.go
├── event_disconnected.go
├── event_health.go
├── event_hostice.go
├── event_hostoffer.go
├── event_join.go
├── event_name.go
├── event_share.go
├── event_stop_share.go
├── once.go
├── once_test.go
├── outgoing
└── messages.go
├── prometheus.go
├── readwrite.go
├── room.go
├── rooms.go
└── rooms_test.go
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: jmattheis
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | custom: https://jmattheis.de/donate
13 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: build
2 | on: [push, pull_request]
3 |
4 | jobs:
5 | screego:
6 | runs-on: ubuntu-latest
7 | steps:
8 | - uses: actions/setup-go@v5
9 | with:
10 | go-version: 1.24.x
11 | - uses: actions/setup-node@v4
12 | with:
13 | node-version: '22'
14 | - uses: actions/checkout@v4
15 | - run: go mod download
16 | - run: (cd ui && yarn)
17 | - run: (cd ui && yarn build)
18 | - run: (cd ui && yarn testformat)
19 | - uses: golangci/golangci-lint-action@v6
20 | with:
21 | version: v1.64.6
22 | - run: go build ./...
23 | - run: go test -race ./...
24 | - if: startsWith(github.ref, 'refs/tags/v')
25 | run: |
26 | echo "$DOCKER_PASS" | docker login --username "$DOCKER_USER" --password-stdin
27 | echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.actor }}" --password-stdin
28 | env:
29 | DOCKER_USER: ${{ secrets.DOCKER_USER }}
30 | DOCKER_PASS: ${{ secrets.DOCKER_PASS }}
31 | - if: startsWith(github.ref, 'refs/tags/v')
32 | uses: goreleaser/goreleaser-action@v5
33 | with:
34 | version: 1.22.1
35 | args: release --skip-validate
36 | env:
37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
38 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /target
2 | /.idea
3 | *-packr.go
4 | /dist/
5 | *.local
6 |
--------------------------------------------------------------------------------
/.goreleaser.yml:
--------------------------------------------------------------------------------
1 | # This is an example goreleaser.yaml file with some sane defaults.
2 | # Make sure to check the documentation at http://goreleaser.com
3 | project_name: screego
4 | before:
5 | hooks:
6 | - go mod download
7 | builds:
8 | - env:
9 | - CGO_ENABLED=0
10 | goos:
11 | - linux
12 | - windows
13 | - darwin
14 | - freebsd
15 | - openbsd
16 | goarch:
17 | - "386"
18 | - amd64
19 | - arm
20 | - arm64
21 | - ppc64
22 | - ppc64le
23 | goarm:
24 | - "6"
25 | - "7"
26 | flags:
27 | - '-tags="netgo osusergo"'
28 | ldflags:
29 | - "-s"
30 | - "-w"
31 | - "-X main.version={{.Version}}"
32 | - "-X main.commitHash={{.Commit}}"
33 | - "-X main.mode=prod"
34 | archives:
35 | - files:
36 | - LICENSE
37 | - README.md
38 | - screego.config.example
39 | name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{- if eq .Arch "386" }}i386{{- else }}{{ .Arch }}{{ end }}{{ if .Arm }}v{{ .Arm }}{{ end }}'
40 | format_overrides:
41 | - goos: windows
42 | format: zip
43 | checksum:
44 | disable: true
45 | changelog:
46 | skip: true
47 | dockers:
48 | - use: buildx
49 | goos: linux
50 | goarch: amd64
51 | goarm: ""
52 | image_templates:
53 | - "screego/server:amd64-unstable"
54 | - "screego/server:amd64-{{ .RawVersion }}"
55 | - "screego/server:amd64-{{ .Major }}"
56 | - "ghcr.io/screego/server:amd64-unstable"
57 | - "ghcr.io/screego/server:amd64-{{ .RawVersion }}"
58 | - "ghcr.io/screego/server:amd64-{{ .Major }}"
59 | dockerfile: Dockerfile
60 | build_flag_templates:
61 | - "--platform=linux/amd64"
62 | - "--label=org.opencontainers.image.created={{.Date}}"
63 | - "--label=org.opencontainers.image.title={{.ProjectName}}"
64 | - "--label=org.opencontainers.image.revision={{.FullCommit}}"
65 | - "--label=org.opencontainers.image.version={{.Version}}"
66 | - use: buildx
67 | goos: linux
68 | goarch: "386"
69 | goarm: ""
70 | image_templates:
71 | - "screego/server:386-unstable"
72 | - "screego/server:386-{{ .RawVersion }}"
73 | - "screego/server:386-{{ .Major }}"
74 | - "ghcr.io/screego/server:386-unstable"
75 | - "ghcr.io/screego/server:386-{{ .RawVersion }}"
76 | - "ghcr.io/screego/server:386-{{ .Major }}"
77 | dockerfile: Dockerfile
78 | build_flag_templates:
79 | - "--platform=linux/386"
80 | - "--label=org.opencontainers.image.created={{.Date}}"
81 | - "--label=org.opencontainers.image.title={{.ProjectName}}"
82 | - "--label=org.opencontainers.image.revision={{.FullCommit}}"
83 | - "--label=org.opencontainers.image.version={{.Version}}"
84 | - use: buildx
85 | goos: linux
86 | goarch: arm64
87 | goarm: ""
88 | image_templates:
89 | - "screego/server:arm64-unstable"
90 | - "screego/server:arm64-{{ .RawVersion }}"
91 | - "screego/server:arm64-{{ .Major }}"
92 | - "ghcr.io/screego/server:arm64-unstable"
93 | - "ghcr.io/screego/server:arm64-{{ .RawVersion }}"
94 | - "ghcr.io/screego/server:arm64-{{ .Major }}"
95 | dockerfile: Dockerfile
96 | build_flag_templates:
97 | - "--platform=linux/arm64"
98 | - "--label=org.opencontainers.image.created={{.Date}}"
99 | - "--label=org.opencontainers.image.title={{.ProjectName}}"
100 | - "--label=org.opencontainers.image.revision={{.FullCommit}}"
101 | - "--label=org.opencontainers.image.version={{.Version}}"
102 | - use: buildx
103 | goos: linux
104 | goarch: arm
105 | goarm: 7
106 | image_templates:
107 | - "screego/server:armv7-unstable"
108 | - "screego/server:armv7-{{ .RawVersion }}"
109 | - "screego/server:armv7-{{ .Major }}"
110 | - "ghcr.io/screego/server:armv7-unstable"
111 | - "ghcr.io/screego/server:armv7-{{ .RawVersion }}"
112 | - "ghcr.io/screego/server:armv7-{{ .Major }}"
113 | dockerfile: Dockerfile
114 | build_flag_templates:
115 | - "--platform=linux/arm/v7"
116 | - "--label=org.opencontainers.image.created={{.Date}}"
117 | - "--label=org.opencontainers.image.title={{.ProjectName}}"
118 | - "--label=org.opencontainers.image.revision={{.FullCommit}}"
119 | - "--label=org.opencontainers.image.version={{.Version}}"
120 | - use: buildx
121 | goos: linux
122 | goarch: arm
123 | goarm: 6
124 | image_templates:
125 | - "screego/server:armv6-unstable"
126 | - "screego/server:armv6-{{ .RawVersion }}"
127 | - "screego/server:armv6-{{ .Major }}"
128 | - "ghcr.io/screego/server:armv6-unstable"
129 | - "ghcr.io/screego/server:armv6-{{ .RawVersion }}"
130 | - "ghcr.io/screego/server:armv6-{{ .Major }}"
131 | dockerfile: Dockerfile
132 | build_flag_templates:
133 | - "--platform=linux/arm/v6"
134 | - "--label=org.opencontainers.image.created={{.Date}}"
135 | - "--label=org.opencontainers.image.title={{.ProjectName}}"
136 | - "--label=org.opencontainers.image.revision={{.FullCommit}}"
137 | - "--label=org.opencontainers.image.version={{.Version}}"
138 | - use: buildx
139 | goos: linux
140 | goarch: ppc64le
141 | goarm: ""
142 | image_templates:
143 | - "screego/server:ppc64le-unstable"
144 | - "screego/server:ppc64le-{{ .RawVersion }}"
145 | - "screego/server:ppc64le-{{ .Major }}"
146 | - "ghcr.io/screego/server:ppc64le-unstable"
147 | - "ghcr.io/screego/server:ppc64le-{{ .RawVersion }}"
148 | - "ghcr.io/screego/server:ppc64le-{{ .Major }}"
149 | dockerfile: Dockerfile
150 | build_flag_templates:
151 | - "--platform=linux/ppc64le"
152 | - "--label=org.opencontainers.image.created={{.Date}}"
153 | - "--label=org.opencontainers.image.title={{.ProjectName}}"
154 | - "--label=org.opencontainers.image.revision={{.FullCommit}}"
155 | - "--label=org.opencontainers.image.version={{.Version}}"
156 | docker_manifests:
157 | - name_template: "ghcr.io/screego/server:unstable"
158 | image_templates:
159 | - "ghcr.io/screego/server:amd64-unstable"
160 | - "ghcr.io/screego/server:386-unstable"
161 | - "ghcr.io/screego/server:arm64-unstable"
162 | - "ghcr.io/screego/server:armv7-unstable"
163 | - "ghcr.io/screego/server:armv6-unstable"
164 | - "ghcr.io/screego/server:ppc64le-unstable"
165 | - name_template: "screego/server:unstable"
166 | image_templates:
167 | - "screego/server:amd64-unstable"
168 | - "screego/server:386-unstable"
169 | - "screego/server:arm64-unstable"
170 | - "screego/server:armv7-unstable"
171 | - "screego/server:armv6-unstable"
172 | - "screego/server:ppc64le-unstable"
173 | - name_template: "screego/server:{{ .RawVersion }}"
174 | image_templates:
175 | - "screego/server:amd64-{{ .RawVersion }}"
176 | - "screego/server:386-{{ .RawVersion }}"
177 | - "screego/server:arm64-{{ .RawVersion }}"
178 | - "screego/server:armv7-{{ .RawVersion }}"
179 | - "screego/server:armv6-{{ .RawVersion }}"
180 | - "screego/server:ppc64le-{{ .RawVersion }}"
181 | - name_template: "ghcr.io/screego/server:{{ .RawVersion }}"
182 | image_templates:
183 | - "ghcr.io/screego/server:amd64-{{ .RawVersion }}"
184 | - "ghcr.io/screego/server:386-{{ .RawVersion }}"
185 | - "ghcr.io/screego/server:arm64-{{ .RawVersion }}"
186 | - "ghcr.io/screego/server:armv7-{{ .RawVersion }}"
187 | - "ghcr.io/screego/server:armv6-{{ .RawVersion }}"
188 | - "ghcr.io/screego/server:ppc64le-{{ .RawVersion }}"
189 | - name_template: "screego/server:{{ .Major }}"
190 | image_templates:
191 | - "screego/server:amd64-{{ .Major }}"
192 | - "screego/server:386-{{ .Major }}"
193 | - "screego/server:arm64-{{ .Major }}"
194 | - "screego/server:armv7-{{ .Major }}"
195 | - "screego/server:armv6-{{ .Major }}"
196 | - "screego/server:ppc64le-{{ .Major }}"
197 | - name_template: "ghcr.io/screego/server:{{ .Major }}"
198 | image_templates:
199 | - "ghcr.io/screego/server:amd64-{{ .Major }}"
200 | - "ghcr.io/screego/server:386-{{ .Major }}"
201 | - "ghcr.io/screego/server:arm64-{{ .Major }}"
202 | - "ghcr.io/screego/server:armv7-{{ .Major }}"
203 | - "ghcr.io/screego/server:armv6-{{ .Major }}"
204 | - "ghcr.io/screego/server:ppc64le-{{ .Major }}"
205 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM scratch
2 | USER 1001
3 | COPY screego /screego
4 | EXPOSE 3478/tcp
5 | EXPOSE 3478/udp
6 | EXPOSE 5050
7 | WORKDIR "/"
8 | ENTRYPOINT [ "/screego" ]
9 | CMD ["serve"]
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | screego/server
9 | screen sharing for developers
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | ## Intro
30 |
31 | In the past I've had some problems sharing my screen with coworkers using
32 | corporate chatting solutions like Microsoft Teams. I wanted to show them some
33 | of my code, but either the stream lagged several seconds behind or the quality
34 | was so poor that my colleagues couldn't read the code. Or both.
35 |
36 | That's why I created screego. It allows you to share your screen with good
37 | quality and low latency. Screego is an addition to existing software and
38 | only helps to share your screen. Nothing else (:.
39 |
40 | ## Features
41 |
42 | * Multi User Screenshare
43 | * Secure transfer via WebRTC
44 | * Low latency / High resolution
45 | * Simple Install via Docker / single binary
46 | * Integrated TURN Server see [NAT Traversal](https://screego.net/#/nat-traversal)
47 |
48 | [Demo / Public Instance](https://app.screego.net/) ᛫ [Installation](https://screego.net/#/install) ᛫ [Configuration](https://screego.net/#/config)
49 |
50 | ## Versioning
51 |
52 | We use [SemVer](http://semver.org/) for versioning. For the versions available, see the
53 | [tags on this repository](https://github.com/screego/server/tags).
54 |
--------------------------------------------------------------------------------
/auth/auth.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "encoding/csv"
5 | "encoding/json"
6 | "errors"
7 | "io"
8 | "net/http"
9 | "os"
10 |
11 | "github.com/gorilla/sessions"
12 | "github.com/rs/zerolog/log"
13 | "golang.org/x/crypto/bcrypt"
14 | )
15 |
16 | type Users struct {
17 | Lookup map[string]string
18 | store sessions.Store
19 | sessionTimeout int
20 | }
21 |
22 | type UserPW struct {
23 | Name string
24 | Pass string
25 | }
26 |
27 | func read(r io.Reader) ([]UserPW, error) {
28 | reader := csv.NewReader(r)
29 | reader.Comma = ':'
30 | reader.Comment = '#'
31 | reader.TrimLeadingSpace = true
32 |
33 | records, err := reader.ReadAll()
34 | if err != nil {
35 | return nil, err
36 | }
37 |
38 | result := []UserPW{}
39 | for _, record := range records {
40 | if len(record) != 2 {
41 | return nil, errors.New("malformed users file")
42 | }
43 | result = append(result, UserPW{Name: record[0], Pass: record[1]})
44 | }
45 | return result, nil
46 | }
47 |
48 | func ReadPasswordsFile(path string, secret []byte, sessionTimeout int) (*Users, error) {
49 | users := &Users{
50 | Lookup: map[string]string{},
51 | sessionTimeout: sessionTimeout,
52 | store: sessions.NewCookieStore(secret),
53 | }
54 | if path == "" {
55 | log.Info().Msg("Users file not specified")
56 | return users, nil
57 | }
58 |
59 | file, err := os.Open(path)
60 | if err != nil {
61 | return users, err
62 | }
63 | defer file.Close()
64 | userPws, err := read(file)
65 | if err != nil {
66 | return users, err
67 | }
68 |
69 | for _, record := range userPws {
70 | users.Lookup[record.Name] = record.Pass
71 | }
72 | log.Info().Int("amount", len(users.Lookup)).Msg("Loaded Users")
73 | return users, nil
74 | }
75 |
76 | type Response struct {
77 | Message string `json:"message"`
78 | }
79 |
80 | func (u *Users) CurrentUser(r *http.Request) (string, bool) {
81 | s, _ := u.store.Get(r, "user")
82 | user, ok := s.Values["user"].(string)
83 | if !ok {
84 | return "guest", ok
85 | }
86 | return user, ok
87 | }
88 |
89 | func (u *Users) Logout(w http.ResponseWriter, r *http.Request) {
90 | session := sessions.NewSession(u.store, "user")
91 | session.IsNew = true
92 | if err := u.store.Save(r, w, session); err != nil {
93 | w.WriteHeader(500)
94 | _ = json.NewEncoder(w).Encode(&Response{
95 | Message: err.Error(),
96 | })
97 | return
98 | }
99 | w.WriteHeader(200)
100 | }
101 |
102 | func (u *Users) Authenticate(w http.ResponseWriter, r *http.Request) {
103 | user := r.FormValue("user")
104 | pass := r.FormValue("pass")
105 |
106 | if !u.Validate(user, pass) {
107 | w.WriteHeader(401)
108 | _ = json.NewEncoder(w).Encode(&Response{
109 | Message: "could not authenticate",
110 | })
111 | return
112 | }
113 |
114 | session := sessions.NewSession(u.store, "user")
115 | session.IsNew = true
116 | session.Options.MaxAge = u.sessionTimeout
117 | session.Values["user"] = user
118 | if err := u.store.Save(r, w, session); err != nil {
119 | w.WriteHeader(500)
120 | _ = json.NewEncoder(w).Encode(&Response{
121 | Message: err.Error(),
122 | })
123 | return
124 | }
125 | w.WriteHeader(200)
126 | _ = json.NewEncoder(w).Encode(&Response{
127 | Message: "authenticated",
128 | })
129 | }
130 |
131 | func (u Users) Validate(user, password string) bool {
132 | realPassword, exists := u.Lookup[user]
133 | return exists && bcrypt.CompareHashAndPassword([]byte(realPassword), []byte(password)) == nil
134 | }
135 |
--------------------------------------------------------------------------------
/cmd/command.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "github.com/rs/zerolog/log"
8 | "github.com/urfave/cli"
9 | )
10 |
11 | func Run(version, commitHash string) {
12 | app := cli.App{
13 | Name: "screego",
14 | Version: fmt.Sprintf("%s; screego/server@%s", version, commitHash),
15 | Commands: []cli.Command{
16 | serveCmd(version),
17 | hashCmd,
18 | },
19 | }
20 | err := app.Run(os.Args)
21 | if err != nil {
22 | log.Fatal().Err(err).Msg("app error")
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/cmd/hash.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "syscall"
7 |
8 | "github.com/rs/zerolog"
9 | "github.com/rs/zerolog/log"
10 | "github.com/screego/server/logger"
11 | "github.com/urfave/cli"
12 | "golang.org/x/crypto/bcrypt"
13 | "golang.org/x/term"
14 | )
15 |
16 | var hashCmd = cli.Command{
17 | Name: "hash",
18 | Flags: []cli.Flag{
19 | &cli.StringFlag{Name: "name"},
20 | &cli.StringFlag{Name: "pass"},
21 | },
22 | Action: func(ctx *cli.Context) {
23 | logger.Init(zerolog.ErrorLevel)
24 | name := ctx.String("name")
25 | pass := []byte(ctx.String("pass"))
26 | if name == "" {
27 | log.Fatal().Msg("--name must be set")
28 | }
29 |
30 | if len(pass) == 0 {
31 | var err error
32 | _, _ = fmt.Fprint(os.Stderr, "Enter Password: ")
33 | pass, err = term.ReadPassword(int(syscall.Stdin))
34 | if err != nil {
35 | log.Fatal().Err(err).Msg("could not read stdin")
36 | }
37 | _, _ = fmt.Fprintln(os.Stderr, "")
38 | }
39 | hashedPw, err := bcrypt.GenerateFromPassword(pass, 12)
40 | if err != nil {
41 | log.Fatal().Err(err).Msg("could not generate password")
42 | }
43 |
44 | fmt.Printf("%s:%s", name, string(hashedPw))
45 | fmt.Println("")
46 | },
47 | }
48 |
--------------------------------------------------------------------------------
/cmd/serve.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "os"
5 |
6 | "github.com/rs/zerolog"
7 | "github.com/rs/zerolog/log"
8 | "github.com/screego/server/auth"
9 | "github.com/screego/server/config"
10 | "github.com/screego/server/logger"
11 | "github.com/screego/server/router"
12 | "github.com/screego/server/server"
13 | "github.com/screego/server/turn"
14 | "github.com/screego/server/ws"
15 | "github.com/urfave/cli"
16 | )
17 |
18 | func serveCmd(version string) cli.Command {
19 | return cli.Command{
20 | Name: "serve",
21 | Action: func(ctx *cli.Context) {
22 | conf, errs := config.Get()
23 | logger.Init(conf.LogLevel.AsZeroLogLevel())
24 |
25 | exit := false
26 | for _, err := range errs {
27 | log.WithLevel(err.Level).Msg(err.Msg)
28 | exit = exit || err.Level == zerolog.FatalLevel || err.Level == zerolog.PanicLevel
29 | }
30 | if exit {
31 | os.Exit(1)
32 | }
33 |
34 | if _, _, err := conf.TurnIPProvider.Get(); err != nil {
35 | // error is already logged by .Get()
36 | os.Exit(1)
37 | }
38 |
39 | users, err := auth.ReadPasswordsFile(conf.UsersFile, conf.Secret, conf.SessionTimeoutSeconds)
40 | if err != nil {
41 | log.Fatal().Str("file", conf.UsersFile).Err(err).Msg("While loading users file")
42 | }
43 |
44 | tServer, err := turn.Start(conf)
45 | if err != nil {
46 | log.Fatal().Err(err).Msg("could not start turn server")
47 | }
48 |
49 | rooms := ws.NewRooms(tServer, users, conf)
50 |
51 | go rooms.Start()
52 |
53 | r := router.Router(conf, rooms, users, version)
54 | if err := server.Start(r, conf.ServerAddress, conf.TLSCertFile, conf.TLSKeyFile); err != nil {
55 | log.Fatal().Err(err).Msg("http server")
56 | }
57 | },
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "crypto/rand"
5 | "errors"
6 | "fmt"
7 | "net"
8 | "os"
9 | "path/filepath"
10 | "regexp"
11 | "strconv"
12 | "strings"
13 |
14 | "github.com/joho/godotenv"
15 | "github.com/kelseyhightower/envconfig"
16 | "github.com/rs/zerolog"
17 | "github.com/screego/server/config/ipdns"
18 | "github.com/screego/server/config/mode"
19 | )
20 |
21 | var (
22 | prefix = "screego"
23 | files = []string{"screego.config.development.local", "screego.config.development", "screego.config.local", "screego.config"}
24 | absoluteFiles = []string{"/etc/screego/server.config"}
25 | osExecutable = os.Executable
26 | osStat = os.Stat
27 | )
28 |
29 | const (
30 | AuthModeTurn = "turn"
31 | AuthModeAll = "all"
32 | AuthModeNone = "none"
33 | )
34 |
35 | // Config represents the application configuration.
36 | type Config struct {
37 | LogLevel LogLevel `default:"info" split_words:"true"`
38 |
39 | ExternalIP []string `split_words:"true"`
40 |
41 | TLSCertFile string `split_words:"true"`
42 | TLSKeyFile string `split_words:"true"`
43 |
44 | ServerTLS bool `split_words:"true"`
45 | ServerAddress string `default:":5050" split_words:"true"`
46 | Secret []byte `split_words:"true"`
47 | SessionTimeoutSeconds int `default:"0" split_words:"true"`
48 |
49 | TurnAddress string `default:":3478" required:"true" split_words:"true"`
50 | TurnPortRange string `split_words:"true"`
51 |
52 | TurnExternalIP []string `split_words:"true"`
53 | TurnExternalPort string `default:"3478" split_words:"true"`
54 | TurnExternalSecret string `split_words:"true"`
55 |
56 | TrustProxyHeaders bool `split_words:"true"`
57 | AuthMode string `default:"turn" split_words:"true"`
58 | CorsAllowedOrigins []string `split_words:"true"`
59 | UsersFile string `split_words:"true"`
60 | Prometheus bool `split_words:"true"`
61 |
62 | CheckOrigin func(string) bool `ignored:"true" json:"-"`
63 | TurnExternal bool `ignored:"true"`
64 | TurnIPProvider ipdns.Provider `ignored:"true"`
65 | TurnPort string `ignored:"true"`
66 |
67 | TurnDenyPeers []string `default:"0.0.0.0/8,127.0.0.1/8,::/128,::1/128,fe80::/10" split_words:"true"`
68 | TurnDenyPeersParsed []*net.IPNet `ignored:"true"`
69 |
70 | CloseRoomWhenOwnerLeaves bool `default:"true" split_words:"true"`
71 | }
72 |
73 | func (c Config) parsePortRange() (uint16, uint16, error) {
74 | if c.TurnPortRange == "" {
75 | return 0, 0, nil
76 | }
77 |
78 | parts := strings.Split(c.TurnPortRange, ":")
79 | if len(parts) != 2 {
80 | return 0, 0, errors.New("must include one colon")
81 | }
82 | stringMin := parts[0]
83 | stringMax := parts[1]
84 | min64, err := strconv.ParseUint(stringMin, 10, 16)
85 | if err != nil {
86 | return 0, 0, fmt.Errorf("invalid min: %s", err)
87 | }
88 | max64, err := strconv.ParseUint(stringMax, 10, 16)
89 | if err != nil {
90 | return 0, 0, fmt.Errorf("invalid max: %s", err)
91 | }
92 |
93 | return uint16(min64), uint16(max64), nil
94 | }
95 |
96 | func (c Config) PortRange() (uint16, uint16, bool) {
97 | min, max, _ := c.parsePortRange()
98 | return min, max, min != 0 && max != 0
99 | }
100 |
101 | // Get loads the application config.
102 | func Get() (Config, []FutureLog) {
103 | var logs []FutureLog
104 | dir, log := getExecutableOrWorkDir()
105 | if log != nil {
106 | logs = append(logs, *log)
107 | }
108 |
109 | for _, file := range getFiles(dir) {
110 | _, fileErr := osStat(file)
111 | if fileErr == nil {
112 | if err := godotenv.Load(file); err != nil {
113 | logs = append(logs, futureFatal(fmt.Sprintf("cannot load file %s: %s", file, err)))
114 | } else {
115 | logs = append(logs, FutureLog{
116 | Level: zerolog.DebugLevel,
117 | Msg: fmt.Sprintf("Loading file %s", file),
118 | })
119 | }
120 | } else if os.IsNotExist(fileErr) {
121 | continue
122 | } else {
123 | logs = append(logs, FutureLog{
124 | Level: zerolog.WarnLevel,
125 | Msg: fmt.Sprintf("cannot read file %s because %s", file, fileErr),
126 | })
127 | }
128 | }
129 |
130 | config := Config{}
131 | err := envconfig.Process(prefix, &config)
132 | if err != nil {
133 | logs = append(logs,
134 | futureFatal(fmt.Sprintf("cannot parse env params: %s", err)))
135 | }
136 |
137 | if config.AuthMode != AuthModeTurn && config.AuthMode != AuthModeAll && config.AuthMode != AuthModeNone {
138 | logs = append(logs,
139 | futureFatal(fmt.Sprintf("invalid SCREEGO_AUTH_MODE: %s", config.AuthMode)))
140 | }
141 |
142 | if config.ServerTLS {
143 | if config.TLSCertFile == "" {
144 | logs = append(logs, futureFatal("SCREEGO_TLS_CERT_FILE must be set if TLS is enabled"))
145 | }
146 |
147 | if config.TLSKeyFile == "" {
148 | logs = append(logs, futureFatal("SCREEGO_TLS_KEY_FILE must be set if TLS is enabled"))
149 | }
150 | }
151 |
152 | var compiledAllowedOrigins []*regexp.Regexp
153 | for _, origin := range config.CorsAllowedOrigins {
154 | compiled, err := regexp.Compile(origin)
155 | if err != nil {
156 | logs = append(logs, futureFatal(fmt.Sprintf("invalid regex: %s", err)))
157 | }
158 | compiledAllowedOrigins = append(compiledAllowedOrigins, compiled)
159 | }
160 |
161 | config.CheckOrigin = func(origin string) bool {
162 | if origin == "" {
163 | return true
164 | }
165 | for _, compiledOrigin := range compiledAllowedOrigins {
166 | if compiledOrigin.Match([]byte(strings.ToLower(origin))) {
167 | return true
168 | }
169 | }
170 | return false
171 | }
172 |
173 | if len(config.Secret) == 0 {
174 | config.Secret = make([]byte, 32)
175 | if _, err := rand.Read(config.Secret); err == nil {
176 | logs = append(logs, FutureLog{
177 | Level: zerolog.InfoLevel,
178 | Msg: "SCREEGO_SECRET unset, user logins will be invalidated on restart",
179 | })
180 | } else {
181 | logs = append(logs, futureFatal(fmt.Sprintf("cannot create secret %s", err)))
182 | }
183 | }
184 |
185 | var errs []FutureLog
186 |
187 | if len(config.TurnExternalIP) > 0 {
188 | if len(config.ExternalIP) > 0 {
189 | logs = append(logs, futureFatal("SCREEGO_EXTERNAL_IP and SCREEGO_TURN_EXTERNAL_IP must not be both set"))
190 | }
191 |
192 | config.TurnIPProvider, errs = parseIPProvider(config.TurnExternalIP, "SCREEGO_TURN_EXTERNAL_IP")
193 | config.TurnPort = config.TurnExternalPort
194 | config.TurnExternal = true
195 | logs = append(logs, errs...)
196 | if config.TurnExternalSecret == "" {
197 | logs = append(logs, futureFatal("SCREEGO_TURN_EXTERNAL_SECRET must be set if external TURN server is used"))
198 | }
199 | } else if len(config.ExternalIP) > 0 {
200 | config.TurnIPProvider, errs = parseIPProvider(config.ExternalIP, "SCREEGO_EXTERNAL_IP")
201 | logs = append(logs, errs...)
202 | split := strings.Split(config.TurnAddress, ":")
203 | config.TurnPort = split[len(split)-1]
204 | } else {
205 | logs = append(logs, futureFatal("SCREEGO_EXTERNAL_IP or SCREEGO_TURN_EXTERNAL_IP must be set"))
206 | }
207 |
208 | min, max, err := config.parsePortRange()
209 | if err != nil {
210 | logs = append(logs, futureFatal(fmt.Sprintf("invalid SCREEGO_TURN_PORT_RANGE: %s", err)))
211 | } else if min == 0 && max == 0 {
212 | // valid; no port range
213 | } else if min == 0 || max == 0 {
214 | logs = append(logs, futureFatal("invalid SCREEGO_TURN_PORT_RANGE: min or max port is 0"))
215 | } else if min > max {
216 | logs = append(logs, futureFatal(fmt.Sprintf("invalid SCREEGO_TURN_PORT_RANGE: min port (%d) is higher than max port (%d)", min, max)))
217 | } else if (max - min) < 40 {
218 | logs = append(logs, FutureLog{
219 | Level: zerolog.WarnLevel,
220 | Msg: "Less than 40 ports are available for turn. When using multiple TURN connections this may not be enough",
221 | })
222 | }
223 | logs = append(logs, logDeprecated()...)
224 |
225 | for _, cidrString := range config.TurnDenyPeers {
226 | _, cidr, err := net.ParseCIDR(cidrString)
227 | if err != nil {
228 | logs = append(logs, FutureLog{
229 | Level: zerolog.FatalLevel,
230 | Msg: fmt.Sprintf("Invalid SCREEGO_TURN_DENY_PEERS %q: %s", cidrString, err),
231 | })
232 | } else {
233 | config.TurnDenyPeersParsed = append(config.TurnDenyPeersParsed, cidr)
234 | }
235 | }
236 | logs = append(logs, FutureLog{
237 | Level: zerolog.InfoLevel,
238 | Msg: fmt.Sprintf("Deny turn peers within %q", config.TurnDenyPeersParsed),
239 | })
240 |
241 | return config, logs
242 | }
243 |
244 | func logDeprecated() []FutureLog {
245 | if os.Getenv("SCREEGO_TURN_STRICT_AUTH") != "" {
246 | return []FutureLog{{Level: zerolog.WarnLevel, Msg: "The setting SCREEGO_TURN_STRICT_AUTH has been removed."}}
247 | }
248 | return nil
249 | }
250 |
251 | func getExecutableOrWorkDir() (string, *FutureLog) {
252 | dir, err := getExecutableDir()
253 | // when using `go run main.go` the executable lives in th temp directory therefore the env.development
254 | // will not be read, this enforces that the current work directory is used in dev mode.
255 | if err != nil || mode.Get() == mode.Dev {
256 | return filepath.Dir("."), err
257 | }
258 | return dir, nil
259 | }
260 |
261 | func getExecutableDir() (string, *FutureLog) {
262 | ex, err := osExecutable()
263 | if err != nil {
264 | return "", &FutureLog{
265 | Level: zerolog.ErrorLevel,
266 | Msg: "Could not get path of executable using working directory instead. " + err.Error(),
267 | }
268 | }
269 | return filepath.Dir(ex), nil
270 | }
271 |
272 | func getFiles(relativeTo string) []string {
273 | var result []string
274 | for _, file := range files {
275 | result = append(result, filepath.Join(relativeTo, file))
276 | }
277 | homeDir, err := os.UserHomeDir()
278 | if err == nil {
279 | result = append(result, filepath.Join(homeDir, ".config/screego/server.config"))
280 | }
281 | result = append(result, absoluteFiles...)
282 | return result
283 | }
284 |
--------------------------------------------------------------------------------
/config/error.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import "github.com/rs/zerolog"
4 |
5 | // FutureLog is an intermediate type for log messages. It is used before the config was loaded because without loaded
6 | // config we do not know the log level, so we log these messages once the config was initialized.
7 | type FutureLog struct {
8 | Level zerolog.Level
9 | Msg string
10 | }
11 |
12 | func futureFatal(msg string) FutureLog {
13 | return FutureLog{
14 | Level: zerolog.FatalLevel,
15 | Msg: msg,
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/config/ip.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "net"
7 | "strings"
8 | "time"
9 |
10 | "github.com/screego/server/config/ipdns"
11 | )
12 |
13 | func parseIPProvider(ips []string, config string) (ipdns.Provider, []FutureLog) {
14 | if len(ips) == 0 {
15 | panic("must have at least one ip")
16 | }
17 |
18 | first := ips[0]
19 | if strings.HasPrefix(first, "dns:") {
20 | if len(ips) > 1 {
21 | return nil, []FutureLog{futureFatal(fmt.Sprintf("invalid %s: when dns server is specified, only one value is allowed", config))}
22 | }
23 |
24 | return parseDNS(strings.TrimPrefix(first, "dns:")), nil
25 | }
26 |
27 | return parseStatic(ips, config)
28 | }
29 |
30 | func parseStatic(ips []string, config string) (*ipdns.Static, []FutureLog) {
31 | var static ipdns.Static
32 |
33 | firstV4, errs := applyIPTo(config, ips[0], &static)
34 | if errs != nil {
35 | return nil, errs
36 | }
37 |
38 | if len(ips) == 1 {
39 | return &static, nil
40 | }
41 |
42 | secondV4, errs := applyIPTo(config, ips[1], &static)
43 | if errs != nil {
44 | return nil, errs
45 | }
46 |
47 | if firstV4 == secondV4 {
48 | return nil, []FutureLog{futureFatal(fmt.Sprintf("invalid %s: the ips must be of different type ipv4/ipv6", config))}
49 | }
50 |
51 | if len(ips) > 2 {
52 | return nil, []FutureLog{futureFatal(fmt.Sprintf("invalid %s: too many ips supplied", config))}
53 | }
54 |
55 | return &static, nil
56 | }
57 |
58 | func applyIPTo(config, ip string, static *ipdns.Static) (bool, []FutureLog) {
59 | parsed := net.ParseIP(ip)
60 | if parsed == nil || ip == "0.0.0.0" {
61 | return false, []FutureLog{futureFatal(fmt.Sprintf("invalid %s: %s", config, ip))}
62 | }
63 |
64 | v4 := parsed.To4() != nil
65 | if v4 {
66 | static.V4 = parsed
67 | } else {
68 | static.V6 = parsed
69 | }
70 | return v4, nil
71 | }
72 |
73 | func parseDNS(dnsString string) *ipdns.DNS {
74 | var dns ipdns.DNS
75 |
76 | parts := strings.SplitN(dnsString, "@", 2)
77 |
78 | dns.Domain = parts[0]
79 | dns.DNS = "system"
80 | if len(parts) == 2 {
81 | dns.DNS = parts[1]
82 | dns.Resolver = &net.Resolver{
83 | PreferGo: true,
84 | Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
85 | d := net.Dialer{Timeout: 10 * time.Second}
86 | return d.DialContext(ctx, network, parts[1])
87 | },
88 | }
89 | }
90 |
91 | return &dns
92 | }
93 |
--------------------------------------------------------------------------------
/config/ipdns/dns.go:
--------------------------------------------------------------------------------
1 | package ipdns
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "net"
7 | "strings"
8 | "sync"
9 | "time"
10 |
11 | "github.com/rs/zerolog/log"
12 | )
13 |
14 | type DNS struct {
15 | sync.Mutex
16 |
17 | DNS string
18 | Resolver *net.Resolver
19 | Domain string
20 |
21 | refetch time.Time
22 | v4 net.IP
23 | v6 net.IP
24 | err error
25 | }
26 |
27 | func (s *DNS) Get() (net.IP, net.IP, error) {
28 | s.Lock()
29 | defer s.Unlock()
30 |
31 | if s.refetch.Before(time.Now()) {
32 | oldV4, oldV6 := s.v4, s.v6
33 | s.v4, s.v6, s.err = s.lookup()
34 | if s.err == nil {
35 | if !oldV4.Equal(s.v4) || !oldV6.Equal(s.v6) {
36 | log.Info().Str("v4", s.v4.String()).
37 | Str("v6", s.v6.String()).
38 | Str("domain", s.Domain).
39 | Str("dns", s.DNS).
40 | Msg("DNS External IP")
41 | }
42 | s.refetch = time.Now().Add(time.Minute)
43 | } else {
44 | // don't spam the dns server
45 | s.refetch = time.Now().Add(time.Second)
46 | log.Err(s.err).Str("domain", s.Domain).Str("dns", s.DNS).Msg("DNS External IP")
47 | }
48 | }
49 |
50 | return s.v4, s.v6, s.err
51 | }
52 |
53 | func (s *DNS) lookup() (net.IP, net.IP, error) {
54 | ips, err := s.Resolver.LookupIP(context.Background(), "ip", s.Domain)
55 | if err != nil {
56 | if dns, ok := err.(*net.DNSError); ok && s.DNS != "system" {
57 | dns.Server = ""
58 | }
59 | return nil, nil, err
60 | }
61 |
62 | var v4, v6 net.IP
63 | for _, ip := range ips {
64 | isV6 := strings.Contains(ip.String(), ":")
65 | if isV6 && v6 == nil {
66 | v6 = ip
67 | } else if !isV6 && v4 == nil {
68 | v4 = ip
69 | }
70 | }
71 |
72 | if v4 == nil && v6 == nil {
73 | return nil, nil, errors.New("dns record doesn't have an A or AAAA record")
74 | }
75 |
76 | return v4, v6, nil
77 | }
78 |
--------------------------------------------------------------------------------
/config/ipdns/provider.go:
--------------------------------------------------------------------------------
1 | package ipdns
2 |
3 | import "net"
4 |
5 | type Provider interface {
6 | Get() (net.IP, net.IP, error)
7 | }
8 |
--------------------------------------------------------------------------------
/config/ipdns/static.go:
--------------------------------------------------------------------------------
1 | package ipdns
2 |
3 | import "net"
4 |
5 | type Static struct {
6 | V4 net.IP
7 | V6 net.IP
8 | }
9 |
10 | func (s *Static) Get() (net.IP, net.IP, error) {
11 | return s.V4, s.V6, nil
12 | }
13 |
--------------------------------------------------------------------------------
/config/loglevel.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/rs/zerolog"
7 | )
8 |
9 | // LogLevel type that provides helper methods for decoding.
10 | type LogLevel zerolog.Level
11 |
12 | // Decode decodes a string to a log level.
13 | func (ll *LogLevel) Decode(value string) error {
14 | if level, err := zerolog.ParseLevel(value); err == nil {
15 | *ll = LogLevel(level)
16 | return nil
17 | }
18 | *ll = LogLevel(zerolog.InfoLevel)
19 | return errors.New("unknown log level")
20 | }
21 |
22 | // AsZeroLogLevel converts the LogLevel to a zerolog.Level.
23 | func (ll LogLevel) AsZeroLogLevel() zerolog.Level {
24 | return zerolog.Level(ll)
25 | }
26 |
--------------------------------------------------------------------------------
/config/loglevel_test.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/rs/zerolog"
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func TestLogLevel_Decode_success(t *testing.T) {
11 | ll := new(LogLevel)
12 | err := ll.Decode("fatal")
13 | assert.Nil(t, err)
14 | assert.Equal(t, ll.AsZeroLogLevel(), zerolog.FatalLevel)
15 | }
16 |
17 | func TestLogLevel_Decode_fail(t *testing.T) {
18 | ll := new(LogLevel)
19 | err := ll.Decode("asdasdasdasdasdasd")
20 | assert.EqualError(t, err, "unknown log level")
21 | assert.Equal(t, ll.AsZeroLogLevel(), zerolog.InfoLevel)
22 | }
23 |
--------------------------------------------------------------------------------
/config/mode/mode.go:
--------------------------------------------------------------------------------
1 | package mode
2 |
3 | const (
4 | // Dev for development mode.
5 | Dev = "dev"
6 | // Prod for production mode.
7 | Prod = "prod"
8 | )
9 |
10 | var mode = Dev
11 |
12 | // Set sets the new mode.
13 | func Set(newMode string) {
14 | mode = newMode
15 | }
16 |
17 | // Get returns the current mode.
18 | func Get() string {
19 | return mode
20 | }
21 |
--------------------------------------------------------------------------------
/config/mode/mode_test.go:
--------------------------------------------------------------------------------
1 | package mode
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 | )
8 |
9 | func TestGet(t *testing.T) {
10 | mode = Prod
11 | require.Equal(t, Prod, Get())
12 | }
13 |
14 | func TestSet(t *testing.T) {
15 | Set(Prod)
16 | require.Equal(t, Prod, mode)
17 | }
18 |
--------------------------------------------------------------------------------
/docs/.nojekyll:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/screego/server/acb8287706a582c0ed21b2d4a8846d1d15c1ab1e/docs/.nojekyll
--------------------------------------------------------------------------------
/docs/CNAME:
--------------------------------------------------------------------------------
1 | screego.net
2 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # screego/server
2 |
3 | In the past I've had some problems sharing my screen with coworkers using
4 | corporate chatting solutions like Microsoft Teams. I wanted to show them some
5 | of my code, but either the stream lagged several seconds behind or the quality
6 | was so poor that my colleagues couldn't read the code. Or both.
7 |
8 | That's why I created screego. It allows you to share your screen with good
9 | quality and low latency. Screego is an addition to existing software and
10 | only helps to share your screen. Nothing else (:.
11 |
12 |
13 | ## Features
14 |
15 | * Multi User Screenshare
16 | * Secure transfer via WebRTC
17 | * Low latency / High resolution
18 | * Simple Install via [Docker](https://hub.docker.com/r/screego/server) / single binary
19 | * Integrated [TURN](nat-traversal.md) Server see [NAT Traversal](nat-traversal.md)
20 |
21 | ---
22 |
23 | [Demo / Public Instance](https://app.screego.net/) ᛫ [Installation](https://screego.net/#/install) ᛫ [Configuration](https://screego.net/#/config)
24 |
--------------------------------------------------------------------------------
/docs/_sidebar.md:
--------------------------------------------------------------------------------
1 | * [Home](/)
2 | * [Installation](install.md)
3 | * [Config](config.md)
4 | * [NAT Traversal](nat-traversal.md)
5 | * [Reverse Proxy](proxy.md)
6 | * [Development](development.md)
7 | * [FAQ](faq.md)
8 | * [GitHub](https://github.com/screego/server)
9 |
--------------------------------------------------------------------------------
/docs/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/screego/server/acb8287706a582c0ed21b2d4a8846d1d15c1ab1e/docs/apple-touch-icon.png
--------------------------------------------------------------------------------
/docs/config.md:
--------------------------------------------------------------------------------
1 | # Config
2 |
3 | !> TLS is required for Screego to work. Either enable TLS inside Screego or
4 | use a reverse proxy to serve Screego via TLS.
5 |
6 | Screego tries to obtain config values from different locations in sequence.
7 | Properties will never be overridden. Thus, the first occurrence of a setting will be used.
8 |
9 | #### Order
10 |
11 | * Environment Variables
12 | * `screego.config.local` (in same path as the binary)
13 | * `screego.config` (in same path as the binary)
14 | * `$HOME/.config/screego/server.config`
15 | * `/etc/screego/server.config`
16 |
17 | #### Config Example
18 |
19 | [screego.config.example](https://raw.githubusercontent.com/screego/server/master/screego.config.example ':include :type=code ini')
20 |
--------------------------------------------------------------------------------
/docs/development.md:
--------------------------------------------------------------------------------
1 | # Development
2 |
3 | Screego requires:
4 |
5 | - Go 1.15+
6 | - Node 13.x
7 | - Yarn 9+
8 |
9 | ## Setup
10 |
11 | ### Clone Repository
12 |
13 | Clone screego/server source from git:
14 |
15 | ```bash
16 | $ git clone https://github.com/screego/server.git && cd server
17 | ```
18 |
19 | ### GOPATH
20 |
21 | If you are in GOPATH, enable [go modules](https://github.com/golang/go/wiki/Modules) explicitly:
22 |
23 | ```bash
24 | $ export GO111MODULE=on
25 | ```
26 |
27 | ### Download Dependencies:
28 |
29 | ```bash
30 | # Server
31 | $ go mod download
32 | # UI
33 | $ (cd ui && yarn install)
34 | ```
35 |
36 | ## Start / Linting
37 |
38 | ### Backend
39 |
40 | Create a file named `screego.config.development.local` inside the screego folder with the content:
41 |
42 | ```ini
43 | SCREEGO_EXTERNAL_IP=YOURIP
44 | ```
45 |
46 | and replace `YOURIP` with your external ip.
47 |
48 | Start the server in development mode.
49 |
50 | ```bash
51 | $ go run . serve
52 | ```
53 |
54 | The backend is available on [http://localhost:5050](http://localhost:5050)
55 |
56 | ?> When accessing `localhost:5050` it is normal that there are panics with `no such file or directory`.
57 | The UI will be started separately.
58 |
59 | ### Frontend
60 |
61 | Start the UI development server.
62 |
63 | _Commands must be executed inside the ui directory._
64 |
65 | ```bash
66 | $ yarn start
67 | ```
68 |
69 | Open [http://localhost:3000](http://localhost:3000) inside your favorite browser.
70 |
71 | ### Lint
72 |
73 | Screego uses [golangci-lint](https://github.com/golangci/golangci-lint) for linting.
74 |
75 | After installation you can check the source code with:
76 |
77 | ```bash
78 | $ golangci-lint run
79 | ```
80 |
81 | ## Build
82 |
83 | 1. [Setup](#setup)
84 |
85 | 1. Build the UI
86 |
87 | ```bash
88 | $ (cd ui && yarn build)
89 | ```
90 |
91 | 1. Build the binary
92 | ```bash
93 | go build -ldflags "-X main.version=$(git describe --tags HEAD) -X main.mode=prod" -o screego ./main.go
94 | ```
95 |
--------------------------------------------------------------------------------
/docs/faq.md:
--------------------------------------------------------------------------------
1 | # Frequently Asked Questions
2 |
3 | ## Stream doesn't load
4 |
5 | Check that
6 | * you are using https to access Screego.
7 | * `SCREEGO_EXTERNAL_IP` is set to your external IP. See [Configuration](config.md)
8 | * you are using TURN for NAT-Traversal. See [NAT-Traversal](nat-traversal.md). *This isn't allowed for app.screego.net, you've to self-host Screego*
9 | * your browser doesn't block WebRTC (extensions or other settings)
10 | * you have opened ports in your firewall. By default 5050, 3478 and any UDP port when using TURN.
11 |
12 | ## Automatically create room on join
13 |
14 | Sometimes you want to reuse the screego room, but always have to recreate it.
15 | By passing `create=true` in the url, you can automatically create the room if it does not exist.
16 |
17 | Example: https://app.screego.net/?room=not-existing-room&create=true
18 |
--------------------------------------------------------------------------------
/docs/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/screego/server/acb8287706a582c0ed21b2d4a8846d1d15c1ab1e/docs/favicon-16x16.png
--------------------------------------------------------------------------------
/docs/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/screego/server/acb8287706a582c0ed21b2d4a8846d1d15c1ab1e/docs/favicon-32x32.png
--------------------------------------------------------------------------------
/docs/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/screego/server/acb8287706a582c0ed21b2d4a8846d1d15c1ab1e/docs/favicon.ico
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Screego
6 |
7 |
8 |
10 |
11 |
12 |
13 |
14 |
15 |
20 |
21 |
22 |
23 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/docs/install.md:
--------------------------------------------------------------------------------
1 | # Installation
2 |
3 | Latest Version: **GITHUB_VERSION**
4 |
5 | Before starting Screego you may read [Configuration](config.md).
6 |
7 | !> TLS is required for Screego to work. Either enable TLS inside Screego or
8 | use a reverse proxy to serve Screego via TLS.
9 |
10 | ## Docker
11 |
12 | Setting up Screego with docker is pretty easy, you basically just have to start the docker container, and you are ready to go:
13 |
14 | [ghcr.io/screego/server](https://github.com/orgs/screego/packages/container/package/server) and
15 | [screego/server](https://hub.docker.com/r/screego/server)
16 | docker images are multi-arch docker images.
17 | This means the image will work for `amd64`, `i386`, `ppc64le` (power pc), `arm64`, `armv7` (Raspberry PI) and `armv6`.
18 |
19 | By default, Screego runs on port 5050.
20 |
21 | ?> Replace `EXTERNALIP` with your external IP. One way to find your external ip is with ipify.
22 | `curl 'https://api.ipify.org'`
23 |
24 | ```bash
25 | $ docker run --net=host -e SCREEGO_EXTERNAL_IP=EXTERNALIP ghcr.io/screego/server:GITHUB_VERSION
26 | ```
27 |
28 | **docker-compose.yml**
29 | ```yaml
30 | services:
31 | screego:
32 | image: ghcr.io/screego/server:GITHUB_VERSION
33 | network_mode: host
34 | environment:
35 | SCREEGO_EXTERNAL_IP: "EXTERNALIP"
36 | ```
37 |
38 | If you don't want to use the host network, then you can configure docker like this:
39 |
40 | (Click to expand)
41 |
42 |
43 | ```bash
44 | $ docker run -it \
45 | -e SCREEGO_EXTERNAL_IP=EXTERNALIP \
46 | -e SCREEGO_TURN_PORT_RANGE=50000:50200 \
47 | -p 5050:5050 \
48 | -p 3478:3478 \
49 | -p 50000-50200:50000-50200/udp \
50 | screego/server:GITHUB_VERSION
51 | ```
52 |
53 | #### docker-compose.yml
54 |
55 | ```yml
56 | version: "3.7"
57 | services:
58 | screego:
59 | image: ghcr.io/screego/server:GITHUB_VERSION
60 | ports:
61 | - 5050:5050
62 | - 3478:3478
63 | - 50000-50200:50000-50200/udp
64 | environment:
65 | SCREEGO_EXTERNAL_IP: "192.168.178.2"
66 | SCREEGO_TURN_PORT_RANGE: "50000:50200"
67 | ```
68 |
69 |
70 |
71 |
72 | ## Binary
73 |
74 | ### Supported Platforms:
75 |
76 | - linux_amd64 (64bit)
77 | - linux_i386 (32bit)
78 | - armv7 (32bit used for Raspberry Pi)
79 | - armv6
80 | - arm64 (ARMv8)
81 | - ppc64
82 | - ppc64le
83 | - windows_i386.exe (32bit)
84 | - windows_amd64.exe (64bit)
85 |
86 | Download the zip with the binary for your platform from [screego/server Releases](https://github.com/screego/server/releases).
87 |
88 | ```bash
89 | $ wget https://github.com/screego/server/releases/download/vGITHUB_VERSION/screego_GITHUB_VERSION_{PLATFORM}.tar.gz
90 | ```
91 |
92 | Unzip the archive.
93 |
94 | ```bash
95 | $ tar xvf screego_GITHUB_VERSION_{PLATFORM}.tar.gz
96 | ```
97 |
98 | Make the binary executable (linux only).
99 |
100 | ```bash
101 | $ chmod +x screego
102 | ```
103 |
104 | Execute screego:
105 |
106 | ```bash
107 | $ ./screego
108 | # on windows
109 | $ screego.exe
110 | ```
111 |
112 | ## Arch-Linux(aur)
113 |
114 | !> Maintenance of the AUR Packages is not performed by the Screego team.
115 | You should always check the PKGBUILD before installing an AUR package.
116 |
117 | Screego's latest release is available in the AUR as [screego-server](https://aur.archlinux.org/packages/screego-server/) and [screego-server-bin](https://aur.archlinux.org/packages/screego-server-bin/).
118 | The development-version can be installed with [screego-server-git](https://aur.archlinux.org/packages/screego-server-git/).
119 |
120 | ## FreeBSD
121 |
122 | !> Maintenance of the FreeBSD Package is not performed by the Screego team.
123 | Check yourself, if you can trust it.
124 |
125 | ```bash
126 | $ pkg install screego
127 | ```
128 |
129 | ## Source
130 |
131 | [See Development#build](development.md#build)
132 |
--------------------------------------------------------------------------------
/docs/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/screego/server/acb8287706a582c0ed21b2d4a8846d1d15c1ab1e/docs/logo.png
--------------------------------------------------------------------------------
/docs/nat-traversal.md:
--------------------------------------------------------------------------------
1 | # NAT Traversal
2 |
3 | In most cases peers cannot directly communicate with each other because of firewalls or other restrictions like NAT.
4 | To work around this issue, WebRTC uses
5 | [Interactive Connectivity Establishment (ICE)](http://en.wikipedia.org/wiki/Interactive_Connectivity_Establishment).
6 | This is a framework for helping to connect peers.
7 |
8 | ICE uses STUN and/or TURN servers to accomplish this.
9 |
10 | ?> Screego exposes a STUN and TURN server. You don't have to configure this separately.
11 | By default, user authentication is required for using TURN.
12 |
13 | ## STUN
14 |
15 | [Session Traversal Utilities for NAT (STUN)](http://en.wikipedia.org/wiki/STUN) is used to find
16 | the public / external ip of a peer. This IP is later sent to others to create a direct connection.
17 |
18 | When STUN is used, only the connection enstablishment will be done through Screego. The actual video stream will be
19 | directly sent to the other peer and doesn't go through Screego.
20 |
21 | While STUN should work for most cases, there are stricter NATs f.ex.
22 | [Symmetric NATs](https://en.wikipedia.org/wiki/Network_address_translation)
23 | where it doesn't, then, TURN will be used.
24 |
25 | ## TURN
26 |
27 | [Traversal Using Relays around NAT (TURN)](http://en.wikipedia.org/wiki/TURN) is used to work around Symmetric NATs.
28 | It does it by relaying all data through a TURN server. As relaying will create traffic on the server,
29 | Screego will require user authentication to use the TURN server. This can be configured see [Configuration](config.md).
30 |
31 |
--------------------------------------------------------------------------------
/docs/proxy.md:
--------------------------------------------------------------------------------
1 | # Proxy
2 |
3 | !> When using a proxy enable `SCREEGO_TRUST_PROXY_HEADERS`. See [Configuration](config.md).
4 |
5 | ## nginx
6 |
7 | ### At root path
8 |
9 | ```nginx
10 | upstream screego {
11 | # Set this to the address configured in
12 | # SCREEGO_SERVER_ADDRESS. Default 5050
13 | server 127.0.0.1:5050;
14 | }
15 |
16 | server {
17 | listen 80;
18 |
19 | # Here goes your domain / subdomain
20 | server_name screego.example.com;
21 |
22 | location / {
23 | # Proxy to screego
24 | proxy_pass http://screego;
25 | proxy_http_version 1.1;
26 |
27 | # Set headers for proxying WebSocket
28 | proxy_set_header Upgrade $http_upgrade;
29 | proxy_set_header Connection "upgrade";
30 | proxy_redirect http:// $scheme://;
31 |
32 | # Set proxy headers
33 | proxy_set_header X-Real-IP $remote_addr;
34 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
35 | proxy_set_header X-Forwarded-Proto http;
36 |
37 | # The proxy must preserve the host because screego verifies it with the origin
38 | # for WebSocket connections
39 | proxy_set_header Host $http_host;
40 | }
41 | }
42 | ```
43 |
44 | ### At a sub path
45 |
46 | ```nginx
47 | upstream screego {
48 | # Set this to the address configured in
49 | # SCREEGO_SERVER_ADDRESS. Default 5050
50 | server 127.0.0.1:5050;
51 | }
52 |
53 | server {
54 | listen 80;
55 |
56 | # Here goes your domain / subdomain
57 | server_name screego.example.com;
58 |
59 | location /screego/ {
60 | rewrite ^/screego(/.*) $1 break;
61 |
62 | # Proxy to screego
63 | proxy_pass http://screego;
64 | proxy_http_version 1.1;
65 |
66 | # Set headers for proxying WebSocket
67 | proxy_set_header Upgrade $http_upgrade;
68 | proxy_set_header Connection "upgrade";
69 | proxy_redirect http:// $scheme://;
70 |
71 | # Set proxy headers
72 | proxy_set_header X-Real-IP $remote_addr;
73 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
74 | proxy_set_header X-Forwarded-Proto http;
75 |
76 | # The proxy must preserve the host because screego verifies it with the origin
77 | # for WebSocket connections
78 | proxy_set_header Host $http_host;
79 | }
80 | }
81 | ```
82 |
83 | ## Apache (httpd)
84 |
85 | The following modules are required:
86 |
87 | * mod_proxy
88 | * mod_proxy_wstunnel
89 | * mod_proxy_http
90 |
91 | ### At root path
92 |
93 | ```apache
94 |
95 | ServerName screego.example.com
96 | Keepalive On
97 |
98 | # The proxy must preserve the host because screego verifies it with the origin
99 | # for WebSocket connections
100 | ProxyPreserveHost On
101 |
102 | # Replace 5050 with the port defined in SCREEGO_SERVER_ADDRESS.
103 | # Default 5050
104 |
105 | # Proxy web socket requests to /stream
106 | ProxyPass "/stream" ws://127.0.0.1:5050/stream retry=0 timeout=5
107 |
108 | # Proxy all other requests to /
109 | ProxyPass "/" http://127.0.0.1:5050/ retry=0 timeout=5
110 |
111 | ProxyPassReverse / http://127.0.0.1:5050/
112 |
113 | ```
114 |
115 | ### At a sub path
116 |
117 | ```apache
118 |
119 | ServerName screego.example.com
120 | Keepalive On
121 |
122 | Redirect 301 "/screego" "/screego/"
123 |
124 | # The proxy must preserve the host because screego verifies it with the origin
125 | # for WebSocket connections
126 | ProxyPreserveHost On
127 |
128 | # Proxy web socket requests to /stream
129 | ProxyPass "/screego/stream" ws://127.0.0.1:5050/stream retry=0 timeout=5
130 |
131 | # Proxy all other requests to /
132 | ProxyPass "/screego/" http://127.0.0.1:5050/ retry=0 timeout=5
133 | # ^- !!trailing slash is required!!
134 |
135 | ProxyPassReverse /screego/ http://127.0.0.1:5050/
136 |
137 | ```
138 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/screego/server
2 |
3 | go 1.23.0
4 |
5 | toolchain go1.24.1
6 |
7 | require (
8 | github.com/gorilla/handlers v1.5.2
9 | github.com/gorilla/mux v1.8.1
10 | github.com/gorilla/sessions v1.4.0
11 | github.com/gorilla/websocket v1.5.3
12 | github.com/joho/godotenv v1.5.1
13 | github.com/kelseyhightower/envconfig v1.4.0
14 | github.com/pion/randutil v0.1.0
15 | github.com/pion/turn/v4 v4.0.0
16 | github.com/prometheus/client_golang v1.22.0
17 | github.com/rs/xid v1.6.0
18 | github.com/rs/zerolog v1.34.0
19 | github.com/stretchr/testify v1.10.0
20 | github.com/urfave/cli v1.22.16
21 | golang.org/x/crypto v0.37.0
22 | golang.org/x/term v0.31.0
23 | golang.org/x/text v0.24.0
24 | )
25 |
26 | require (
27 | github.com/beorn7/perks v1.0.1 // indirect
28 | github.com/cespare/xxhash/v2 v2.3.0 // indirect
29 | github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
30 | github.com/davecgh/go-spew v1.1.1 // indirect
31 | github.com/felixge/httpsnoop v1.0.3 // indirect
32 | github.com/gorilla/securecookie v1.1.2 // indirect
33 | github.com/klauspost/compress v1.18.0 // indirect
34 | github.com/kr/text v0.2.0 // indirect
35 | github.com/mattn/go-colorable v0.1.13 // indirect
36 | github.com/mattn/go-isatty v0.0.19 // indirect
37 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
38 | github.com/pion/dtls/v3 v3.0.2 // indirect
39 | github.com/pion/logging v0.2.2 // indirect
40 | github.com/pion/stun/v3 v3.0.0 // indirect
41 | github.com/pion/transport/v3 v3.0.7 // indirect
42 | github.com/pmezard/go-difflib v1.0.0 // indirect
43 | github.com/prometheus/client_model v0.6.1 // indirect
44 | github.com/prometheus/common v0.62.0 // indirect
45 | github.com/prometheus/procfs v0.15.1 // indirect
46 | github.com/russross/blackfriday/v2 v2.1.0 // indirect
47 | github.com/wlynxg/anet v0.0.4 // indirect
48 | golang.org/x/sys v0.32.0 // indirect
49 | google.golang.org/protobuf v1.36.5 // indirect
50 | gopkg.in/yaml.v3 v3.0.1 // indirect
51 | )
52 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
2 | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
4 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
5 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
6 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
7 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
8 | github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
9 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
10 | github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
11 | github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
12 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
13 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
14 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
16 | github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
17 | github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
18 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
19 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
20 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
21 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
22 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
23 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
24 | github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
25 | github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
26 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
27 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
28 | github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
29 | github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
30 | github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
31 | github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
32 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
33 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
34 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
35 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
36 | github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
37 | github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
38 | github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
39 | github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
40 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
41 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
42 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
43 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
44 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
45 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
46 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
47 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
48 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
49 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
50 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
51 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
52 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
53 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
54 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
55 | github.com/pion/dtls/v3 v3.0.2 h1:425DEeJ/jfuTTghhUDW0GtYZYIwwMtnKKJNMcWccTX0=
56 | github.com/pion/dtls/v3 v3.0.2/go.mod h1:dfIXcFkKoujDQ+jtd8M6RgqKK3DuaUilm3YatAbGp5k=
57 | github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
58 | github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
59 | github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
60 | github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
61 | github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
62 | github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
63 | github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
64 | github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
65 | github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
66 | github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
67 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
68 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
69 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
70 | github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
71 | github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
72 | github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
73 | github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
74 | github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
75 | github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
76 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
77 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
78 | github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
79 | github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
80 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
81 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
82 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
83 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
84 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
85 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
86 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
87 | github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
88 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
89 | github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
90 | github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
91 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
92 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
93 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
94 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
95 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
96 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
97 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
98 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
99 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
100 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
101 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
102 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
103 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
104 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
105 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
106 | github.com/urfave/cli v1.22.15 h1:nuqt+pdC/KqswQKhETJjo7pvn/k4xMUxgW6liI7XpnM=
107 | github.com/urfave/cli v1.22.15/go.mod h1:wSan1hmo5zeyLGBjRJbzRTNk8gwoYa2B9n4q9dmRIc0=
108 | github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ=
109 | github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po=
110 | github.com/wlynxg/anet v0.0.4 h1:0de1OFQxnNqAu+x2FAKKCVIrnfGKQbs7FQz++tB0+Uw=
111 | github.com/wlynxg/anet v0.0.4/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
112 | golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
113 | golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
114 | golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY=
115 | golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
116 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
117 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
118 | golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
119 | golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
120 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
121 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
122 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
123 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
124 | golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
125 | golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
126 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
127 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
128 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
129 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
130 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
131 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
132 | golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
133 | golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
134 | golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
135 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
136 | golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
137 | golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
138 | golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
139 | golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
140 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
141 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
142 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
143 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
144 | google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
145 | google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
146 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
147 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
148 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
149 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
150 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
151 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
152 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
153 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
154 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
155 |
--------------------------------------------------------------------------------
/logger/logger.go:
--------------------------------------------------------------------------------
1 | package logger
2 |
3 | import (
4 | "os"
5 | "time"
6 |
7 | "github.com/rs/zerolog"
8 | "github.com/rs/zerolog/log"
9 | )
10 |
11 | // Init initializes the logger.
12 | func Init(lvl zerolog.Level) {
13 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339}).Level(lvl)
14 | log.Debug().Msg("Logger initialized")
15 | }
16 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/screego/server/cmd"
5 | pmode "github.com/screego/server/config/mode"
6 | )
7 |
8 | var (
9 | version = "unknown"
10 | commitHash = "unknown"
11 | mode = pmode.Dev
12 | )
13 |
14 | func main() {
15 | pmode.Set(mode)
16 | cmd.Run(version, commitHash)
17 | }
18 |
--------------------------------------------------------------------------------
/router/router.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "encoding/json"
5 | "net/http"
6 | "time"
7 |
8 | "github.com/gorilla/handlers"
9 | "github.com/gorilla/mux"
10 | "github.com/prometheus/client_golang/prometheus/promhttp"
11 | "github.com/rs/zerolog/hlog"
12 | "github.com/rs/zerolog/log"
13 | "github.com/screego/server/auth"
14 | "github.com/screego/server/config"
15 | "github.com/screego/server/ui"
16 | "github.com/screego/server/ws"
17 | )
18 |
19 | type Health struct {
20 | Status string `json:"status"`
21 | Clients int `json:"clients"`
22 | Reason string `json:"reason,omitempty"`
23 | }
24 |
25 | type UIConfig struct {
26 | AuthMode string `json:"authMode"`
27 | User string `json:"user"`
28 | LoggedIn bool `json:"loggedIn"`
29 | Version string `json:"version"`
30 | RoomName string `json:"roomName"`
31 | CloseRoomWhenOwnerLeaves bool `json:"closeRoomWhenOwnerLeaves"`
32 | }
33 |
34 | func Router(conf config.Config, rooms *ws.Rooms, users *auth.Users, version string) *mux.Router {
35 | router := mux.NewRouter()
36 | router.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37 | // https://github.com/gorilla/mux/issues/416
38 | accessLogger(r, 404, 0, 0)
39 | })
40 | router.Use(hlog.AccessHandler(accessLogger))
41 | router.Use(handlers.CORS(handlers.AllowedMethods([]string{"GET", "POST"}), handlers.AllowedOriginValidator(conf.CheckOrigin)))
42 | router.HandleFunc("/stream", rooms.Upgrade)
43 | router.Methods("POST").Path("/login").HandlerFunc(users.Authenticate)
44 | router.Methods("POST").Path("/logout").HandlerFunc(users.Logout)
45 | router.Methods("GET").Path("/config").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
46 | user, loggedIn := users.CurrentUser(r)
47 | _ = json.NewEncoder(w).Encode(&UIConfig{
48 | AuthMode: conf.AuthMode,
49 | LoggedIn: loggedIn,
50 | User: user,
51 | Version: version,
52 | RoomName: rooms.RandRoomName(),
53 | CloseRoomWhenOwnerLeaves: conf.CloseRoomWhenOwnerLeaves,
54 | })
55 | })
56 | router.Methods("GET").Path("/health").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
57 | i, err := rooms.Count()
58 | status := "up"
59 | if err != "" {
60 | status = "down"
61 | w.WriteHeader(500)
62 | }
63 | _ = json.NewEncoder(w).Encode(Health{
64 | Status: status,
65 | Clients: i,
66 | Reason: err,
67 | })
68 | })
69 | if conf.Prometheus {
70 | log.Info().Msg("Prometheus enabled")
71 | router.Methods("GET").Path("/metrics").Handler(basicAuth(promhttp.Handler(), users))
72 | }
73 |
74 | ui.Register(router)
75 |
76 | return router
77 | }
78 |
79 | func accessLogger(r *http.Request, status, size int, dur time.Duration) {
80 | log.Debug().
81 | Str("host", r.Host).
82 | Int("status", status).
83 | Int("size", size).
84 | Str("ip", r.RemoteAddr).
85 | Str("path", r.URL.Path).
86 | Str("duration", dur.String()).
87 | Msg("HTTP")
88 | }
89 |
90 | func basicAuth(handler http.Handler, users *auth.Users) http.HandlerFunc {
91 | return func(w http.ResponseWriter, r *http.Request) {
92 | user, pass, ok := r.BasicAuth()
93 |
94 | if !ok || !users.Validate(user, pass) {
95 | w.Header().Set("WWW-Authenticate", `Basic realm="screego"`)
96 | w.WriteHeader(401)
97 | _, _ = w.Write([]byte("Unauthorized.\n"))
98 | return
99 | }
100 |
101 | handler.ServeHTTP(w, r)
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/screego.config.development:
--------------------------------------------------------------------------------
1 | SCREEGO_SECRET=secure
2 | SCREEGO_LOG_LEVEL=debug
3 | SCREEGO_CORS_ALLOWED_ORIGINS=http://localhost:3000
4 | SCREEGO_USERS_FILE=./users
5 | SCREEGO_TURN_DENY_PEERS=
6 |
--------------------------------------------------------------------------------
/screego.config.example:
--------------------------------------------------------------------------------
1 | # The external ip of the server.
2 | # When using a dual stack setup define both IPv4 & IPv6 separated by a comma.
3 | # Execute the following command on the server you want to host Screego
4 | # to find your external ip.
5 | # curl 'https://api.ipify.org'
6 | # Example:
7 | # SCREEGO_EXTERNAL_IP=192.168.178.2,2a01:c22:a87c:e500:2d8:61ff:fec7:f92a
8 | #
9 | # If the server doesn't have a static ip, the ip can be obtained via a domain:
10 | # SCREEGO_EXTERNAL_IP=dns:app.screego.net
11 | # You can also specify the dns server to use
12 | # SCREEGO_EXTERNAL_IP=dns:app.screego.net@9.9.9.9:53
13 | SCREEGO_EXTERNAL_IP=
14 |
15 | # A secret which should be unique. Is used for cookie authentication.
16 | SCREEGO_SECRET=
17 |
18 | # If TLS should be enabled for HTTP requests. Screego requires TLS,
19 | # you either have to enable this setting or serve TLS via a reverse proxy.
20 | SCREEGO_SERVER_TLS=false
21 | # The TLS cert file (only needed if TLS is enabled)
22 | SCREEGO_TLS_CERT_FILE=
23 | # The TLS key file (only needed if TLS is enabled)
24 | SCREEGO_TLS_KEY_FILE=
25 |
26 | # The address the http server will listen on.
27 | # Formats:
28 | # - host:port
29 | # Example: 127.0.0.1:5050
30 | # - unix socket (must be prefixed with unix:)
31 | # Example: unix:/my/file/path.socket
32 | SCREEGO_SERVER_ADDRESS=0.0.0.0:5050
33 |
34 | # The address the TURN server will listen on.
35 | SCREEGO_TURN_ADDRESS=0.0.0.0:3478
36 |
37 | # Limit the ports that TURN will use for data relaying.
38 | # Format: min:max
39 | # Example:
40 | # 50000:55000
41 | SCREEGO_TURN_PORT_RANGE=
42 |
43 | # If set, screego will not start TURN server and instead use an external TURN server.
44 | # When using a dual stack setup define both IPv4 & IPv6 separated by a comma.
45 | # Execute the following command on the server where you host TURN server
46 | # to find your external ip.
47 | # curl 'https://api.ipify.org'
48 | # Example:
49 | # SCREEGO_TURN_EXTERNAL_IP=192.168.178.2,2a01:c22:a87c:e500:2d8:61ff:fec7:f92a
50 | #
51 | # If the turn server doesn't have a static ip, the ip can be obtained via a domain:
52 | # SCREEGO_TURN_EXTERNAL_IP=dns:turn.screego.net
53 | # You can also specify the dns server to use
54 | # SCREEGO_TURN_EXTERNAL_IP=dns:turn.screego.net@9.9.9.9:53
55 | SCREEGO_TURN_EXTERNAL_IP=
56 |
57 | # The port the external TURN server listens on.
58 | SCREEGO_TURN_EXTERNAL_PORT=3478
59 |
60 | # Authentication secret for the external TURN server.
61 | SCREEGO_TURN_EXTERNAL_SECRET=
62 |
63 | # Deny/ban peers within specific CIDRs to prevent TURN server users from
64 | # accessing machines reachable by the TURN server but not from the internet,
65 | # useful when the server is behind a NAT.
66 | #
67 | # Disallow internal ip addresses: https://en.wikipedia.org/wiki/Reserved_IP_addresses
68 | # SCREEGO_TURN_DENY_PEERS=0.0.0.0/8,10.0.0.0/8,100.64.0.0/10,127.0.0.1/8,169.254.0.0/16,172.16.0.0/12,192.0.0.0/24,192.0.2.0/24,192.88.99.0/24,192.168.0.0/16,198.18.0.0/15,198.51.100.0/24,203.0.113.0/24,224.0.0.0/4,239.0.0.0/8,255.255.255.255/32,::/128,::1/128,64:ff9b:1::/48,100::/64,2001::/32,2002::/16,fc00::/7,fe80::/10
69 | #
70 | # By default denies local addresses.
71 | SCREEGO_TURN_DENY_PEERS=0.0.0.0/8,127.0.0.1/8,::/128,::1/128,fe80::/10
72 |
73 | # If reverse proxy headers should be trusted.
74 | # Screego uses ip whitelisting for authentication
75 | # of TURN connections. When behind a proxy the ip is always the proxy server.
76 | # To still allow whitelisting this setting must be enabled and
77 | # the `X-Real-Ip` header must be set by the reverse proxy.
78 | SCREEGO_TRUST_PROXY_HEADERS=false
79 |
80 | # Defines when a user login is required
81 | # Possible values:
82 | # all: User login is always required
83 | # turn: User login is required for TURN connections
84 | # none: User login is never required
85 | SCREEGO_AUTH_MODE=turn
86 |
87 | # Defines origins that will be allowed to access Screego (HTTP + WebSocket)
88 | # The default value is sufficient for most use-cases.
89 | # Example Value: https://screego.net,https://sub.gotify.net
90 | SCREEGO_CORS_ALLOWED_ORIGINS=
91 |
92 | # Defines the location of the users file.
93 | # File Format:
94 | # user1:bcrypt_password_hash
95 | # user2:bcrypt_password_hash
96 | #
97 | # Example:
98 | # user1:$2a$12$WEfYCnWGk0PDzbATLTNiTuoZ7e/43v6DM/h7arOnPU6qEtFG.kZQy
99 | #
100 | # The user password pair can be created via
101 | # screego hash --name "user1" --pass "your password"
102 | SCREEGO_USERS_FILE=
103 |
104 | # Defines how long a user session is valid in seconds.
105 | # 0 = session invalides after browser session ends
106 | SCREEGO_SESSION_TIMEOUT_SECONDS=0
107 |
108 | # Defines the default value for the checkbox in the room creation dialog to select
109 | # if the room should be closed when the room owner leaves
110 | SCREEGO_CLOSE_ROOM_WHEN_OWNER_LEAVES=true
111 |
112 | # The loglevel (one of: debug, info, warn, error)
113 | SCREEGO_LOG_LEVEL=info
114 |
115 | # If screego should expose a prometheus endpoint at /metrics. The endpoint
116 | # requires basic authentication from a user in the users file.
117 | SCREEGO_PROMETHEUS=false
118 |
--------------------------------------------------------------------------------
/server/server.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "context"
5 | "net"
6 | "net/http"
7 | "os"
8 | "os/signal"
9 | "strings"
10 | "time"
11 |
12 | "github.com/gorilla/mux"
13 | "github.com/rs/zerolog/log"
14 | )
15 |
16 | var (
17 | notifySignal = signal.Notify
18 | serverShutdown = func(server *http.Server, ctx context.Context) error {
19 | return server.Shutdown(ctx)
20 | }
21 | )
22 |
23 | // Start starts the http server.
24 | func Start(mux *mux.Router, address, cert, key string) error {
25 | server, shutdown := startServer(mux, address, cert, key)
26 | shutdownOnInterruptSignal(server, 2*time.Second, shutdown)
27 | return waitForServerToClose(shutdown)
28 | }
29 |
30 | func startServer(mux *mux.Router, address, cert, key string) (*http.Server, chan error) {
31 | srv := &http.Server{
32 | Addr: address,
33 | Handler: mux,
34 | }
35 |
36 | shutdown := make(chan error)
37 | go func() {
38 | err := listenAndServe(srv, address, cert, key)
39 | shutdown <- err
40 | }()
41 | return srv, shutdown
42 | }
43 |
44 | func listenAndServe(srv *http.Server, address, cert, key string) error {
45 | var err error
46 | var listener net.Listener
47 |
48 | if strings.HasPrefix(address, "unix:") {
49 | listener, err = net.Listen("unix", strings.TrimPrefix(address, "unix:"))
50 | } else {
51 | listener, err = net.Listen("tcp", address)
52 | }
53 | if err != nil {
54 | return err
55 | }
56 |
57 | if cert != "" || key != "" {
58 | log.Info().Str("addr", address).Msg("Start HTTP with tls")
59 | return srv.ServeTLS(listener, cert, key)
60 | } else {
61 | log.Info().Str("addr", address).Msg("Start HTTP")
62 | return srv.Serve(listener)
63 | }
64 | }
65 |
66 | func shutdownOnInterruptSignal(server *http.Server, timeout time.Duration, shutdown chan<- error) {
67 | interrupt := make(chan os.Signal, 1)
68 | notifySignal(interrupt, os.Interrupt)
69 |
70 | go func() {
71 | <-interrupt
72 | log.Info().Msg("Received interrupt. Shutting down...")
73 | ctx, cancel := context.WithTimeout(context.Background(), timeout)
74 | defer cancel()
75 | if err := serverShutdown(server, ctx); err != nil {
76 | shutdown <- err
77 | }
78 | }()
79 | }
80 |
81 | func waitForServerToClose(shutdown <-chan error) error {
82 | err := <-shutdown
83 | if err == http.ErrServerClosed {
84 | return nil
85 | }
86 | return err
87 | }
88 |
--------------------------------------------------------------------------------
/server/server_test.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "net"
7 | "net/http"
8 | "os"
9 | "strconv"
10 | "testing"
11 | "time"
12 |
13 | "github.com/gorilla/mux"
14 | "github.com/stretchr/testify/assert"
15 | )
16 |
17 | func TestShutdownOnErrorWhileShutdown(t *testing.T) {
18 | disposeInterrupt := fakeInterrupt(t)
19 | defer disposeInterrupt()
20 |
21 | shutdownError := errors.New("shutdown error")
22 | disposeShutdown := fakeShutdownError(shutdownError)
23 | defer disposeShutdown()
24 |
25 | finished := make(chan error)
26 |
27 | go func() {
28 | finished <- Start(mux.NewRouter(), ":"+strconv.Itoa(port()), "", "")
29 | }()
30 |
31 | select {
32 | case <-time.After(1 * time.Second):
33 | t.Fatal("Server should be closed")
34 | case err := <-finished:
35 | assert.Equal(t, shutdownError, err)
36 | }
37 | }
38 |
39 | func TestShutdownAfterError(t *testing.T) {
40 | finished := make(chan error)
41 |
42 | go func() {
43 | finished <- Start(mux.NewRouter(), ":-5", "", "")
44 | }()
45 |
46 | select {
47 | case <-time.After(1 * time.Second):
48 | t.Fatal("Server should be closed")
49 | case err := <-finished:
50 | assert.NotNil(t, err)
51 | }
52 | }
53 |
54 | func TestShutdown(t *testing.T) {
55 | dispose := fakeInterrupt(t)
56 | defer dispose()
57 |
58 | finished := make(chan error)
59 |
60 | go func() {
61 | finished <- Start(mux.NewRouter(), ":"+strconv.Itoa(port()), "", "")
62 | }()
63 |
64 | select {
65 | case <-time.After(1 * time.Second):
66 | t.Fatal("Server should be closed")
67 | case err := <-finished:
68 | assert.Nil(t, err)
69 | }
70 | }
71 |
72 | func fakeInterrupt(t *testing.T) func() {
73 | oldNotify := notifySignal
74 | notifySignal = func(c chan<- os.Signal, sig ...os.Signal) {
75 | assert.Contains(t, sig, os.Interrupt)
76 | go func() {
77 | time.Sleep(100 * time.Millisecond)
78 | c <- os.Interrupt
79 | }()
80 | }
81 | return func() {
82 | notifySignal = oldNotify
83 | }
84 | }
85 |
86 | func fakeShutdownError(err error) func() {
87 | old := serverShutdown
88 | serverShutdown = func(server *http.Server, ctx context.Context) error {
89 | return err
90 | }
91 | return func() {
92 | serverShutdown = old
93 | }
94 | }
95 |
96 | func port() int {
97 | addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
98 | if err != nil {
99 | panic(err)
100 | }
101 |
102 | l, err := net.ListenTCP("tcp", addr)
103 | if err != nil {
104 | panic(err)
105 | }
106 | defer l.Close()
107 | return l.Addr().(*net.TCPAddr).Port
108 | }
109 |
--------------------------------------------------------------------------------
/turn/none.go:
--------------------------------------------------------------------------------
1 | package turn
2 |
3 | import (
4 | "errors"
5 | "net"
6 | "strconv"
7 | )
8 |
9 | type RelayAddressGeneratorNone struct{}
10 |
11 | func (r *RelayAddressGeneratorNone) Validate() error {
12 | return nil
13 | }
14 |
15 | func (r *RelayAddressGeneratorNone) AllocatePacketConn(network string, requestedPort int) (net.PacketConn, net.Addr, error) {
16 | conn, err := net.ListenPacket("udp", ":"+strconv.Itoa(requestedPort))
17 | if err != nil {
18 | return nil, nil, err
19 | }
20 |
21 | return conn, conn.LocalAddr(), nil
22 | }
23 |
24 | func (r *RelayAddressGeneratorNone) AllocateConn(network string, requestedPort int) (net.Conn, net.Addr, error) {
25 | return nil, nil, errors.New("todo")
26 | }
27 |
--------------------------------------------------------------------------------
/turn/portrange.go:
--------------------------------------------------------------------------------
1 | package turn
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "net"
7 |
8 | "github.com/pion/randutil"
9 | )
10 |
11 | type RelayAddressGeneratorPortRange struct {
12 | MinPort uint16
13 | MaxPort uint16
14 | Rand randutil.MathRandomGenerator
15 | }
16 |
17 | func (r *RelayAddressGeneratorPortRange) Validate() error {
18 | if r.Rand == nil {
19 | r.Rand = randutil.NewMathRandomGenerator()
20 | }
21 |
22 | return nil
23 | }
24 |
25 | func (r *RelayAddressGeneratorPortRange) AllocatePacketConn(network string, requestedPort int) (net.PacketConn, net.Addr, error) {
26 | if requestedPort != 0 {
27 | conn, err := net.ListenPacket("udp", fmt.Sprintf(":%d", requestedPort))
28 | if err != nil {
29 | return nil, nil, err
30 | }
31 | relayAddr := conn.LocalAddr().(*net.UDPAddr)
32 | return conn, relayAddr, nil
33 | }
34 |
35 | for try := 0; try < 10; try++ {
36 | port := r.MinPort + uint16(r.Rand.Intn(int((r.MaxPort+1)-r.MinPort)))
37 | conn, err := net.ListenPacket("udp", fmt.Sprintf(":%d", port))
38 | if err != nil {
39 | continue
40 | }
41 |
42 | relayAddr := conn.LocalAddr().(*net.UDPAddr)
43 | return conn, relayAddr, nil
44 | }
45 |
46 | return nil, nil, errors.New("could not find free port: max retries exceeded")
47 | }
48 |
49 | func (r *RelayAddressGeneratorPortRange) AllocateConn(network string, requestedPort int) (net.Conn, net.Addr, error) {
50 | return nil, nil, errors.New("todo")
51 | }
52 |
--------------------------------------------------------------------------------
/turn/server.go:
--------------------------------------------------------------------------------
1 | package turn
2 |
3 | import (
4 | "crypto/hmac"
5 | "crypto/sha1"
6 | "encoding/base64"
7 | "fmt"
8 | "net"
9 | "sync"
10 | "time"
11 |
12 | "github.com/pion/turn/v4"
13 | "github.com/rs/zerolog/log"
14 | "github.com/screego/server/config"
15 | "github.com/screego/server/config/ipdns"
16 | "github.com/screego/server/util"
17 | )
18 |
19 | type Server interface {
20 | Credentials(id string, addr net.IP) (string, string)
21 | Disallow(username string)
22 | }
23 |
24 | type InternalServer struct {
25 | lock sync.RWMutex
26 | lookup map[string]Entry
27 | }
28 |
29 | type ExternalServer struct {
30 | secret []byte
31 | ttl time.Duration
32 | }
33 |
34 | type Entry struct {
35 | addr net.IP
36 | password []byte
37 | }
38 |
39 | const Realm = "screego"
40 |
41 | type Generator struct {
42 | turn.RelayAddressGenerator
43 | IPProvider ipdns.Provider
44 | }
45 |
46 | func (r *Generator) AllocatePacketConn(network string, requestedPort int) (net.PacketConn, net.Addr, error) {
47 | conn, addr, err := r.RelayAddressGenerator.AllocatePacketConn(network, requestedPort)
48 | if err != nil {
49 | return conn, addr, err
50 | }
51 | relayAddr := *addr.(*net.UDPAddr)
52 |
53 | v4, v6, err := r.IPProvider.Get()
54 | if err != nil {
55 | return conn, addr, err
56 | }
57 |
58 | if v6 == nil || (relayAddr.IP.To4() != nil && v4 != nil) {
59 | relayAddr.IP = v4
60 | } else {
61 | relayAddr.IP = v6
62 | }
63 | if err == nil {
64 | log.Debug().Str("addr", addr.String()).Str("relayaddr", relayAddr.String()).Msg("TURN allocated")
65 | }
66 | return conn, &relayAddr, err
67 | }
68 |
69 | func Start(conf config.Config) (Server, error) {
70 | if conf.TurnExternal {
71 | return newExternalServer(conf)
72 | } else {
73 | return newInternalServer(conf)
74 | }
75 | }
76 |
77 | func newExternalServer(conf config.Config) (Server, error) {
78 | return &ExternalServer{
79 | secret: []byte(conf.TurnExternalSecret),
80 | ttl: 24 * time.Hour,
81 | }, nil
82 | }
83 |
84 | func newInternalServer(conf config.Config) (Server, error) {
85 | udpListener, err := net.ListenPacket("udp", conf.TurnAddress)
86 | if err != nil {
87 | return nil, fmt.Errorf("udp: could not listen on %s: %s", conf.TurnAddress, err)
88 | }
89 | tcpListener, err := net.Listen("tcp", conf.TurnAddress)
90 | if err != nil {
91 | return nil, fmt.Errorf("tcp: could not listen on %s: %s", conf.TurnAddress, err)
92 | }
93 |
94 | svr := &InternalServer{lookup: map[string]Entry{}}
95 |
96 | gen := &Generator{
97 | RelayAddressGenerator: generator(conf),
98 | IPProvider: conf.TurnIPProvider,
99 | }
100 |
101 | var permissions turn.PermissionHandler = func(clientAddr net.Addr, peerIP net.IP) bool {
102 | for _, cidr := range conf.TurnDenyPeersParsed {
103 | if cidr.Contains(peerIP) {
104 | return false
105 | }
106 | }
107 |
108 | return true
109 | }
110 |
111 | _, err = turn.NewServer(turn.ServerConfig{
112 | Realm: Realm,
113 | AuthHandler: svr.authenticate,
114 | ListenerConfigs: []turn.ListenerConfig{
115 | {Listener: tcpListener, RelayAddressGenerator: gen, PermissionHandler: permissions},
116 | },
117 | PacketConnConfigs: []turn.PacketConnConfig{
118 | {PacketConn: udpListener, RelayAddressGenerator: gen, PermissionHandler: permissions},
119 | },
120 | })
121 | if err != nil {
122 | return nil, err
123 | }
124 |
125 | log.Info().Str("addr", conf.TurnAddress).Msg("Start TURN/STUN")
126 | return svr, nil
127 | }
128 |
129 | func generator(conf config.Config) turn.RelayAddressGenerator {
130 | min, max, useRange := conf.PortRange()
131 | if useRange {
132 | log.Debug().Uint16("min", min).Uint16("max", max).Msg("Using Port Range")
133 | return &RelayAddressGeneratorPortRange{MinPort: min, MaxPort: max}
134 | }
135 | return &RelayAddressGeneratorNone{}
136 | }
137 |
138 | func (a *InternalServer) allow(username, password string, addr net.IP) {
139 | a.lock.Lock()
140 | defer a.lock.Unlock()
141 | a.lookup[username] = Entry{
142 | addr: addr,
143 | password: turn.GenerateAuthKey(username, Realm, password),
144 | }
145 | }
146 |
147 | func (a *InternalServer) Disallow(username string) {
148 | a.lock.Lock()
149 | defer a.lock.Unlock()
150 |
151 | delete(a.lookup, username)
152 | }
153 |
154 | func (a *ExternalServer) Disallow(username string) {
155 | // not supported, will expire on TTL
156 | }
157 |
158 | func (a *InternalServer) authenticate(username, realm string, addr net.Addr) ([]byte, bool) {
159 | a.lock.RLock()
160 | defer a.lock.RUnlock()
161 |
162 | entry, ok := a.lookup[username]
163 |
164 | if !ok {
165 | log.Debug().Interface("addr", addr).Str("username", username).Msg("TURN username not found")
166 | return nil, false
167 | }
168 |
169 | log.Debug().Interface("addr", addr.String()).Str("realm", realm).Msg("TURN authenticated")
170 | return entry.password, true
171 | }
172 |
173 | func (a *InternalServer) Credentials(id string, addr net.IP) (string, string) {
174 | password := util.RandString(20)
175 | a.allow(id, password, addr)
176 | return id, password
177 | }
178 |
179 | func (a *ExternalServer) Credentials(id string, addr net.IP) (string, string) {
180 | username := fmt.Sprintf("%d:%s", time.Now().Add(a.ttl).Unix(), id)
181 | mac := hmac.New(sha1.New, a.secret)
182 | _, _ = mac.Write([]byte(username))
183 | password := base64.StdEncoding.EncodeToString(mac.Sum(nil))
184 | return username, password
185 | }
186 |
--------------------------------------------------------------------------------
/ui/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/ui/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 100,
3 | "tabWidth": 4,
4 | "useTabs": false,
5 | "semi": true,
6 | "singleQuote": true,
7 | "trailingComma": "es5",
8 | "bracketSpacing": false,
9 | "arrowParens": "always",
10 | "parser": "typescript"
11 | }
12 |
--------------------------------------------------------------------------------
/ui/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
18 |
19 | Screego
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/ui/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ui",
3 | "version": "0.1.0",
4 | "homepage": ".",
5 | "private": true,
6 | "dependencies": {
7 | "@emotion/react": "^11.13.3",
8 | "@emotion/styled": "^11.13.0",
9 | "@mui/icons-material": "7.0.2",
10 | "@mui/material": "7.0.2",
11 | "@mui/styles": "^6.1.1",
12 | "@types/react": "19.1.1",
13 | "@types/react-dom": "19.1.2",
14 | "@vitejs/plugin-react-swc": "^3.7.0",
15 | "notistack": "^3.0.1",
16 | "prettier": "^3.3.3",
17 | "react": "19.1.0",
18 | "react-dom": "19.1.0",
19 | "react-hotkeys-hook": "5.0.1",
20 | "typescript": "5.8.3",
21 | "use-http": "^1.0.28",
22 | "vite": "6.2.6",
23 | "vite-plugin-svgr": "^4.2.0",
24 | "vite-tsconfig-paths": "^5.0.1"
25 | },
26 | "scripts": {
27 | "start": "vite",
28 | "format": "prettier \"src/**/*.{ts,tsx}\" --write",
29 | "testformat": "prettier \"src/**/*.{ts,tsx}\" --list-different",
30 | "build": "tsc && vite build"
31 | },
32 | "eslintConfig": {
33 | "extends": "react-app"
34 | },
35 | "browserslist": {
36 | "production": [
37 | ">0.2%",
38 | "not dead",
39 | "not op_mini all"
40 | ],
41 | "development": [
42 | "last 1 chrome version",
43 | "last 1 firefox version",
44 | "last 1 safari version"
45 | ]
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/ui/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/screego/server/acb8287706a582c0ed21b2d4a8846d1d15c1ab1e/ui/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/ui/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/screego/server/acb8287706a582c0ed21b2d4a8846d1d15c1ab1e/ui/public/favicon.ico
--------------------------------------------------------------------------------
/ui/public/og-banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/screego/server/acb8287706a582c0ed21b2d4a8846d1d15c1ab1e/ui/public/og-banner.png
--------------------------------------------------------------------------------
/ui/serve.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "embed"
5 | "io"
6 | "io/fs"
7 | "net/http"
8 |
9 | "github.com/gorilla/mux"
10 | "github.com/rs/zerolog/log"
11 | )
12 |
13 | //go:embed build
14 | var buildFiles embed.FS
15 | var files, _ = fs.Sub(buildFiles, "build")
16 |
17 | // Register registers the ui on the root path.
18 | func Register(r *mux.Router) {
19 | r.Handle("/", serveFile("index.html", "text/html"))
20 | r.Handle("/index.html", serveFile("index.html", "text/html"))
21 | r.Handle("/assets/{resource}", http.FileServer(http.FS(files)))
22 |
23 | r.Handle("/favicon.ico", serveFile("favicon.ico", "image/x-icon"))
24 | r.Handle("/logo.svg", serveFile("logo.svg", "image/svg+xml"))
25 | r.Handle("/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png"))
26 | r.Handle("/og-banner.png", serveFile("og-banner.png", "image/png"))
27 | }
28 |
29 | func serveFile(name, contentType string) http.HandlerFunc {
30 | file, err := files.Open(name)
31 | if err != nil {
32 | log.Panic().Err(err).Msgf("could not find %s", file)
33 | }
34 | defer file.Close()
35 | content, err := io.ReadAll(file)
36 | if err != nil {
37 | log.Panic().Err(err).Msgf("could not read %s", file)
38 | }
39 |
40 | return func(writer http.ResponseWriter, reg *http.Request) {
41 | writer.Header().Set("Content-Type", contentType)
42 | _, _ = writer.Write(content)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/ui/src/LoginForm.tsx:
--------------------------------------------------------------------------------
1 | import {UseConfig} from './useConfig';
2 | import React from 'react';
3 | import {
4 | Box,
5 | Button,
6 | ButtonProps,
7 | CircularProgress,
8 | FormControl,
9 | TextField,
10 | Typography,
11 | } from '@mui/material';
12 | import makeStyles from '@mui/styles/makeStyles';
13 | import {green} from '@mui/material/colors';
14 |
15 | export const LoginForm = ({config: {login}, hide}: {config: UseConfig; hide?: () => void}) => {
16 | const [user, setUser] = React.useState('');
17 | const [pass, setPass] = React.useState('');
18 | const [loading, setLoading] = React.useState(false);
19 | const submit = async (event: {preventDefault: () => void}) => {
20 | event.preventDefault();
21 | setLoading(true);
22 | login(user, pass)
23 | .then(() => {
24 | setLoading(false);
25 | })
26 | .catch(() => setLoading(false));
27 | };
28 | return (
29 |
71 | );
72 | };
73 |
74 | export const LoadingButton = ({loading, children, ...props}: ButtonProps & {loading: boolean}) => {
75 | const classes = useStyles();
76 | return (
77 |
83 | );
84 | };
85 |
86 | const useStyles = makeStyles(() => ({
87 | buttonProgress: {
88 | color: green[500],
89 | position: 'absolute',
90 | top: '50%',
91 | left: '50%',
92 | marginTop: -12,
93 | marginLeft: -12,
94 | },
95 | }));
96 |
--------------------------------------------------------------------------------
/ui/src/NumberField.tsx:
--------------------------------------------------------------------------------
1 | import {TextField, TextFieldProps} from '@mui/material';
2 | import React from 'react';
3 |
4 | export interface NumberFieldProps {
5 | value: number;
6 | min: number;
7 | onChange: (value: number) => void;
8 | }
9 |
10 | export const NumberField = ({
11 | value,
12 | min,
13 | onChange,
14 | ...props
15 | }: NumberFieldProps & Omit) => {
16 | const [stringValue, setStringValue] = React.useState(value.toString());
17 | const [error, setError] = React.useState('');
18 |
19 | return (
20 | {
26 | setStringValue(event.target.value);
27 | const i = parseInt(event.target.value, 10);
28 | if (Number.isNaN(i)) {
29 | setError('Invalid number');
30 | return;
31 | }
32 |
33 | if (i < min) {
34 | setError('Number must be at least ' + min);
35 | return;
36 | }
37 | onChange(i);
38 | setError('');
39 | }}
40 | {...props}
41 | />
42 | );
43 | };
44 |
--------------------------------------------------------------------------------
/ui/src/Room.tsx:
--------------------------------------------------------------------------------
1 | import React, {useCallback} from 'react';
2 | import {Badge, Box, IconButton, Paper, Tooltip, Typography, Slider, Stack} from '@mui/material';
3 | import CancelPresentationIcon from '@mui/icons-material/CancelPresentation';
4 | import PresentToAllIcon from '@mui/icons-material/PresentToAll';
5 | import FullScreenIcon from '@mui/icons-material/Fullscreen';
6 | import PeopleIcon from '@mui/icons-material/People';
7 | import VolumeMuteIcon from '@mui/icons-material/VolumeOff';
8 | import VolumeIcon from '@mui/icons-material/VolumeUp';
9 | import SettingsIcon from '@mui/icons-material/Settings';
10 | import {useHotkeys} from 'react-hotkeys-hook';
11 | import {Video} from './Video';
12 | import makeStyles from '@mui/styles/makeStyles';
13 | import {ConnectedRoom} from './useRoom';
14 | import {useSnackbar} from 'notistack';
15 | import {RoomUser} from './message';
16 | import {useSettings, VideoDisplayMode} from './settings';
17 | import {SettingDialog} from './SettingDialog';
18 |
19 | const HostStream: unique symbol = Symbol('mystream');
20 |
21 | const flags = (user: RoomUser) => {
22 | const result: string[] = [];
23 | if (user.you) {
24 | result.push('You');
25 | }
26 | if (user.owner) {
27 | result.push('Owner');
28 | }
29 | if (user.streaming) {
30 | result.push('Streaming');
31 | }
32 | if (!result.length) {
33 | return '';
34 | }
35 | return ` (${result.join(', ')})`;
36 | };
37 |
38 | interface FullScreenHTMLVideoElement extends HTMLVideoElement {
39 | msRequestFullscreen?: () => void;
40 | mozRequestFullScreen?: () => void;
41 | webkitRequestFullscreen?: () => void;
42 | }
43 |
44 | const requestFullscreen = (element: FullScreenHTMLVideoElement | null) => {
45 | if (element?.requestFullscreen) {
46 | element.requestFullscreen();
47 | } else if (element?.mozRequestFullScreen) {
48 | element.mozRequestFullScreen();
49 | } else if (element?.msRequestFullscreen) {
50 | element.msRequestFullscreen();
51 | } else if (element?.webkitRequestFullscreen) {
52 | element.webkitRequestFullscreen();
53 | }
54 | };
55 |
56 | export const Room = ({
57 | state,
58 | share,
59 | stopShare,
60 | setName,
61 | }: {
62 | state: ConnectedRoom;
63 | share: () => void;
64 | stopShare: () => void;
65 | setName: (name: string) => void;
66 | }) => {
67 | const classes = useStyles();
68 | const [open, setOpen] = React.useState(false);
69 | const {enqueueSnackbar} = useSnackbar();
70 | const [settings, setSettings] = useSettings();
71 | const [showControl, setShowControl] = React.useState(true);
72 | const [hoverControl, setHoverControl] = React.useState(false);
73 | const [selectedStream, setSelectedStream] = React.useState();
74 | const [videoElement, setVideoElement] = React.useState(null);
75 |
76 | useShowOnMouseMovement(setShowControl);
77 |
78 | const handleFullscreen = useCallback(() => requestFullscreen(videoElement), [videoElement]);
79 |
80 | React.useEffect(() => {
81 | if (selectedStream === HostStream && state.hostStream) {
82 | return;
83 | }
84 | if (state.clientStreams.some(({id}) => id === selectedStream)) {
85 | return;
86 | }
87 | if (state.clientStreams.length === 0 && selectedStream) {
88 | setSelectedStream(undefined);
89 | return;
90 | }
91 | setSelectedStream(state.clientStreams[0]?.id);
92 | }, [state.clientStreams, selectedStream, state.hostStream]);
93 |
94 | const stream =
95 | selectedStream === HostStream
96 | ? state.hostStream
97 | : state.clientStreams.find(({id}) => selectedStream === id)?.stream;
98 |
99 | React.useEffect(() => {
100 | if (videoElement && stream) {
101 | videoElement.srcObject = stream;
102 | videoElement.play().catch((err) => {
103 | console.log('Could not play main video', err);
104 | if (err.name === 'NotAllowedError') {
105 | videoElement.muted = true;
106 | videoElement
107 | .play()
108 | .catch((retryErr) =>
109 | console.log('Could not play main video with mute', retryErr)
110 | );
111 | }
112 | });
113 | }
114 | }, [videoElement, stream]);
115 |
116 | const copyLink = () => {
117 | navigator?.clipboard?.writeText(window.location.href)?.then(
118 | () => enqueueSnackbar('Link Copied', {variant: 'success'}),
119 | (err) => enqueueSnackbar('Copy Failed ' + err, {variant: 'error'})
120 | );
121 | };
122 |
123 | const setHoverState = React.useMemo(
124 | () => ({
125 | onMouseLeave: () => setHoverControl(false),
126 | onMouseEnter: () => setHoverControl(true),
127 | }),
128 | [setHoverControl]
129 | );
130 |
131 | const controlVisible = showControl || open || hoverControl;
132 |
133 | useHotkeys('s', () => (state.hostStream ? stopShare() : share()), [state.hostStream]);
134 | useHotkeys(
135 | 'f',
136 | () => {
137 | if (selectedStream) {
138 | handleFullscreen();
139 | }
140 | },
141 | [handleFullscreen, selectedStream]
142 | );
143 | useHotkeys('c', copyLink);
144 | useHotkeys(
145 | 'h',
146 | () => {
147 | if (state.clientStreams !== undefined && state.clientStreams.length > 0) {
148 | const currentStreamIndex = state.clientStreams.findIndex(
149 | ({id}) => id === selectedStream
150 | );
151 | const nextIndex =
152 | currentStreamIndex === state.clientStreams.length - 1
153 | ? 0
154 | : currentStreamIndex + 1;
155 | setSelectedStream(state.clientStreams[nextIndex].id);
156 | }
157 | },
158 | [state.clientStreams, selectedStream]
159 | );
160 | useHotkeys(
161 | 'l',
162 | () => {
163 | if (state.clientStreams !== undefined && state.clientStreams.length > 0) {
164 | const currentStreamIndex = state.clientStreams.findIndex(
165 | ({id}) => id === selectedStream
166 | );
167 | const previousIndex =
168 | currentStreamIndex === 0
169 | ? state.clientStreams.length - 1
170 | : currentStreamIndex - 1;
171 | setSelectedStream(state.clientStreams[previousIndex].id);
172 | }
173 | },
174 | [state.clientStreams, selectedStream]
175 | );
176 | useHotkeys(
177 | 'm',
178 | () => {
179 | if (videoElement) {
180 | videoElement.muted = !videoElement.muted;
181 | }
182 | },
183 | [videoElement]
184 | );
185 |
186 | const videoClasses = () => {
187 | switch (settings.displayMode) {
188 | case VideoDisplayMode.FitToWindow:
189 | return `${classes.video} ${classes.videoWindowFit}`;
190 | case VideoDisplayMode.OriginalSize:
191 | return `${classes.video}`;
192 | case VideoDisplayMode.FitWidth:
193 | return `${classes.video} ${classes.videoWindowWidth}`;
194 | case VideoDisplayMode.FitHeight:
195 | return `${classes.video} ${classes.videoWindowHeight}`;
196 | }
197 | };
198 |
199 | return (
200 |
201 | {controlVisible && (
202 |
203 |
204 |
210 | {state.id}
211 |
212 |
213 |
214 | )}
215 |
216 | {stream ? (
217 |
222 | ) : (
223 |
234 | no stream available
235 |
236 | )}
237 |
238 | {controlVisible && (
239 |
240 | {(stream?.getAudioTracks().length ?? 0) > 0 && videoElement && (
241 |
242 | )}
243 |
244 | {state.hostStream ? (
245 |
246 |
247 |
248 |
249 |
250 | ) : (
251 |
252 |
253 |
254 |
255 |
256 | )}
257 |
258 |
262 | Member List
263 | {state.users.map((user) => (
264 |
265 | {user.name} {flags(user)}
266 |
267 | ))}
268 |
269 | }
270 | arrow
271 | >
272 |
273 |
274 |
275 |
276 |
277 | handleFullscreen()}
279 | disabled={!selectedStream}
280 | size="large"
281 | >
282 |
283 |
284 |
285 |
286 |
287 | setOpen(true)} size="large">
288 |
289 |
290 |
291 |
292 |
293 | )}
294 |
295 |
296 | {state.clientStreams
297 | .filter(({id}) => id !== selectedStream)
298 | .map((client) => {
299 | return (
300 |
setSelectedStream(client.id)}
305 | >
306 |
311 |
317 | {state.users.find(({id}) => client.peer_id === id)?.name ??
318 | 'unknown'}
319 |
320 |
321 | );
322 | })}
323 | {state.hostStream && selectedStream !== HostStream && (
324 |
setSelectedStream(HostStream)}
328 | >
329 |
330 |
336 | You
337 |
338 |
339 | )}
340 |
346 |
347 |
348 | );
349 | };
350 |
351 | const useShowOnMouseMovement = (doShow: (s: boolean) => void) => {
352 | const timeoutHandle = React.useRef(0);
353 |
354 | React.useEffect(() => {
355 | const update = () => {
356 | if (timeoutHandle.current === 0) {
357 | doShow(true);
358 | }
359 |
360 | clearTimeout(timeoutHandle.current);
361 | timeoutHandle.current = window.setTimeout(() => {
362 | timeoutHandle.current = 0;
363 | doShow(false);
364 | }, 1000);
365 | };
366 | window.addEventListener('mousemove', update);
367 | return () => window.removeEventListener('mousemove', update);
368 | }, [doShow]);
369 |
370 | React.useEffect(
371 | () =>
372 | void (timeoutHandle.current = window.setTimeout(() => {
373 | timeoutHandle.current = 0;
374 | doShow(false);
375 | }, 1000)),
376 | // eslint-disable-next-line react-hooks/exhaustive-deps
377 | []
378 | );
379 | };
380 |
381 | const AudioControl = ({video}: {video: FullScreenHTMLVideoElement}) => {
382 | // this is used to force a rerender
383 | const [, setMuted] = React.useState();
384 |
385 | React.useEffect(() => {
386 | const handler = () => setMuted(video.muted);
387 | video.addEventListener('volumechange', handler);
388 | setMuted(video.muted);
389 | return () => video.removeEventListener('volumechange', handler);
390 | });
391 |
392 | return (
393 |
394 | (video.muted = !video.muted)}>
395 | {video.muted ? (
396 |
397 | ) : (
398 |
399 | )}
400 |
401 | {
407 | video.muted = false;
408 | video.volume = newVolume;
409 | }}
410 | />
411 |
412 | );
413 | };
414 |
415 | const useStyles = makeStyles(() => ({
416 | title: {
417 | padding: 15,
418 | position: 'fixed',
419 | top: '30px',
420 | left: '50%',
421 | transform: 'translateX(-50%)',
422 | zIndex: 30,
423 | },
424 | bottomContainer: {
425 | position: 'fixed',
426 | display: 'flex',
427 | bottom: 0,
428 | right: 0,
429 | zIndex: 20,
430 | },
431 | control: {
432 | padding: 15,
433 | position: 'fixed',
434 | bottom: '30px',
435 | left: '50%',
436 | transform: 'translateX(-50%)',
437 | zIndex: 30,
438 | },
439 | video: {
440 | display: 'block',
441 | margin: '0 auto',
442 |
443 | '&::-webkit-media-controls-start-playback-button': {
444 | display: 'none!important',
445 | },
446 | '&::-webkit-media-controls': {
447 | display: 'none!important',
448 | },
449 | },
450 | smallVideo: {
451 | minWidth: '100%',
452 | minHeight: '100%',
453 | width: 'auto',
454 | maxWidth: '300px',
455 |
456 | maxHeight: '200px',
457 | },
458 | videoWindowFit: {
459 | width: '100%',
460 | height: '100%',
461 |
462 | position: 'absolute',
463 | top: '50%',
464 | left: '50%',
465 | transform: 'translate(-50%,-50%)',
466 | },
467 | videoWindowWidth: {
468 | height: 'auto',
469 | width: '100%',
470 | },
471 | videoWindowHeight: {
472 | height: '100%',
473 | width: 'auto',
474 | },
475 | smallVideoLabel: {
476 | position: 'absolute',
477 | display: 'block',
478 | bottom: 0,
479 | background: 'rgba(0,0,0,.5)',
480 | padding: '5px 15px',
481 | },
482 | noMaxWidth: {
483 | maxWidth: 'none',
484 | },
485 | smallVideoContainer: {
486 | height: '100%',
487 | padding: 5,
488 | maxHeight: 200,
489 | maxWidth: 400,
490 | width: '100%',
491 | },
492 | videoContainer: {
493 | position: 'absolute',
494 | top: 0,
495 | bottom: 0,
496 | width: '100%',
497 | height: '100%',
498 |
499 | overflow: 'auto',
500 | },
501 | }));
502 |
--------------------------------------------------------------------------------
/ui/src/RoomManage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Box,
4 | Button,
5 | Checkbox,
6 | FormControl,
7 | FormControlLabel,
8 | Grid,
9 | Paper,
10 | TextField,
11 | Typography,
12 | Link,
13 | } from '@mui/material';
14 | import {FCreateRoom, UseRoom} from './useRoom';
15 | import {UIConfig} from './message';
16 | import {getRoomFromURL} from './useRoomID';
17 | import {authModeToRoomMode, UseConfig} from './useConfig';
18 | import {LoginForm} from './LoginForm';
19 |
20 | const CreateRoom = ({room, config}: Pick & {config: UIConfig}) => {
21 | const [id, setId] = React.useState(() => getRoomFromURL() ?? config.roomName);
22 | const mode = authModeToRoomMode(config.authMode, config.loggedIn);
23 | const [ownerLeave, setOwnerLeave] = React.useState(config.closeRoomWhenOwnerLeaves);
24 | const submit = () =>
25 | room({
26 | type: 'create',
27 | payload: {
28 | mode,
29 | closeOnOwnerLeave: ownerLeave,
30 | joinIfExist: true,
31 | id: id || undefined,
32 | },
33 | });
34 | return (
35 |
36 |
37 | setId(e.target.value)}
41 | label="id"
42 | margin="dense"
43 | />
44 | setOwnerLeave(checked)}
49 | />
50 | }
51 | label="Close Room after you leave"
52 | />
53 |
54 |
55 | Nat Traversal via:{' '}
56 |
61 | {mode.toUpperCase()}
62 |
63 |
64 |
65 |
68 |
69 |
70 | );
71 | };
72 |
73 | export const RoomManage = ({room, config}: {room: FCreateRoom; config: UseConfig}) => {
74 | const [showLogin, setShowLogin] = React.useState(false);
75 |
76 | const canCreateRoom = config.authMode !== 'all';
77 | const loginVisible = !config.loggedIn && (showLogin || !canCreateRoom);
78 |
79 | return (
80 |
86 |
87 |
88 |
89 |
90 |
91 | {loginVisible ? (
92 | setShowLogin(false) : undefined}
95 | />
96 | ) : (
97 | <>
98 |
99 | Hello {config.user}!{' '}
100 | {config.loggedIn ? (
101 |
104 | ) : (
105 |
112 | )}
113 |
114 |
115 |
116 | >
117 | )}
118 |
119 |
120 |
121 | Screego {config.version} |{' '}
122 | GitHub
123 |
124 |
125 | );
126 | };
127 |
--------------------------------------------------------------------------------
/ui/src/Router.tsx:
--------------------------------------------------------------------------------
1 | import {RoomManage} from './RoomManage';
2 | import {useRoom} from './useRoom';
3 | import {Room} from './Room';
4 | import {UseConfig, useConfig} from './useConfig';
5 |
6 | export const Router = () => {
7 | const config = useConfig();
8 |
9 | if (config.loading) {
10 | // show spinner
11 | return null;
12 | }
13 | return ;
14 | };
15 |
16 | const RouterLoadedConfig = ({config}: {config: UseConfig}) => {
17 | const {room, state, ...other} = useRoom(config);
18 |
19 | if (state) {
20 | return ;
21 | }
22 |
23 | return ;
24 | };
25 |
--------------------------------------------------------------------------------
/ui/src/SettingDialog.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Dialog,
4 | DialogTitle,
5 | DialogContent,
6 | TextField,
7 | DialogActions,
8 | Button,
9 | Autocomplete,
10 | Box,
11 | } from '@mui/material';
12 | import {
13 | CodecBestQuality,
14 | CodecDefault,
15 | codecName,
16 | loadSettings,
17 | PreferredCodec,
18 | Settings,
19 | VideoDisplayMode,
20 | } from './settings';
21 | import {NumberField} from './NumberField';
22 |
23 | export interface SettingDialogProps {
24 | open: boolean;
25 | setOpen: (open: boolean) => void;
26 | updateName: (s: string) => void;
27 | saveSettings: (s: Settings) => void;
28 | }
29 |
30 | const getAvailableCodecs = (): PreferredCodec[] => {
31 | if ('getCapabilities' in RTCRtpSender) {
32 | return RTCRtpSender.getCapabilities('video')?.codecs ?? [];
33 | }
34 | return [];
35 | };
36 |
37 | const NativeCodecs = getAvailableCodecs();
38 |
39 | export const SettingDialog = ({open, setOpen, updateName, saveSettings}: SettingDialogProps) => {
40 | const [settingsInput, setSettingsInput] = React.useState(loadSettings);
41 |
42 | const doSubmit = () => {
43 | saveSettings(settingsInput);
44 | updateName(settingsInput.name ?? '');
45 | setOpen(false);
46 | };
47 |
48 | const {name, preferCodec, displayMode, framerate} = settingsInput;
49 |
50 | return (
51 |
125 | );
126 | };
127 |
--------------------------------------------------------------------------------
/ui/src/Video.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export const Video = ({src, className}: {src: MediaStream; className?: string}) => {
4 | const [element, setElement] = React.useState(null);
5 |
6 | React.useEffect(() => {
7 | if (element) {
8 | element.srcObject = src;
9 | element.play().catch((e) => console.log('Could not play preview video', e));
10 | }
11 | }, [element, src]);
12 |
13 | return ;
14 | };
15 |
--------------------------------------------------------------------------------
/ui/src/global.css:
--------------------------------------------------------------------------------
1 | #root,
2 | body,
3 | html {
4 | height: 100%;
5 | }
--------------------------------------------------------------------------------
/ui/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import './global.css';
4 | import {Button, createTheme, CssBaseline, ThemeProvider, StyledEngineProvider} from '@mui/material';
5 | import {Router} from './Router';
6 | import {SnackbarProvider} from 'notistack';
7 |
8 | const theme = createTheme({
9 | components: {
10 | MuiSelect: {
11 | styleOverrides: {
12 | icon: {position: 'relative'},
13 | },
14 | },
15 | MuiLink: {
16 | styleOverrides: {
17 | root: {
18 | color: '#458588',
19 | },
20 | },
21 | },
22 | MuiIconButton: {
23 | styleOverrides: {
24 | root: {
25 | color: 'inherit',
26 | },
27 | },
28 | },
29 | MuiListItemIcon: {
30 | styleOverrides: {
31 | root: {
32 | color: 'inherit',
33 | },
34 | },
35 | },
36 | MuiToolbar: {
37 | styleOverrides: {
38 | root: {
39 | background: '#a89984',
40 | },
41 | },
42 | },
43 | MuiTooltip: {
44 | styleOverrides: {
45 | tooltip: {
46 | fontSize: '1.6em',
47 | },
48 | },
49 | },
50 | },
51 | palette: {
52 | background: {
53 | default: '#282828',
54 | paper: '#32302f',
55 | },
56 | text: {
57 | primary: '#fbf1d4',
58 | },
59 | primary: {
60 | main: '#a89984',
61 | },
62 | secondary: {
63 | main: '#f44336',
64 | },
65 | mode: 'dark',
66 | },
67 | });
68 |
69 | const Snackbar: React.FC = ({children}) => {
70 | const notistackRef = React.createRef();
71 | const onClickDismiss = (key: unknown) => () => {
72 | notistackRef.current?.closeSnackbar(key);
73 | };
74 |
75 | return (
76 | (
80 |
83 | )}
84 | >
85 | {children}
86 |
87 | );
88 | };
89 |
90 | ReactDOM.createRoot(document.getElementById('root')!!).render(
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 | );
100 |
--------------------------------------------------------------------------------
/ui/src/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/screego/server/acb8287706a582c0ed21b2d4a8846d1d15c1ab1e/ui/src/logo.png
--------------------------------------------------------------------------------
/ui/src/message.ts:
--------------------------------------------------------------------------------
1 | export enum ShareMode {
2 | Everyone = 'Everyone',
3 | Selected = 'Selected',
4 | }
5 |
6 | type Typed = {type: Type; payload: Base};
7 |
8 | export interface UIConfig {
9 | authMode: 'turn' | 'none' | 'all';
10 | user: string;
11 | loggedIn: boolean;
12 | version: string;
13 | roomName: string;
14 | closeRoomWhenOwnerLeaves: boolean;
15 | }
16 |
17 | export interface RoomConfiguration {
18 | id?: string;
19 | closeOnOwnerLeave?: boolean;
20 | mode: RoomMode;
21 | username?: string;
22 | }
23 |
24 | export enum RoomMode {
25 | Turn = 'turn',
26 | Stun = 'stun',
27 | Local = 'local',
28 | }
29 |
30 | export interface JoinConfiguration {
31 | id: string;
32 | password?: string;
33 | username?: string;
34 | }
35 |
36 | export interface StringMessage {
37 | message: string;
38 | }
39 |
40 | export interface P2PSession {
41 | id: string;
42 | peer: string;
43 | iceServers: ICEServer[];
44 | }
45 |
46 | export interface ICEServer {
47 | urls: string[];
48 | credential: string;
49 | username: string;
50 | }
51 |
52 | export interface RoomInfo {
53 | id: string;
54 | share: ShareMode;
55 | mode: RoomMode;
56 | users: RoomUser[];
57 | }
58 |
59 | export interface RoomUser {
60 | id: string;
61 | name: string;
62 | streaming: boolean;
63 | you: boolean;
64 | owner: boolean;
65 | }
66 |
67 | export interface P2PMessage {
68 | sid: string;
69 | value: T;
70 | }
71 |
72 | export type Room = Typed;
73 | export type Error = Typed;
74 | export type HostSession = Typed;
75 | export type Name = Typed<{username: string}, 'name'>;
76 | export type ClientSession = Typed;
77 | export type HostICECandidate = Typed, 'hostice'>;
78 | export type ClientICECandidate = Typed, 'clientice'>;
79 | export type HostOffer = Typed, 'hostoffer'>;
80 | export type ClientAnswer = Typed, 'clientanswer'>;
81 | export type StartSharing = Typed<{}, 'share'>;
82 | export type StopShare = Typed<{}, 'stopshare'>;
83 | export type RoomCreate = Typed;
84 | export type JoinRoom = Typed;
85 | export type EndShare = Typed;
86 |
87 | export type IncomingMessage =
88 | | Room
89 | | Error
90 | | HostSession
91 | | ClientSession
92 | | HostICECandidate
93 | | ClientICECandidate
94 | | HostOffer
95 | | EndShare
96 | | ClientAnswer;
97 |
98 | export type OutgoingMessage =
99 | | RoomCreate
100 | | Name
101 | | JoinRoom
102 | | HostICECandidate
103 | | ClientICECandidate
104 | | HostOffer
105 | | StopShare
106 | | ClientAnswer
107 | | StartSharing;
108 |
--------------------------------------------------------------------------------
/ui/src/settings.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | export const CodecBestQuality: PreferredCodec = {mimeType: 'BEST_QUALITY'};
3 | export const CodecDefault: PreferredCodec = {mimeType: 'DEFAULT'};
4 |
5 | export const preferCodecEquals = (a: PreferredCodec, b: PreferredCodec): boolean => {
6 | return a.mimeType === b.mimeType && a.sdpFmtpLine === b.sdpFmtpLine;
7 | };
8 |
9 | export const codecName = (mimeType: string): string => {
10 | switch (mimeType) {
11 | case CodecBestQuality.mimeType:
12 | return 'Preset: Best Quality';
13 | case CodecDefault.mimeType:
14 | return 'Preset: Browser Default';
15 | default:
16 | return mimeType;
17 | }
18 | };
19 |
20 | export const resolveCodecPlaceholder = (
21 | codec: PreferredCodec | undefined
22 | ): PreferredCodec | undefined => {
23 | switch (codec?.mimeType) {
24 | case CodecBestQuality.mimeType:
25 | return {
26 | mimeType: 'video/VP9',
27 | sdpFmtpLine: 'profile-id=2',
28 | };
29 | case CodecDefault.mimeType:
30 | return undefined;
31 | default:
32 | return codec;
33 | }
34 | };
35 |
36 | export interface Settings {
37 | name?: string;
38 | displayMode: VideoDisplayMode;
39 | preferCodec?: PreferredCodec;
40 | framerate: number;
41 | }
42 | export interface PreferredCodec {
43 | mimeType: string;
44 | sdpFmtpLine?: string;
45 | }
46 |
47 | export enum VideoDisplayMode {
48 | FitToWindow = 'FitToWindow',
49 | FitWidth = 'FitWidth',
50 | FitHeight = 'FitHeight',
51 | OriginalSize = 'OriginalSize',
52 | }
53 |
54 | const SettingsKey = 'screegoSettings';
55 |
56 | export const loadSettings = (): Settings => {
57 | const settings: Partial = JSON.parse(localStorage.getItem(SettingsKey) ?? '{}') ?? {};
58 |
59 | const defaults: Settings = {
60 | displayMode: VideoDisplayMode.FitToWindow,
61 | framerate: 30,
62 | };
63 |
64 | if (settings && typeof settings === 'object') {
65 | return {
66 | name: settings.name?.toString(),
67 | framerate: settings.framerate ?? defaults.framerate,
68 | displayMode:
69 | Object.values(VideoDisplayMode).find((mode) => mode === settings.displayMode) ??
70 | defaults.displayMode,
71 | preferCodec: settings.preferCodec ?? CodecDefault,
72 | };
73 | }
74 | return defaults;
75 | };
76 |
77 | export const saveSettings = (settings: Settings): void => {
78 | localStorage.setItem(SettingsKey, JSON.stringify(settings));
79 | };
80 |
81 | export const useSettings = (): [Settings, (s: Settings) => void] => {
82 | const [settings, setSettings] = React.useState(loadSettings);
83 |
84 | return [
85 | settings,
86 | (newSettings) => {
87 | setSettings(newSettings);
88 | saveSettings(newSettings);
89 | },
90 | ];
91 | };
92 |
--------------------------------------------------------------------------------
/ui/src/url.ts:
--------------------------------------------------------------------------------
1 | const {port, hostname, protocol, pathname} = window.location;
2 | const slashes = protocol.concat('//');
3 | const path = pathname.endsWith('/') ? pathname : pathname.substring(0, pathname.lastIndexOf('/'));
4 | const url = slashes.concat(port ? hostname.concat(':', port) : hostname) + path;
5 | export const urlWithSlash = url.endsWith('/') ? url : url.concat('/');
6 |
--------------------------------------------------------------------------------
/ui/src/useConfig.ts:
--------------------------------------------------------------------------------
1 | import {RoomMode, UIConfig} from './message';
2 | import {useSnackbar} from 'notistack';
3 | import React from 'react';
4 | import {urlWithSlash} from './url';
5 |
6 | export interface UseConfig extends UIConfig {
7 | login: (username: string, password: string) => Promise;
8 | refetch: () => void;
9 | logout: () => Promise;
10 | loading: boolean;
11 | }
12 |
13 | export const useConfig = (): UseConfig => {
14 | const {enqueueSnackbar} = useSnackbar();
15 | const [{loading, ...config}, setConfig] = React.useState({
16 | authMode: 'all',
17 | user: 'guest',
18 | loggedIn: false,
19 | loading: true,
20 | version: 'unknown',
21 | roomName: 'unknown',
22 | closeRoomWhenOwnerLeaves: true,
23 | });
24 |
25 | const refetch = React.useCallback(async () => {
26 | return fetch(`${urlWithSlash}config`)
27 | .then((data) => data.json())
28 | .then(setConfig);
29 | }, [setConfig]);
30 |
31 | const login = async (username: string, password: string) => {
32 | const body = new FormData();
33 | body.set('user', username);
34 | body.set('pass', password);
35 | const result = await fetch(`${urlWithSlash}login`, {method: 'POST', body});
36 | const json = await result.json();
37 | if (result.status !== 200) {
38 | enqueueSnackbar('Login Failed: ' + json.message, {variant: 'error'});
39 | } else {
40 | await refetch();
41 | enqueueSnackbar('Logged in!', {variant: 'success'});
42 | }
43 | };
44 |
45 | const logout = async () => {
46 | const result = await fetch(`${urlWithSlash}logout`, {method: 'POST'});
47 | if (result.status !== 200) {
48 | enqueueSnackbar('Logout Failed: ' + (await result.text()), {variant: 'error'});
49 | } else {
50 | await refetch();
51 | enqueueSnackbar('Logged Out.', {variant: 'success'});
52 | }
53 | };
54 |
55 | // eslint-disable-next-line react-hooks/exhaustive-deps
56 | React.useEffect(() => void refetch(), []);
57 |
58 | return {...config, refetch, loading, login, logout};
59 | };
60 |
61 | export const authModeToRoomMode = (authMode: UIConfig['authMode'], loggedIn: boolean): RoomMode => {
62 | if (loggedIn) {
63 | return RoomMode.Turn;
64 | }
65 | switch (authMode) {
66 | case 'all':
67 | return RoomMode.Turn;
68 | case 'turn':
69 | return RoomMode.Stun;
70 | case 'none':
71 | default:
72 | return RoomMode.Turn;
73 | }
74 | };
75 |
--------------------------------------------------------------------------------
/ui/src/useRoom.ts:
--------------------------------------------------------------------------------
1 | import {useSnackbar} from 'notistack';
2 | import React from 'react';
3 |
4 | import {
5 | ICEServer,
6 | IncomingMessage,
7 | JoinRoom,
8 | OutgoingMessage,
9 | RoomCreate,
10 | RoomInfo,
11 | UIConfig,
12 | } from './message';
13 | import {loadSettings, resolveCodecPlaceholder} from './settings';
14 | import {urlWithSlash} from './url';
15 | import {authModeToRoomMode} from './useConfig';
16 | import {getFromURL, useRoomID} from './useRoomID';
17 |
18 | export type RoomState = false | ConnectedRoom;
19 | export type ConnectedRoom = {
20 | ws: WebSocket;
21 | hostStream?: MediaStream;
22 | clientStreams: ClientStream[];
23 | } & RoomInfo;
24 |
25 | interface ClientStream {
26 | id: string;
27 | peer_id: string;
28 | stream: MediaStream;
29 | }
30 |
31 | export interface UseRoom {
32 | state: RoomState;
33 | room: FCreateRoom;
34 | share: () => void;
35 | setName: (name: string) => void;
36 | stopShare: () => void;
37 | }
38 |
39 | const relayConfig: Partial =
40 | window.location.search.indexOf('forceTurn=true') !== -1 ? {iceTransportPolicy: 'relay'} : {};
41 |
42 | const hostSession = async ({
43 | sid,
44 | ice,
45 | send,
46 | done,
47 | stream,
48 | }: {
49 | sid: string;
50 | ice: ICEServer[];
51 | send: (e: OutgoingMessage) => void;
52 | done: () => void;
53 | stream: MediaStream;
54 | }): Promise => {
55 | const peer = new RTCPeerConnection({...relayConfig, iceServers: ice});
56 | peer.onicecandidate = (event) => {
57 | if (!event.candidate) {
58 | return;
59 | }
60 | send({type: 'hostice', payload: {sid: sid, value: event.candidate}});
61 | };
62 |
63 | peer.onconnectionstatechange = (event) => {
64 | console.log('host change', event);
65 | if (
66 | peer.connectionState === 'closed' ||
67 | peer.connectionState === 'disconnected' ||
68 | peer.connectionState === 'failed'
69 | ) {
70 | peer.close();
71 | done();
72 | }
73 | };
74 |
75 | stream.getTracks().forEach((track) => peer.addTrack(track, stream));
76 |
77 | const preferCodec = resolveCodecPlaceholder(loadSettings().preferCodec);
78 | if (preferCodec) {
79 | const transceiver = peer
80 | .getTransceivers()
81 | .find((t) => t.sender && t.sender.track === stream.getVideoTracks()[0]);
82 |
83 | if (!!transceiver && 'setCodecPreferences' in transceiver) {
84 | const exactMatch: RTCRtpCodec[] = [];
85 | const mimeMatch: RTCRtpCodec[] = [];
86 | const others: RTCRtpCodec[] = [];
87 |
88 | RTCRtpReceiver.getCapabilities('video')?.codecs.forEach((codec) => {
89 | if (codec.mimeType === preferCodec.mimeType) {
90 | if (codec.sdpFmtpLine === preferCodec.sdpFmtpLine) {
91 | exactMatch.push(codec);
92 | } else {
93 | mimeMatch.push(codec);
94 | }
95 | } else {
96 | others.push(codec);
97 | }
98 | });
99 |
100 | const sortedCodecs = [...exactMatch, ...mimeMatch, ...others];
101 |
102 | console.log('Setting codec preferences', sortedCodecs);
103 | transceiver.setCodecPreferences(sortedCodecs);
104 | }
105 | }
106 |
107 | const hostOffer = await peer.createOffer({offerToReceiveVideo: true});
108 | await peer.setLocalDescription(hostOffer);
109 | send({type: 'hostoffer', payload: {value: hostOffer, sid: sid}});
110 |
111 | return peer;
112 | };
113 |
114 | const clientSession = async ({
115 | sid,
116 | ice,
117 | send,
118 | done,
119 | onTrack,
120 | }: {
121 | sid: string;
122 | ice: ICEServer[];
123 | send: (e: OutgoingMessage) => void;
124 | onTrack: (s: MediaStream) => void;
125 | done: () => void;
126 | }): Promise => {
127 | console.log('ice', ice);
128 | const peer = new RTCPeerConnection({...relayConfig, iceServers: ice});
129 | peer.onicecandidate = (event) => {
130 | if (!event.candidate) {
131 | return;
132 | }
133 | send({type: 'clientice', payload: {sid: sid, value: event.candidate}});
134 | };
135 | peer.onconnectionstatechange = (event) => {
136 | console.log('client change', event);
137 | if (
138 | peer.connectionState === 'closed' ||
139 | peer.connectionState === 'disconnected' ||
140 | peer.connectionState === 'failed'
141 | ) {
142 | peer.close();
143 | done();
144 | }
145 | };
146 |
147 | let notified = false;
148 | const stream = new MediaStream();
149 | peer.ontrack = (event) => {
150 | stream.addTrack(event.track);
151 | if (!notified) {
152 | notified = true;
153 | onTrack(stream);
154 | }
155 | };
156 |
157 | return peer;
158 | };
159 |
160 | export type FCreateRoom = (room: RoomCreate | JoinRoom) => Promise;
161 |
162 | export const useRoom = (config: UIConfig): UseRoom => {
163 | const [roomID, setRoomID] = useRoomID();
164 | const {enqueueSnackbar} = useSnackbar();
165 | const conn = React.useRef(undefined);
166 | const host = React.useRef>({});
167 | const client = React.useRef>({});
168 | const stream = React.useRef(undefined);
169 |
170 | const [state, setState] = React.useState(false);
171 |
172 | const room: FCreateRoom = React.useCallback(
173 | (create) => {
174 | return new Promise((resolve) => {
175 | const ws = (conn.current = new WebSocket(
176 | urlWithSlash.replace('http', 'ws') + 'stream'
177 | ));
178 | const send = (message: OutgoingMessage) => {
179 | if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(message));
180 | };
181 | let first = true;
182 | ws.onmessage = (data) => {
183 | const event: IncomingMessage = JSON.parse(data.data);
184 | if (first) {
185 | first = false;
186 | if (event.type === 'room') {
187 | resolve();
188 | setState({ws, ...event.payload, clientStreams: []});
189 | setRoomID(event.payload.id);
190 | } else {
191 | resolve();
192 | enqueueSnackbar('Unknown Event: ' + event.type, {variant: 'error'});
193 | ws.close(1000, 'received unknown event');
194 | }
195 | return;
196 | }
197 |
198 | switch (event.type) {
199 | case 'room':
200 | setState((current) =>
201 | current ? {...current, ...event.payload} : current
202 | );
203 | return;
204 | case 'hostsession':
205 | if (!stream.current) {
206 | return;
207 | }
208 | hostSession({
209 | sid: event.payload.id,
210 | stream: stream.current!,
211 | ice: event.payload.iceServers,
212 | send,
213 | done: () => delete host.current[event.payload.id],
214 | }).then((peer) => {
215 | host.current[event.payload.id] = peer;
216 | });
217 | return;
218 | case 'clientsession':
219 | const {id: sid, peer} = event.payload;
220 | clientSession({
221 | sid,
222 | send,
223 | ice: event.payload.iceServers,
224 | done: () => {
225 | delete client.current[sid];
226 | setState((current) =>
227 | current
228 | ? {
229 | ...current,
230 | clientStreams: current.clientStreams.filter(
231 | ({id}) => id !== sid
232 | ),
233 | }
234 | : current
235 | );
236 | },
237 | onTrack: (stream) =>
238 | setState((current) =>
239 | current
240 | ? {
241 | ...current,
242 | clientStreams: [
243 | ...current.clientStreams,
244 | {
245 | id: sid,
246 | stream,
247 | peer_id: peer,
248 | },
249 | ],
250 | }
251 | : current
252 | ),
253 | }).then((peer) => (client.current[event.payload.id] = peer));
254 | return;
255 | case 'clientice':
256 | host.current[event.payload.sid]?.addIceCandidate(event.payload.value);
257 | return;
258 | case 'clientanswer':
259 | host.current[event.payload.sid]?.setRemoteDescription(
260 | event.payload.value
261 | );
262 | return;
263 | case 'hostoffer':
264 | (async () => {
265 | await client.current[event.payload.sid]?.setRemoteDescription(
266 | event.payload.value
267 | );
268 | const answer =
269 | await client.current[event.payload.sid]?.createAnswer();
270 | await client.current[event.payload.sid]?.setLocalDescription(
271 | answer
272 | );
273 | send({
274 | type: 'clientanswer',
275 | payload: {sid: event.payload.sid, value: answer},
276 | });
277 | })();
278 | return;
279 | case 'hostice':
280 | client.current[event.payload.sid]?.addIceCandidate(event.payload.value);
281 | return;
282 | case 'endshare':
283 | client.current[event.payload]?.close();
284 | host.current[event.payload]?.close();
285 | setState((current) =>
286 | current
287 | ? {
288 | ...current,
289 | clientStreams: current.clientStreams.filter(
290 | ({id}) => id !== event.payload
291 | ),
292 | }
293 | : current
294 | );
295 | }
296 | };
297 | ws.onclose = (event) => {
298 | if (first) {
299 | resolve();
300 | first = false;
301 | }
302 | enqueueSnackbar(event.reason, {variant: 'error', persist: true});
303 | setState(false);
304 | };
305 | ws.onerror = (err) => {
306 | if (first) {
307 | resolve();
308 | first = false;
309 | }
310 | enqueueSnackbar(err?.toString(), {variant: 'error', persist: true});
311 | setState(false);
312 | };
313 | ws.onopen = () => {
314 | create.payload.username = loadSettings().name;
315 | send(create);
316 | };
317 | });
318 | },
319 | [setState, enqueueSnackbar, setRoomID]
320 | );
321 |
322 | const share = async () => {
323 | if (!navigator.mediaDevices) {
324 | enqueueSnackbar(
325 | 'Could not start presentation. Are you using https? (mediaDevices undefined)',
326 | {variant: 'error', persist: true}
327 | );
328 | return;
329 | }
330 | if (typeof navigator.mediaDevices.getDisplayMedia !== 'function') {
331 | enqueueSnackbar(
332 | `Could not start presentation. Your browser likely doesn't support screensharing. (mediaDevices.getDeviceMedia ${typeof navigator.mediaDevices.getDisplayMedia})`,
333 | {variant: 'error', persist: true}
334 | );
335 | return;
336 | }
337 | try {
338 | stream.current = await navigator.mediaDevices.getDisplayMedia({
339 | video: {frameRate: loadSettings().framerate},
340 | audio: {
341 | echoCancellation: false,
342 | autoGainControl: false,
343 | noiseSuppression: false,
344 | // https://medium.com/@trystonperry/why-is-getdisplaymedias-audio-quality-so-bad-b49ba9cfaa83
345 | // @ts-expect-error
346 | googAutoGainControl: false,
347 | },
348 | });
349 | } catch (e) {
350 | console.log('Could not getDisplayMedia', e);
351 | enqueueSnackbar(`Could not start presentation. (getDisplayMedia error). ${e}`, {
352 | variant: 'error',
353 | persist: true,
354 | });
355 | return;
356 | }
357 |
358 | stream.current?.getVideoTracks()[0].addEventListener('ended', () => stopShare());
359 | setState((current) => (current ? {...current, hostStream: stream.current} : current));
360 |
361 | conn.current?.send(JSON.stringify({type: 'share', payload: {}}));
362 | };
363 |
364 | const stopShare = async () => {
365 | Object.values(host.current).forEach((peer) => {
366 | peer.close();
367 | });
368 | host.current = {};
369 | stream.current?.getTracks().forEach((track) => track.stop());
370 | stream.current = undefined;
371 | conn.current?.send(JSON.stringify({type: 'stopshare', payload: {}}));
372 | setState((current) => (current ? {...current, hostStream: undefined} : current));
373 | };
374 |
375 | const setName = (name: string): void => {
376 | conn.current?.send(JSON.stringify({type: 'name', payload: {username: name}}));
377 | };
378 |
379 | React.useEffect(() => {
380 | if (roomID) {
381 | const create = getFromURL('create') === 'true';
382 | if (create) {
383 | const closeOnOwnerLeaveString = getFromURL('closeOnOwnerLeave');
384 | const closeOnOwnerLeave =
385 | closeOnOwnerLeaveString === undefined
386 | ? config.closeRoomWhenOwnerLeaves
387 | : closeOnOwnerLeaveString === 'true';
388 | room({
389 | type: 'create',
390 | payload: {
391 | joinIfExist: true,
392 | closeOnOwnerLeave,
393 | id: roomID,
394 | mode: authModeToRoomMode(config.authMode, config.loggedIn),
395 | },
396 | });
397 | } else {
398 | room({type: 'join', payload: {id: roomID}});
399 | }
400 | }
401 | // eslint-disable-next-line react-hooks/exhaustive-deps
402 | }, []);
403 |
404 | return {state, room, share, stopShare, setName};
405 | };
406 |
--------------------------------------------------------------------------------
/ui/src/useRoomID.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export const getRoomFromURL = (): string | undefined => getFromURL('room');
4 |
5 | export const getFromURL = (
6 | key: string,
7 | search: string = window.location.search
8 | ): string | undefined =>
9 | search
10 | .slice(1)
11 | .split('&')
12 | .find((param) => param.startsWith(`${key}=`))
13 | ?.split('=')[1];
14 |
15 | export const useRoomID = (): [string | undefined, (v?: string) => void] => {
16 | const [state, setState] = React.useState(() => getRoomFromURL());
17 | React.useEffect(() => {
18 | const onChange = (): void => setState(getRoomFromURL());
19 | window.addEventListener('popstate', onChange);
20 | return () => window.removeEventListener('popstate', onChange);
21 | }, [setState]);
22 | return [
23 | state,
24 | React.useCallback(
25 | (id) =>
26 | setState((oldId?: string) => {
27 | if (oldId !== id) {
28 | window.history.pushState({roomId: id}, '', id ? `?room=${id}` : '?');
29 | }
30 | return id;
31 | }),
32 | [setState]
33 | ),
34 | ];
35 | };
36 |
--------------------------------------------------------------------------------
/ui/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/ui/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | "moduleResolution": "bundler",
10 | "allowImportingTsExtensions": true,
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "noEmit": true,
14 | "jsx": "react-jsx",
15 |
16 | "strict": true,
17 | "noUnusedLocals": true,
18 | "noUnusedParameters": true,
19 | "noFallthroughCasesInSwitch": true
20 | },
21 | "include": ["src"],
22 | "references": [{ "path": "./tsconfig.node.json" }]
23 | }
24 |
25 |
--------------------------------------------------------------------------------
/ui/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/ui/vite.config.mts:
--------------------------------------------------------------------------------
1 | import {defineConfig} from 'vite';
2 | import react from '@vitejs/plugin-react-swc';
3 |
4 | export default defineConfig({
5 | base: './',
6 | server: {
7 | host: '127.0.0.1',
8 | port: 3000,
9 | open: false,
10 | proxy: {
11 | '^/(config|logout|login|stream)$': {
12 | target: 'http://localhost:5050',
13 | ws: true,
14 | },
15 | },
16 | },
17 | build: {outDir: 'build/'},
18 | plugins: [react()],
19 | });
20 |
--------------------------------------------------------------------------------
/users:
--------------------------------------------------------------------------------
1 | # Password: admin
2 | admin:$2a$12$kNgc2ZYAXzIL6SHY.8PHAOQ8Casi0s1bKatYoG/jupt2yV1M5K5nO
3 |
--------------------------------------------------------------------------------
/util/password.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "crypto/rand"
5 | "math/big"
6 | )
7 |
8 | func RandString(length int) string {
9 | res := make([]byte, length)
10 | for i := range res {
11 | index := randIntn(len(tokenCharacters))
12 | res[i] = tokenCharacters[index]
13 | }
14 | return string(res)
15 | }
16 |
17 | var (
18 | tokenCharacters = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_!@#$%^&*()){}\\/=+,.><")
19 | randReader = rand.Reader
20 | )
21 |
22 | func randIntn(n int) int {
23 | max := big.NewInt(int64(n))
24 | res, err := rand.Int(randReader, max)
25 | if err != nil {
26 | panic("random source is not available")
27 | }
28 | return int(res.Int64())
29 | }
30 |
--------------------------------------------------------------------------------
/util/sillyname.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "math/rand"
5 |
6 | "golang.org/x/text/cases"
7 | "golang.org/x/text/language"
8 | )
9 |
10 | var adjectives = []string{
11 | "able", "adaptive", "adventurous", "affable", "agreeable", "ambitious",
12 | "amiable", "amusing", "balanced", "brave", "bright", "calm", "capable",
13 | "charming", "clever", "compassionate", "considerate", "courageous",
14 | "creative", "decisive", "determined", "discreet", "dynamic",
15 | "enthusiastic", "exuberant", "faithful", "fearless", "friendly", "funny",
16 | "generous", "gentle", "good", "honest", "humorous", "independent",
17 | "intelligent", "intuitive", "kind", "loving", "loyal", "modest", "nice",
18 | "optimistic", "patient", "pioneering", "polite", "powerful", "reliable",
19 | "resourceful", "sensible", "sincere", "thoughtful", "tough", "versatile",
20 | }
21 |
22 | var animals = []string{
23 | "dog", "puppy", "turtle", "rabbit", "parrot", "cat", "kitten", "goldfish",
24 | "mouse", "hamster", "fish", "cow", "rabbit", "duck", "shrimp", "pig",
25 | "goat", "crab", "deer", "bee", "sheep", "fish", "turkey", "dove",
26 | "chicken", "horse", "squirrel", "dog", "chimpanzee", "ox", "lion", "panda",
27 | "walrus", "otter", "mouse", "kangaroo", "goat", "horse", "monkey", "cow",
28 | "koala", "mole", "elephant", "leopard", "hippopotamus", "giraffe", "fox",
29 | "coyote", "hedgehong", "sheep", "deer",
30 | }
31 |
32 | var colors = []string{
33 | "amaranth", "amber", "amethyst", "apricot", "aqua", "aquamarine", "azure",
34 | "beige", "black", "blue", "blush", "bronze", "brown", "chocolate",
35 | "coffee", "copper", "coral", "crimson", "cyan", "emerald", "fuchsia",
36 | "gold", "gray", "green", "harlequin", "indigo", "ivory", "jade",
37 | "lavender", "lime", "magenta", "maroon", "moccasin", "olive", "orange",
38 | "peach", "pink", "plum", "purple", "red", "rose", "salmon", "sapphire",
39 | "scarlet", "silver", "tan", "teal", "tomato", "turquoise", "violet",
40 | "white", "yellow",
41 | }
42 |
43 | func r(r *rand.Rand, l []string) string {
44 | return l[r.Intn(len(l)-1)]
45 | }
46 |
47 | func NewUserName(s *rand.Rand) string {
48 | title := cases.Title(language.English)
49 | return title.String(r(s, adjectives)) + " " + title.String(r(s, animals))
50 | }
51 |
52 | func NewRoomName(s *rand.Rand) string {
53 | return r(s, adjectives) + "-" + r(s, colors) + "-" + r(s, animals)
54 | }
55 |
--------------------------------------------------------------------------------
/ws/client.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | import (
4 | "fmt"
5 | "net"
6 | "net/http"
7 | "strings"
8 | "time"
9 |
10 | "github.com/gorilla/websocket"
11 | "github.com/rs/xid"
12 | "github.com/rs/zerolog"
13 | "github.com/rs/zerolog/log"
14 | "github.com/screego/server/ws/outgoing"
15 | )
16 |
17 | var ping = func(conn *websocket.Conn) error {
18 | return conn.WriteMessage(websocket.PingMessage, nil)
19 | }
20 |
21 | var writeJSON = func(conn *websocket.Conn, v interface{}) error {
22 | return conn.WriteJSON(v)
23 | }
24 |
25 | const (
26 | writeWait = 2 * time.Second
27 | )
28 |
29 | type Client struct {
30 | conn *websocket.Conn
31 | info ClientInfo
32 | once once
33 | read chan<- ClientMessage
34 | }
35 |
36 | type ClientMessage struct {
37 | Info ClientInfo
38 | SkipConnectedCheck bool
39 | Incoming Event
40 | }
41 |
42 | type ClientInfo struct {
43 | ID xid.ID
44 | Authenticated bool
45 | AuthenticatedUser string
46 | Write chan outgoing.Message
47 | Addr net.IP
48 | }
49 |
50 | func newClient(conn *websocket.Conn, req *http.Request, read chan ClientMessage, authenticatedUser string, authenticated, trustProxy bool) *Client {
51 | ip := conn.RemoteAddr().(*net.TCPAddr).IP
52 | if realIP := req.Header.Get("X-Real-IP"); trustProxy && realIP != "" {
53 | ip = net.ParseIP(realIP)
54 | }
55 |
56 | client := &Client{
57 | conn: conn,
58 | info: ClientInfo{
59 | Authenticated: authenticated,
60 | AuthenticatedUser: authenticatedUser,
61 | ID: xid.New(),
62 | Addr: ip,
63 | Write: make(chan outgoing.Message, 1),
64 | },
65 | read: read,
66 | }
67 | client.debug().Msg("WebSocket New Connection")
68 | return client
69 | }
70 |
71 | // CloseOnError closes the connection.
72 | func (c *Client) CloseOnError(code int, reason string) {
73 | c.once.Do(func() {
74 | go func() {
75 | c.read <- ClientMessage{
76 | Info: c.info,
77 | Incoming: &Disconnected{
78 | Code: code,
79 | Reason: reason,
80 | },
81 | }
82 | }()
83 | c.writeCloseMessage(code, reason)
84 | })
85 | }
86 |
87 | func (c *Client) CloseOnDone(code int, reason string) {
88 | c.once.Do(func() {
89 | c.writeCloseMessage(code, reason)
90 | })
91 | }
92 |
93 | func (c *Client) writeCloseMessage(code int, reason string) {
94 | message := websocket.FormatCloseMessage(code, reason)
95 | _ = c.conn.WriteControl(websocket.CloseMessage, message, time.Now().Add(writeWait))
96 | c.conn.Close()
97 | }
98 |
99 | // startWriteHandler starts listening on the client connection. As we do not need anything from the client,
100 | // we ignore incoming messages. Leaves the loop on errors.
101 | func (c *Client) startReading(pongWait time.Duration) {
102 | defer c.CloseOnError(websocket.CloseNormalClosure, "Reader Routine Closed")
103 |
104 | _ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
105 | c.conn.SetPongHandler(func(appData string) error {
106 | _ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
107 | return nil
108 | })
109 | for {
110 | t, m, err := c.conn.NextReader()
111 | if err != nil {
112 | c.CloseOnError(websocket.CloseNormalClosure, "read error: "+err.Error())
113 | return
114 | }
115 | if t == websocket.BinaryMessage {
116 | c.CloseOnError(websocket.CloseUnsupportedData, "unsupported binary message type")
117 | return
118 | }
119 |
120 | incoming, err := ReadTypedIncoming(m)
121 | if err != nil {
122 | c.CloseOnError(websocket.CloseUnsupportedData, fmt.Sprintf("malformed message: %s", err))
123 | return
124 | }
125 | c.debug().Interface("event", fmt.Sprintf("%T", incoming)).Interface("payload", incoming).Msg("WebSocket Receive")
126 | c.read <- ClientMessage{Info: c.info, Incoming: incoming}
127 | }
128 | }
129 |
130 | // startWriteHandler starts the write loop. The method has the following tasks:
131 | // * ping the client in the interval provided as parameter
132 | // * write messages send by the channel to the client
133 | // * on errors exit the loop.
134 | func (c *Client) startWriteHandler(pingPeriod time.Duration) {
135 | pingTicker := time.NewTicker(pingPeriod)
136 | defer pingTicker.Stop()
137 | defer func() {
138 | c.debug().Msg("WebSocket Done")
139 | }()
140 | defer c.conn.Close()
141 | for {
142 | select {
143 | case message := <-c.info.Write:
144 | if msg, ok := message.(outgoing.CloseWriter); ok {
145 | c.debug().Str("reason", msg.Reason).Int("code", msg.Code).Msg("WebSocket Close")
146 | c.CloseOnDone(msg.Code, msg.Reason)
147 | return
148 | }
149 |
150 | _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
151 | typed, err := ToTypedOutgoing(message)
152 | c.debug().Interface("event", typed.Type).Interface("payload", typed.Payload).Msg("WebSocket Send")
153 | if err != nil {
154 | c.debug().Err(err).Msg("could not get typed message, exiting connection.")
155 | c.CloseOnError(websocket.CloseNormalClosure, "malformed outgoing "+err.Error())
156 | continue
157 | }
158 |
159 | if err := writeJSON(c.conn, typed); err != nil {
160 | c.printWebSocketError("write", err)
161 | c.CloseOnError(websocket.CloseNormalClosure, "write error"+err.Error())
162 | }
163 | case <-pingTicker.C:
164 | _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
165 | if err := ping(c.conn); err != nil {
166 | c.printWebSocketError("ping", err)
167 | c.CloseOnError(websocket.CloseNormalClosure, "ping timeout")
168 | }
169 | }
170 | }
171 | }
172 |
173 | func (c *Client) debug() *zerolog.Event {
174 | return log.Debug().Str("id", c.info.ID.String()).Str("ip", c.info.Addr.String())
175 | }
176 |
177 | func (c *Client) printWebSocketError(typex string, err error) {
178 | if strings.Contains(err.Error(), "use of closed network connection") {
179 | return
180 | }
181 | closeError, ok := err.(*websocket.CloseError)
182 |
183 | if ok && closeError != nil && (closeError.Code == 1000 || closeError.Code == 1001) {
184 | // normal closure
185 | return
186 | }
187 |
188 | c.debug().Str("type", typex).Err(err).Msg("WebSocket Error")
189 | }
190 |
--------------------------------------------------------------------------------
/ws/event.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | type Event interface {
4 | Execute(*Rooms, ClientInfo) error
5 | }
6 |
--------------------------------------------------------------------------------
/ws/event_clientanswer.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/rs/zerolog/log"
7 | "github.com/screego/server/ws/outgoing"
8 | )
9 |
10 | func init() {
11 | register("clientanswer", func() Event {
12 | return &ClientAnswer{}
13 | })
14 | }
15 |
16 | type ClientAnswer outgoing.P2PMessage
17 |
18 | func (e *ClientAnswer) Execute(rooms *Rooms, current ClientInfo) error {
19 | room, err := rooms.CurrentRoom(current)
20 | if err != nil {
21 | return err
22 | }
23 |
24 | session, ok := room.Sessions[e.SID]
25 |
26 | if !ok {
27 | log.Debug().Str("id", e.SID.String()).Msg("unknown session")
28 | return nil
29 | }
30 |
31 | if session.Client != current.ID {
32 | return fmt.Errorf("permission denied for session %s", e.SID)
33 | }
34 |
35 | room.Users[session.Host].WriteTimeout(outgoing.ClientAnswer(*e))
36 |
37 | return nil
38 | }
39 |
--------------------------------------------------------------------------------
/ws/event_clientice.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/rs/zerolog/log"
7 | "github.com/screego/server/ws/outgoing"
8 | )
9 |
10 | func init() {
11 | register("clientice", func() Event {
12 | return &ClientICE{}
13 | })
14 | }
15 |
16 | type ClientICE outgoing.P2PMessage
17 |
18 | func (e *ClientICE) Execute(rooms *Rooms, current ClientInfo) error {
19 | room, err := rooms.CurrentRoom(current)
20 | if err != nil {
21 | return err
22 | }
23 |
24 | session, ok := room.Sessions[e.SID]
25 |
26 | if !ok {
27 | log.Debug().Str("id", e.SID.String()).Msg("unknown session")
28 | return nil
29 | }
30 |
31 | if session.Client != current.ID {
32 | return fmt.Errorf("permission denied for session %s", e.SID)
33 | }
34 |
35 | room.Users[session.Host].WriteTimeout(outgoing.ClientICE(*e))
36 |
37 | return nil
38 | }
39 |
--------------------------------------------------------------------------------
/ws/event_connected.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | type Connected struct{}
4 |
5 | func (e Connected) Execute(rooms *Rooms, current ClientInfo) error {
6 | rooms.connected[current.ID] = ""
7 | return nil
8 | }
9 |
--------------------------------------------------------------------------------
/ws/event_create.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 |
7 | "github.com/rs/xid"
8 | "github.com/screego/server/config"
9 | )
10 |
11 | func init() {
12 | register("create", func() Event {
13 | return &Create{}
14 | })
15 | }
16 |
17 | type Create struct {
18 | ID string `json:"id"`
19 | Mode ConnectionMode `json:"mode"`
20 | CloseOnOwnerLeave bool `json:"closeOnOwnerLeave"`
21 | UserName string `json:"username"`
22 | JoinIfExist bool `json:"joinIfExist,omitempty"`
23 | }
24 |
25 | func (e *Create) Execute(rooms *Rooms, current ClientInfo) error {
26 | if rooms.connected[current.ID] != "" {
27 | return fmt.Errorf("cannot join room, you are already in one")
28 | }
29 |
30 | if _, ok := rooms.Rooms[e.ID]; ok {
31 | if e.JoinIfExist {
32 | join := &Join{UserName: e.UserName, ID: e.ID}
33 | return join.Execute(rooms, current)
34 | }
35 |
36 | return fmt.Errorf("room with id %s does already exist", e.ID)
37 | }
38 |
39 | name := e.UserName
40 | if current.Authenticated {
41 | name = current.AuthenticatedUser
42 | }
43 | if name == "" {
44 | name = rooms.RandUserName()
45 | }
46 |
47 | switch rooms.config.AuthMode {
48 | case config.AuthModeNone:
49 | case config.AuthModeAll:
50 | if !current.Authenticated {
51 | return errors.New("you need to login")
52 | }
53 | case config.AuthModeTurn:
54 | if e.Mode != ConnectionSTUN && e.Mode != ConnectionLocal && !current.Authenticated {
55 | return errors.New("you need to login")
56 | }
57 | default:
58 | return errors.New("invalid authmode:" + rooms.config.AuthMode)
59 | }
60 |
61 | room := &Room{
62 | ID: e.ID,
63 | CloseOnOwnerLeave: e.CloseOnOwnerLeave,
64 | Mode: e.Mode,
65 | Sessions: map[xid.ID]*RoomSession{},
66 | Users: map[xid.ID]*User{
67 | current.ID: {
68 | ID: current.ID,
69 | Name: name,
70 | Streaming: false,
71 | Owner: true,
72 | Addr: current.Addr,
73 | _write: current.Write,
74 | },
75 | },
76 | }
77 | rooms.connected[current.ID] = room.ID
78 | rooms.Rooms[e.ID] = room
79 | room.notifyInfoChanged()
80 | usersJoinedTotal.Inc()
81 | roomsCreatedTotal.Inc()
82 | return nil
83 | }
84 |
--------------------------------------------------------------------------------
/ws/event_disconnected.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | import (
4 | "bytes"
5 |
6 | "github.com/gorilla/websocket"
7 | "github.com/screego/server/ws/outgoing"
8 | )
9 |
10 | type Disconnected struct {
11 | Code int
12 | Reason string
13 | }
14 |
15 | func (e *Disconnected) Execute(rooms *Rooms, current ClientInfo) error {
16 | e.executeNoError(rooms, current)
17 | return nil
18 | }
19 |
20 | func (e *Disconnected) executeNoError(rooms *Rooms, current ClientInfo) {
21 | roomID := rooms.connected[current.ID]
22 | delete(rooms.connected, current.ID)
23 | writeTimeout[outgoing.Message](current.Write, outgoing.CloseWriter{Code: e.Code, Reason: e.Reason})
24 |
25 | if roomID == "" {
26 | return
27 | }
28 |
29 | room, ok := rooms.Rooms[roomID]
30 | if !ok {
31 | // room may already be removed
32 | return
33 | }
34 |
35 | user, ok := room.Users[current.ID]
36 |
37 | if !ok {
38 | // room may already be removed
39 | return
40 | }
41 |
42 | delete(room.Users, current.ID)
43 | usersLeftTotal.Inc()
44 |
45 | for id, session := range room.Sessions {
46 | if bytes.Equal(session.Client.Bytes(), current.ID.Bytes()) {
47 | host, ok := room.Users[session.Host]
48 | if ok {
49 | host.WriteTimeout(outgoing.EndShare(id))
50 | }
51 | room.closeSession(rooms, id)
52 | }
53 | if bytes.Equal(session.Host.Bytes(), current.ID.Bytes()) {
54 | client, ok := room.Users[session.Client]
55 | if ok {
56 | client.WriteTimeout(outgoing.EndShare(id))
57 | }
58 | room.closeSession(rooms, id)
59 | }
60 | }
61 |
62 | if user.Owner && room.CloseOnOwnerLeave {
63 | for _, member := range room.Users {
64 | delete(rooms.connected, member.ID)
65 | member.WriteTimeout(outgoing.CloseWriter{Code: websocket.CloseNormalClosure, Reason: CloseOwnerLeft})
66 | }
67 | rooms.closeRoom(roomID)
68 | return
69 | }
70 |
71 | if len(room.Users) == 0 {
72 | rooms.closeRoom(roomID)
73 | return
74 | }
75 |
76 | room.notifyInfoChanged()
77 | }
78 |
--------------------------------------------------------------------------------
/ws/event_health.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | type Health struct {
4 | Response chan int
5 | }
6 |
7 | func (e *Health) Execute(rooms *Rooms, current ClientInfo) error {
8 | writeTimeout(e.Response, len(rooms.connected))
9 | return nil
10 | }
11 |
--------------------------------------------------------------------------------
/ws/event_hostice.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/rs/zerolog/log"
7 | "github.com/screego/server/ws/outgoing"
8 | )
9 |
10 | func init() {
11 | register("hostice", func() Event {
12 | return &HostICE{}
13 | })
14 | }
15 |
16 | type HostICE outgoing.P2PMessage
17 |
18 | func (e *HostICE) Execute(rooms *Rooms, current ClientInfo) error {
19 | room, err := rooms.CurrentRoom(current)
20 | if err != nil {
21 | return err
22 | }
23 |
24 | session, ok := room.Sessions[e.SID]
25 |
26 | if !ok {
27 | log.Debug().Str("id", e.SID.String()).Msg("unknown session")
28 | return nil
29 | }
30 |
31 | if session.Host != current.ID {
32 | return fmt.Errorf("permission denied for session %s", e.SID)
33 | }
34 |
35 | room.Users[session.Client].WriteTimeout(outgoing.HostICE(*e))
36 |
37 | return nil
38 | }
39 |
--------------------------------------------------------------------------------
/ws/event_hostoffer.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/rs/zerolog/log"
7 | "github.com/screego/server/ws/outgoing"
8 | )
9 |
10 | func init() {
11 | register("hostoffer", func() Event {
12 | return &HostOffer{}
13 | })
14 | }
15 |
16 | type HostOffer outgoing.P2PMessage
17 |
18 | func (e *HostOffer) Execute(rooms *Rooms, current ClientInfo) error {
19 | room, err := rooms.CurrentRoom(current)
20 | if err != nil {
21 | return err
22 | }
23 |
24 | session, ok := room.Sessions[e.SID]
25 |
26 | if !ok {
27 | log.Debug().Str("id", e.SID.String()).Msg("unknown session")
28 | return nil
29 | }
30 |
31 | if session.Host != current.ID {
32 | return fmt.Errorf("permission denied for session %s", e.SID)
33 | }
34 |
35 | room.Users[session.Client].WriteTimeout(outgoing.HostOffer(*e))
36 |
37 | return nil
38 | }
39 |
--------------------------------------------------------------------------------
/ws/event_join.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | import (
4 | "fmt"
5 | )
6 |
7 | func init() {
8 | register("join", func() Event {
9 | return &Join{}
10 | })
11 | }
12 |
13 | type Join struct {
14 | ID string `json:"id"`
15 | UserName string `json:"username,omitempty"`
16 | }
17 |
18 | func (e *Join) Execute(rooms *Rooms, current ClientInfo) error {
19 | if rooms.connected[current.ID] != "" {
20 | return fmt.Errorf("cannot join room, you are already in one")
21 | }
22 |
23 | room, ok := rooms.Rooms[e.ID]
24 | if !ok {
25 | return fmt.Errorf("room with id %s does not exist", e.ID)
26 | }
27 | name := e.UserName
28 | if current.Authenticated {
29 | name = current.AuthenticatedUser
30 | }
31 | if name == "" {
32 | name = rooms.RandUserName()
33 | }
34 |
35 | room.Users[current.ID] = &User{
36 | ID: current.ID,
37 | Name: name,
38 | Streaming: false,
39 | Owner: false,
40 | Addr: current.Addr,
41 | _write: current.Write,
42 | }
43 | rooms.connected[current.ID] = room.ID
44 | room.notifyInfoChanged()
45 | usersJoinedTotal.Inc()
46 |
47 | v4, v6, err := rooms.config.TurnIPProvider.Get()
48 | if err != nil {
49 | return err
50 | }
51 |
52 | for _, user := range room.Users {
53 | if current.ID == user.ID || !user.Streaming {
54 | continue
55 | }
56 | room.newSession(user.ID, current.ID, rooms, v4, v6)
57 | }
58 |
59 | return nil
60 | }
61 |
--------------------------------------------------------------------------------
/ws/event_name.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | func init() {
4 | register("name", func() Event {
5 | return &Name{}
6 | })
7 | }
8 |
9 | type Name struct {
10 | UserName string `json:"username"`
11 | }
12 |
13 | func (e *Name) Execute(rooms *Rooms, current ClientInfo) error {
14 | room, err := rooms.CurrentRoom(current)
15 | if err != nil {
16 | return err
17 | }
18 |
19 | room.Users[current.ID].Name = e.UserName
20 |
21 | room.notifyInfoChanged()
22 | return nil
23 | }
24 |
--------------------------------------------------------------------------------
/ws/event_share.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | func init() {
4 | register("share", func() Event {
5 | return &StartShare{}
6 | })
7 | }
8 |
9 | type StartShare struct{}
10 |
11 | func (e *StartShare) Execute(rooms *Rooms, current ClientInfo) error {
12 | room, err := rooms.CurrentRoom(current)
13 | if err != nil {
14 | return err
15 | }
16 |
17 | room.Users[current.ID].Streaming = true
18 |
19 | v4, v6, err := rooms.config.TurnIPProvider.Get()
20 | if err != nil {
21 | return err
22 | }
23 |
24 | for _, user := range room.Users {
25 | if current.ID == user.ID {
26 | continue
27 | }
28 | room.newSession(current.ID, user.ID, rooms, v4, v6)
29 | }
30 |
31 | room.notifyInfoChanged()
32 | return nil
33 | }
34 |
--------------------------------------------------------------------------------
/ws/event_stop_share.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | import (
4 | "bytes"
5 |
6 | "github.com/screego/server/ws/outgoing"
7 | )
8 |
9 | func init() {
10 | register("stopshare", func() Event {
11 | return &StopShare{}
12 | })
13 | }
14 |
15 | type StopShare struct{}
16 |
17 | func (e *StopShare) Execute(rooms *Rooms, current ClientInfo) error {
18 | room, err := rooms.CurrentRoom(current)
19 | if err != nil {
20 | return err
21 | }
22 |
23 | room.Users[current.ID].Streaming = false
24 | for id, session := range room.Sessions {
25 | if bytes.Equal(session.Host.Bytes(), current.ID.Bytes()) {
26 | client, ok := room.Users[session.Client]
27 | if ok {
28 | client.WriteTimeout(outgoing.EndShare(id))
29 | }
30 | room.closeSession(rooms, id)
31 | }
32 | }
33 |
34 | room.notifyInfoChanged()
35 | return nil
36 | }
37 |
--------------------------------------------------------------------------------
/ws/once.go:
--------------------------------------------------------------------------------
1 | // Copyright 2009 The Go Authors. All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 |
5 | package ws
6 |
7 | import (
8 | "sync"
9 | "sync/atomic"
10 | )
11 |
12 | // Modified version of sync.Once (https://github.com/golang/go/blob/master/src/sync/once.go)
13 | // This version unlocks the mutex early and therefore doesn't hold the lock while executing func f().
14 | type once struct {
15 | m sync.Mutex
16 | done uint32
17 | }
18 |
19 | func (o *once) Do(f func()) {
20 | if atomic.LoadUint32(&o.done) == 1 {
21 | return
22 | }
23 | if o.mayExecute() {
24 | f()
25 | }
26 | }
27 |
28 | func (o *once) mayExecute() bool {
29 | o.m.Lock()
30 | defer o.m.Unlock()
31 | if o.done == 0 {
32 | atomic.StoreUint32(&o.done, 1)
33 | return true
34 | }
35 | return false
36 | }
37 |
--------------------------------------------------------------------------------
/ws/once_test.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func Test_Execute(t *testing.T) {
11 | executeOnce := once{}
12 | execution := make(chan struct{})
13 | fExecute := func() {
14 | execution <- struct{}{}
15 | }
16 | go executeOnce.Do(fExecute)
17 | go executeOnce.Do(fExecute)
18 |
19 | select {
20 | case <-execution:
21 | // expected
22 | case <-time.After(100 * time.Millisecond):
23 | t.Fatal("fExecute should be executed once")
24 | }
25 |
26 | select {
27 | case <-execution:
28 | t.Fatal("should only execute once")
29 | case <-time.After(100 * time.Millisecond):
30 | // expected
31 | }
32 |
33 | assert.False(t, executeOnce.mayExecute())
34 |
35 | go executeOnce.Do(fExecute)
36 |
37 | select {
38 | case <-execution:
39 | t.Fatal("should only execute once")
40 | case <-time.After(100 * time.Millisecond):
41 | // expected
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/ws/outgoing/messages.go:
--------------------------------------------------------------------------------
1 | package outgoing
2 |
3 | import (
4 | "encoding/json"
5 |
6 | "github.com/rs/xid"
7 | )
8 |
9 | type Message interface {
10 | Type() string
11 | }
12 |
13 | type Room struct {
14 | ID string `json:"id"`
15 | Mode ConnectionMode `json:"mode"`
16 | Users []User `json:"users"`
17 | }
18 |
19 | type User struct {
20 | ID xid.ID `json:"id"`
21 | Name string `json:"name"`
22 | Streaming bool `json:"streaming"`
23 | You bool `json:"you"`
24 | Owner bool `json:"owner"`
25 | }
26 |
27 | func (Room) Type() string {
28 | return "room"
29 | }
30 |
31 | type HostSession struct {
32 | ID xid.ID `json:"id"`
33 | Peer xid.ID `json:"peer"`
34 | ICEServers []ICEServer `json:"iceServers"`
35 | }
36 |
37 | func (HostSession) Type() string {
38 | return "hostsession"
39 | }
40 |
41 | type ClientSession struct {
42 | ID xid.ID `json:"id"`
43 | Peer xid.ID `json:"peer"`
44 | ICEServers []ICEServer `json:"iceServers"`
45 | }
46 |
47 | func (ClientSession) Type() string {
48 | return "clientsession"
49 | }
50 |
51 | type ICEServer struct {
52 | URLs []string `json:"urls"`
53 | Credential string `json:"credential"`
54 | Username string `json:"username"`
55 | }
56 |
57 | type P2PMessage struct {
58 | SID xid.ID `json:"sid"`
59 | Value json.RawMessage `json:"value"`
60 | }
61 |
62 | type HostICE P2PMessage
63 |
64 | func (HostICE) Type() string {
65 | return "hostice"
66 | }
67 |
68 | type ClientICE P2PMessage
69 |
70 | func (ClientICE) Type() string {
71 | return "clientice"
72 | }
73 |
74 | type ClientAnswer P2PMessage
75 |
76 | func (ClientAnswer) Type() string {
77 | return "clientanswer"
78 | }
79 |
80 | type HostOffer P2PMessage
81 |
82 | func (HostOffer) Type() string {
83 | return "hostoffer"
84 | }
85 |
86 | type EndShare xid.ID
87 |
88 | func (EndShare) Type() string {
89 | return "endshare"
90 | }
91 |
92 | type ConnectionMode string
93 |
94 | const (
95 | ConnectionLocal ConnectionMode = "local"
96 | ConnectionSTUN ConnectionMode = "stun"
97 | ConnectionTURN ConnectionMode = "turn"
98 | )
99 |
100 | type CloseWriter struct {
101 | Code int
102 | Reason string
103 | }
104 |
105 | func (CloseWriter) Type() string {
106 | return "closewriter"
107 | }
108 |
--------------------------------------------------------------------------------
/ws/prometheus.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | import (
4 | "github.com/prometheus/client_golang/prometheus"
5 | "github.com/prometheus/client_golang/prometheus/promauto"
6 | )
7 |
8 | var (
9 | roomsCreatedTotal = promauto.NewCounter(prometheus.CounterOpts{
10 | Name: "screego_room_created_total",
11 | Help: "The total number of rooms created",
12 | })
13 | roomsClosedTotal = promauto.NewCounter(prometheus.CounterOpts{
14 | Name: "screego_room_closed_total",
15 | Help: "The total number of rooms closed",
16 | })
17 | usersJoinedTotal = promauto.NewCounter(prometheus.CounterOpts{
18 | Name: "screego_user_joined_total",
19 | Help: "The total number of users joined",
20 | })
21 | usersLeftTotal = promauto.NewCounter(prometheus.CounterOpts{
22 | Name: "screego_user_left_total",
23 | Help: "The total number of users left",
24 | })
25 | sessionCreatedTotal = promauto.NewCounter(prometheus.CounterOpts{
26 | Name: "screego_session_created_total",
27 | Help: "The total number of sessions created",
28 | })
29 | sessionClosedTotal = promauto.NewCounter(prometheus.CounterOpts{
30 | Name: "screego_session_closed_total",
31 | Help: "The total number of sessions closed",
32 | })
33 | )
34 |
--------------------------------------------------------------------------------
/ws/readwrite.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "io"
8 |
9 | "github.com/screego/server/ws/outgoing"
10 | )
11 |
12 | type Typed struct {
13 | Type string `json:"type"`
14 | Payload json.RawMessage `json:"payload"`
15 | }
16 |
17 | func ToTypedOutgoing(outgoing outgoing.Message) (Typed, error) {
18 | payload, err := json.Marshal(outgoing)
19 | if err != nil {
20 | return Typed{}, err
21 | }
22 | return Typed{
23 | Type: outgoing.Type(),
24 | Payload: payload,
25 | }, nil
26 | }
27 |
28 | func ReadTypedIncoming(r io.Reader) (Event, error) {
29 | typed := Typed{}
30 | if err := json.NewDecoder(r).Decode(&typed); err != nil {
31 | return nil, fmt.Errorf("%s e", err)
32 | }
33 |
34 | create, ok := provider[typed.Type]
35 |
36 | if !ok {
37 | return nil, errors.New("cannot handle " + typed.Type)
38 | }
39 |
40 | payload := create()
41 |
42 | if err := json.Unmarshal(typed.Payload, payload); err != nil {
43 | return nil, fmt.Errorf("incoming payload %s", err)
44 | }
45 | return payload, nil
46 | }
47 |
48 | var provider = map[string]func() Event{}
49 |
50 | func register(t string, incoming func() Event) {
51 | provider[t] = incoming
52 | }
53 |
--------------------------------------------------------------------------------
/ws/room.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | import (
4 | "fmt"
5 | "net"
6 | "sort"
7 | "time"
8 |
9 | "github.com/rs/xid"
10 | "github.com/rs/zerolog/log"
11 | "github.com/screego/server/config"
12 | "github.com/screego/server/ws/outgoing"
13 | )
14 |
15 | type ConnectionMode string
16 |
17 | const (
18 | ConnectionLocal ConnectionMode = "local"
19 | ConnectionSTUN ConnectionMode = "stun"
20 | ConnectionTURN ConnectionMode = config.AuthModeTurn
21 | )
22 |
23 | type Room struct {
24 | ID string
25 | CloseOnOwnerLeave bool
26 | Mode ConnectionMode
27 | Users map[xid.ID]*User
28 | Sessions map[xid.ID]*RoomSession
29 | }
30 |
31 | const (
32 | CloseOwnerLeft = "Owner Left"
33 | CloseDone = "Read End"
34 | )
35 |
36 | func (r *Room) newSession(host, client xid.ID, rooms *Rooms, v4, v6 net.IP) {
37 | id := xid.New()
38 | r.Sessions[id] = &RoomSession{
39 | Host: host,
40 | Client: client,
41 | }
42 | sessionCreatedTotal.Inc()
43 |
44 | iceHost := []outgoing.ICEServer{}
45 | iceClient := []outgoing.ICEServer{}
46 | switch r.Mode {
47 | case ConnectionLocal:
48 | case ConnectionSTUN:
49 | iceHost = []outgoing.ICEServer{{URLs: rooms.addresses("stun", v4, v6, false)}}
50 | iceClient = []outgoing.ICEServer{{URLs: rooms.addresses("stun", v4, v6, false)}}
51 | case ConnectionTURN:
52 | hostName, hostPW := rooms.turnServer.Credentials(id.String()+"host", r.Users[host].Addr)
53 | clientName, clientPW := rooms.turnServer.Credentials(id.String()+"client", r.Users[client].Addr)
54 | iceHost = []outgoing.ICEServer{{
55 | URLs: rooms.addresses("turn", v4, v6, true),
56 | Credential: hostPW,
57 | Username: hostName,
58 | }}
59 | iceClient = []outgoing.ICEServer{{
60 | URLs: rooms.addresses("turn", v4, v6, true),
61 | Credential: clientPW,
62 | Username: clientName,
63 | }}
64 | }
65 | r.Users[host].WriteTimeout(outgoing.HostSession{Peer: client, ID: id, ICEServers: iceHost})
66 | r.Users[client].WriteTimeout(outgoing.ClientSession{Peer: host, ID: id, ICEServers: iceClient})
67 | }
68 |
69 | func (r *Rooms) addresses(prefix string, v4, v6 net.IP, tcp bool) (result []string) {
70 | if v4 != nil {
71 | result = append(result, fmt.Sprintf("%s:%s:%s", prefix, v4.String(), r.config.TurnPort))
72 | if tcp {
73 | result = append(result, fmt.Sprintf("%s:%s:%s?transport=tcp", prefix, v4.String(), r.config.TurnPort))
74 | }
75 | }
76 | if v6 != nil {
77 | result = append(result, fmt.Sprintf("%s:[%s]:%s", prefix, v6.String(), r.config.TurnPort))
78 | if tcp {
79 | result = append(result, fmt.Sprintf("%s:[%s]:%s?transport=tcp", prefix, v6.String(), r.config.TurnPort))
80 | }
81 | }
82 | return
83 | }
84 |
85 | func (r *Room) closeSession(rooms *Rooms, id xid.ID) {
86 | if r.Mode == ConnectionTURN {
87 | rooms.turnServer.Disallow(id.String() + "host")
88 | rooms.turnServer.Disallow(id.String() + "client")
89 | }
90 | delete(r.Sessions, id)
91 | sessionClosedTotal.Inc()
92 | }
93 |
94 | type RoomSession struct {
95 | Host xid.ID
96 | Client xid.ID
97 | }
98 |
99 | func (r *Room) notifyInfoChanged() {
100 | for _, current := range r.Users {
101 | users := []outgoing.User{}
102 | for _, user := range r.Users {
103 | users = append(users, outgoing.User{
104 | ID: user.ID,
105 | Name: user.Name,
106 | Streaming: user.Streaming,
107 | You: current == user,
108 | Owner: user.Owner,
109 | })
110 | }
111 |
112 | sort.Slice(users, func(i, j int) bool {
113 | left := users[i]
114 | right := users[j]
115 |
116 | if left.Owner != right.Owner {
117 | return left.Owner
118 | }
119 |
120 | if left.Streaming != right.Streaming {
121 | return left.Streaming
122 | }
123 |
124 | return left.Name < right.Name
125 | })
126 |
127 | current.WriteTimeout(outgoing.Room{
128 | ID: r.ID,
129 | Users: users,
130 | })
131 | }
132 | }
133 |
134 | type User struct {
135 | ID xid.ID
136 | Addr net.IP
137 | Name string
138 | Streaming bool
139 | Owner bool
140 | _write chan<- outgoing.Message
141 | }
142 |
143 | func (u *User) WriteTimeout(msg outgoing.Message) {
144 | writeTimeout(u._write, msg)
145 | }
146 |
147 | func writeTimeout[T any](ch chan<- T, msg T) {
148 | select {
149 | case <-time.After(2 * time.Second):
150 | log.Warn().Interface("event", fmt.Sprintf("%T", msg)).Interface("payload", msg).Msg("Client write loop didn't accept the message.")
151 | case ch <- msg:
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/ws/rooms.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | import (
4 | "fmt"
5 | "math/rand"
6 | "net/http"
7 | "net/url"
8 | "time"
9 |
10 | "github.com/gorilla/websocket"
11 | "github.com/rs/xid"
12 | "github.com/rs/zerolog/log"
13 | "github.com/screego/server/auth"
14 | "github.com/screego/server/config"
15 | "github.com/screego/server/turn"
16 | "github.com/screego/server/util"
17 | )
18 |
19 | func NewRooms(tServer turn.Server, users *auth.Users, conf config.Config) *Rooms {
20 | return &Rooms{
21 | Rooms: map[string]*Room{},
22 | Incoming: make(chan ClientMessage),
23 | connected: map[xid.ID]string{},
24 | turnServer: tServer,
25 | users: users,
26 | config: conf,
27 | r: rand.New(rand.NewSource(time.Now().Unix())),
28 | upgrader: websocket.Upgrader{
29 | ReadBufferSize: 1024,
30 | WriteBufferSize: 1024,
31 | CheckOrigin: func(r *http.Request) bool {
32 | origin := r.Header.Get("origin")
33 | u, err := url.Parse(origin)
34 | if err != nil {
35 | return false
36 | }
37 | if u.Host == r.Host {
38 | return true
39 | }
40 | return conf.CheckOrigin(origin)
41 | },
42 | },
43 | }
44 | }
45 |
46 | type Rooms struct {
47 | turnServer turn.Server
48 | Rooms map[string]*Room
49 | Incoming chan ClientMessage
50 | upgrader websocket.Upgrader
51 | users *auth.Users
52 | config config.Config
53 | r *rand.Rand
54 | connected map[xid.ID]string
55 | }
56 |
57 | func (r *Rooms) CurrentRoom(info ClientInfo) (*Room, error) {
58 | roomID, ok := r.connected[info.ID]
59 | if !ok {
60 | return nil, fmt.Errorf("not connected")
61 | }
62 | if roomID == "" {
63 | return nil, fmt.Errorf("not in a room")
64 | }
65 | room, ok := r.Rooms[roomID]
66 | if !ok {
67 | return nil, fmt.Errorf("room with id %s does not exist", roomID)
68 | }
69 |
70 | return room, nil
71 | }
72 |
73 | func (r *Rooms) RandUserName() string {
74 | return util.NewUserName(r.r)
75 | }
76 |
77 | func (r *Rooms) RandRoomName() string {
78 | return util.NewRoomName(r.r)
79 | }
80 |
81 | func (r *Rooms) Upgrade(w http.ResponseWriter, req *http.Request) {
82 | conn, err := r.upgrader.Upgrade(w, req, nil)
83 | if err != nil {
84 | log.Debug().Err(err).Msg("Websocket upgrade")
85 | w.WriteHeader(400)
86 | _, _ = w.Write([]byte(fmt.Sprintf("Upgrade failed %s", err)))
87 | return
88 | }
89 |
90 | user, loggedIn := r.users.CurrentUser(req)
91 | c := newClient(conn, req, r.Incoming, user, loggedIn, r.config.TrustProxyHeaders)
92 | r.Incoming <- ClientMessage{Info: c.info, Incoming: Connected{}, SkipConnectedCheck: true}
93 |
94 | go c.startReading(time.Second * 20)
95 | go c.startWriteHandler(time.Second * 5)
96 | }
97 |
98 | func (r *Rooms) Start() {
99 | for msg := range r.Incoming {
100 | _, connected := r.connected[msg.Info.ID]
101 | if !msg.SkipConnectedCheck && !connected {
102 | log.Debug().Interface("event", fmt.Sprintf("%T", msg.Incoming)).Interface("payload", msg.Incoming).Msg("WebSocket Ignore")
103 | continue
104 | }
105 |
106 | if err := msg.Incoming.Execute(r, msg.Info); err != nil {
107 | dis := Disconnected{Code: websocket.CloseNormalClosure, Reason: err.Error()}
108 | dis.executeNoError(r, msg.Info)
109 | }
110 | }
111 | }
112 |
113 | func (r *Rooms) Count() (int, string) {
114 | timeout := time.After(5 * time.Second)
115 |
116 | h := Health{Response: make(chan int, 1)}
117 | select {
118 | case r.Incoming <- ClientMessage{SkipConnectedCheck: true, Incoming: &h}:
119 | case <-timeout:
120 | return -1, "main loop didn't accept a message within 5 second"
121 | }
122 | select {
123 | case count := <-h.Response:
124 | return count, ""
125 | case <-timeout:
126 | return -1, "main loop didn't respond to a message within 5 second"
127 | }
128 | }
129 |
130 | func (r *Rooms) closeRoom(roomID string) {
131 | room, ok := r.Rooms[roomID]
132 | if !ok {
133 | return
134 | }
135 | usersLeftTotal.Add(float64(len(room.Users)))
136 | for id := range room.Sessions {
137 | room.closeSession(r, id)
138 | }
139 |
140 | delete(r.Rooms, roomID)
141 | roomsClosedTotal.Inc()
142 | }
143 |
--------------------------------------------------------------------------------
/ws/rooms_test.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "math/rand"
7 | "sync"
8 | "testing"
9 | "time"
10 |
11 | "github.com/gorilla/websocket"
12 | "github.com/rs/xid"
13 | )
14 |
15 | const SERVER = "ws://localhost:5050/stream"
16 |
17 | func TestMultipleClients(t *testing.T) {
18 | t.Skip("only for manual testing")
19 | r := rand.New(rand.NewSource(time.Now().UnixMicro()))
20 |
21 | var wg sync.WaitGroup
22 |
23 | for j := 0; j < 100; j++ {
24 | name := fmt.Sprint(1)
25 |
26 | users := r.Intn(5000)
27 | for i := 0; i < users; i++ {
28 | wg.Add(1)
29 | go func() {
30 | defer wg.Done()
31 | testClient(r.Int63(), name)
32 | }()
33 | if i%100 == 0 {
34 | time.Sleep(10 * time.Millisecond)
35 | }
36 | }
37 | time.Sleep(50 * time.Millisecond)
38 | }
39 |
40 | wg.Wait()
41 | }
42 |
43 | func testClient(i int64, room string) {
44 | r := rand.New(rand.NewSource(i))
45 | conn, _, err := websocket.DefaultDialer.Dial(SERVER, nil)
46 | if err != nil {
47 | panic(err)
48 | }
49 | go func() {
50 | for {
51 | _ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
52 | _, _, err := conn.ReadMessage()
53 | if err != nil {
54 | return
55 | }
56 | }
57 | }()
58 | defer conn.Close()
59 |
60 | ops := r.Intn(100)
61 | for i := 0; i < ops; i++ {
62 | m := msg(r, room)
63 | err = conn.WriteMessage(websocket.TextMessage, m)
64 | if err != nil {
65 | fmt.Println("err", err)
66 | }
67 | time.Sleep(30 * time.Millisecond)
68 | }
69 | }
70 |
71 | func msg(r *rand.Rand, room string) []byte {
72 | typed := Typed{}
73 | var e Event
74 | switch r.Intn(8) {
75 | case 0:
76 | typed.Type = "clientanswer"
77 | e = &ClientAnswer{SID: xid.New(), Value: nil}
78 | case 1:
79 | typed.Type = "clientice"
80 | e = &ClientICE{SID: xid.New(), Value: nil}
81 | case 2:
82 | typed.Type = "hostice"
83 | e = &HostICE{SID: xid.New(), Value: nil}
84 | case 3:
85 | typed.Type = "hostoffer"
86 | e = &HostOffer{SID: xid.New(), Value: nil}
87 | case 4:
88 | typed.Type = "name"
89 | e = &Name{UserName: "a"}
90 | case 5:
91 | typed.Type = "share"
92 | e = &StartShare{}
93 | case 6:
94 | typed.Type = "stopshare"
95 | e = &StopShare{}
96 | case 7:
97 | typed.Type = "create"
98 | e = &Create{ID: room, CloseOnOwnerLeave: r.Intn(2) == 0, JoinIfExist: r.Intn(2) == 0, Mode: ConnectionSTUN, UserName: "hello"}
99 | }
100 |
101 | b, err := json.Marshal(e)
102 | if err != nil {
103 | panic(err)
104 | }
105 | typed.Payload = json.RawMessage(b)
106 |
107 | b, err = json.Marshal(typed)
108 | if err != nil {
109 | panic(err)
110 | }
111 | return b
112 | }
113 |
--------------------------------------------------------------------------------