├── .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 | Build Status 14 | 15 | 16 | Build Status 17 | 18 | 19 | Go Report Card 20 | 21 | 22 | Docker Pulls 23 | 24 | 25 | latest release 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 |
30 | 31 |
32 |
33 | Login to Screego 34 | {hide ? ( 35 | 38 | ) : undefined} 39 |
40 | setUser(e.target.value)} 44 | label="Username" 45 | size="small" 46 | margin="dense" 47 | /> 48 | setPass(e.target.value)} 53 | label="Password" 54 | size="small" 55 | margin="dense" 56 | /> 57 | 58 | 65 | Login 66 | 67 | 68 | 69 |
70 |
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 |
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 | 321 | ); 322 | })} 323 | {state.hostStream && selectedStream !== HostStream && ( 324 | setSelectedStream(HostStream)} 328 | > 329 | 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 | logo 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 | setOpen(false)} maxWidth={'xs'} fullWidth> 52 | Settings 53 | 54 |
55 | 56 | 62 | setSettingsInput((c) => ({...c, name: e.target.value})) 63 | } 64 | fullWidth 65 | /> 66 | 67 | {NativeCodecs.length > 0 ? ( 68 | 69 | 70 | options={[CodecBestQuality, CodecDefault, ...NativeCodecs]} 71 | getOptionLabel={({mimeType, sdpFmtpLine}) => 72 | codecName(mimeType) + (sdpFmtpLine ? ` (${sdpFmtpLine})` : '') 73 | } 74 | value={preferCodec} 75 | isOptionEqualToValue={(a, b) => 76 | a.mimeType === b.mimeType && a.sdpFmtpLine === b.sdpFmtpLine 77 | } 78 | fullWidth 79 | onChange={(_, value) => 80 | setSettingsInput((c) => ({ 81 | ...c, 82 | preferCodec: value ?? undefined, 83 | })) 84 | } 85 | renderInput={(params) => ( 86 | 87 | )} 88 | /> 89 | 90 | ) : undefined} 91 | 92 | 93 | options={Object.values(VideoDisplayMode)} 94 | onChange={(_, value) => 95 | setSettingsInput((c) => ({ 96 | ...c, 97 | displayMode: value ?? VideoDisplayMode.FitToWindow, 98 | })) 99 | } 100 | value={displayMode} 101 | fullWidth 102 | renderInput={(params) => } 103 | /> 104 | 105 | 106 | setSettingsInput((c) => ({...c, framerate}))} 110 | value={framerate} 111 | fullWidth 112 | /> 113 | 114 |
115 |
116 | 117 | 120 | 123 | 124 |
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