├── .github ├── .gitignore ├── fetch-scripts.sh ├── install-hooks.sh └── workflows │ ├── api.yaml │ ├── codeql-analysis.yml │ ├── gh-pages-deploy.yaml │ ├── lint.yaml │ ├── release.yml │ ├── renovate-go-sum-fix.yaml │ ├── reuse.yml │ ├── test.yaml │ └── tidy-check.yaml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── .reuse └── dep5 ├── LICENSE ├── LICENSES └── MIT.txt ├── README.md ├── codecov.yml ├── examples ├── go │ ├── README.md │ └── main.go └── node │ ├── README.md │ └── main.js ├── go.mod ├── go.sum ├── internal ├── result │ ├── errors.go │ ├── media_format_details.go │ ├── media_section_details.go │ ├── peer_details.go │ ├── peerdetails_test.go │ └── result.go └── sdp │ ├── errors.go │ ├── jsep.go │ ├── media_description.go │ ├── scanner.go │ ├── session_description.go │ ├── unmarshal.go │ └── unmarshal_test.go ├── peerconnection_explainer.go ├── peerconnection_explainer_test.go ├── pkg ├── output │ └── output.go └── wasm │ └── main.go ├── renovate.json ├── result.go ├── sessiondescription.go └── web ├── README.md └── index.html /.github/.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 The Pion community 2 | # SPDX-License-Identifier: MIT 3 | 4 | .goassets 5 | -------------------------------------------------------------------------------- /.github/fetch-scripts.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # DO NOT EDIT THIS FILE 5 | # 6 | # It is automatically copied from https://github.com/pion/.goassets repository. 7 | # 8 | # If you want to update the shared CI config, send a PR to 9 | # https://github.com/pion/.goassets instead of this repository. 10 | # 11 | # SPDX-FileCopyrightText: 2023 The Pion community 12 | # SPDX-License-Identifier: MIT 13 | 14 | set -eu 15 | 16 | SCRIPT_PATH="$(realpath "$(dirname "$0")")" 17 | GOASSETS_PATH="${SCRIPT_PATH}/.goassets" 18 | 19 | GOASSETS_REF=${GOASSETS_REF:-master} 20 | 21 | if [ -d "${GOASSETS_PATH}" ]; then 22 | if ! git -C "${GOASSETS_PATH}" diff --exit-code; then 23 | echo "${GOASSETS_PATH} has uncommitted changes" >&2 24 | exit 1 25 | fi 26 | git -C "${GOASSETS_PATH}" fetch origin 27 | git -C "${GOASSETS_PATH}" checkout ${GOASSETS_REF} 28 | git -C "${GOASSETS_PATH}" reset --hard origin/${GOASSETS_REF} 29 | else 30 | git clone -b ${GOASSETS_REF} https://github.com/pion/.goassets.git "${GOASSETS_PATH}" 31 | fi 32 | -------------------------------------------------------------------------------- /.github/install-hooks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # DO NOT EDIT THIS FILE 5 | # 6 | # It is automatically copied from https://github.com/pion/.goassets repository. 7 | # 8 | # If you want to update the shared CI config, send a PR to 9 | # https://github.com/pion/.goassets instead of this repository. 10 | # 11 | # SPDX-FileCopyrightText: 2023 The Pion community 12 | # SPDX-License-Identifier: MIT 13 | 14 | SCRIPT_PATH="$(realpath "$(dirname "$0")")" 15 | 16 | . ${SCRIPT_PATH}/fetch-scripts.sh 17 | 18 | cp "${GOASSETS_PATH}/hooks/commit-msg.sh" "${SCRIPT_PATH}/../.git/hooks/commit-msg" 19 | cp "${GOASSETS_PATH}/hooks/pre-commit.sh" "${SCRIPT_PATH}/../.git/hooks/pre-commit" 20 | cp "${GOASSETS_PATH}/hooks/pre-push.sh" "${SCRIPT_PATH}/../.git/hooks/pre-push" 21 | -------------------------------------------------------------------------------- /.github/workflows/api.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # DO NOT EDIT THIS FILE 3 | # 4 | # It is automatically copied from https://github.com/pion/.goassets repository. 5 | # If this repository should have package specific CI config, 6 | # remove the repository name from .goassets/.github/workflows/assets-sync.yml. 7 | # 8 | # If you want to update the shared CI config, send a PR to 9 | # https://github.com/pion/.goassets instead of this repository. 10 | # 11 | # SPDX-FileCopyrightText: 2023 The Pion community 12 | # SPDX-License-Identifier: MIT 13 | 14 | name: API 15 | on: 16 | pull_request: 17 | 18 | jobs: 19 | check: 20 | uses: pion/.goassets/.github/workflows/api.reusable.yml@master 21 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # 2 | # DO NOT EDIT THIS FILE 3 | # 4 | # It is automatically copied from https://github.com/pion/.goassets repository. 5 | # If this repository should have package specific CI config, 6 | # remove the repository name from .goassets/.github/workflows/assets-sync.yml. 7 | # 8 | # If you want to update the shared CI config, send a PR to 9 | # https://github.com/pion/.goassets instead of this repository. 10 | # 11 | # SPDX-FileCopyrightText: 2023 The Pion community 12 | # SPDX-License-Identifier: MIT 13 | 14 | name: CodeQL 15 | 16 | on: 17 | workflow_dispatch: 18 | schedule: 19 | - cron: '23 5 * * 0' 20 | pull_request: 21 | branches: 22 | - master 23 | paths: 24 | - '**.go' 25 | 26 | jobs: 27 | analyze: 28 | uses: pion/.goassets/.github/workflows/codeql-analysis.reusable.yml@master 29 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages-deploy.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 The Pion community 2 | # SPDX-License-Identifier: MIT 3 | 4 | name: GitHub Pages 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-24.04 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.ref }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | submodules: true 20 | fetch-depth: 0 21 | 22 | - name: Setup Go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version: stable 26 | 27 | - name: Build WASM 28 | run: | 29 | cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" web/ 30 | GOOS=js GOARCH=wasm go build -o web/wasm.wasm ./pkg/wasm/ 31 | 32 | - name: Deploy 33 | uses: peaceiris/actions-gh-pages@v3 34 | if: ${{ github.ref == 'refs/heads/master' }} 35 | with: 36 | github_token: ${{ secrets.PIONBOT_PRIVATE_KEY }} 37 | cname: pe.pion.ly 38 | publish_dir: ./web 39 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # DO NOT EDIT THIS FILE 3 | # 4 | # It is automatically copied from https://github.com/pion/.goassets repository. 5 | # If this repository should have package specific CI config, 6 | # remove the repository name from .goassets/.github/workflows/assets-sync.yml. 7 | # 8 | # If you want to update the shared CI config, send a PR to 9 | # https://github.com/pion/.goassets instead of this repository. 10 | # 11 | # SPDX-FileCopyrightText: 2023 The Pion community 12 | # SPDX-License-Identifier: MIT 13 | 14 | name: Lint 15 | on: 16 | pull_request: 17 | 18 | jobs: 19 | lint: 20 | uses: pion/.goassets/.github/workflows/lint.reusable.yml@master 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # 2 | # DO NOT EDIT THIS FILE 3 | # 4 | # It is automatically copied from https://github.com/pion/.goassets repository. 5 | # If this repository should have package specific CI config, 6 | # remove the repository name from .goassets/.github/workflows/assets-sync.yml. 7 | # 8 | # If you want to update the shared CI config, send a PR to 9 | # https://github.com/pion/.goassets instead of this repository. 10 | # 11 | # SPDX-FileCopyrightText: 2023 The Pion community 12 | # SPDX-License-Identifier: MIT 13 | 14 | name: Release 15 | on: 16 | push: 17 | tags: 18 | - 'v*' 19 | 20 | jobs: 21 | release: 22 | uses: pion/.goassets/.github/workflows/release.reusable.yml@master 23 | with: 24 | go-version: "1.24" # auto-update/latest-go-version 25 | -------------------------------------------------------------------------------- /.github/workflows/renovate-go-sum-fix.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # DO NOT EDIT THIS FILE 3 | # 4 | # It is automatically copied from https://github.com/pion/.goassets repository. 5 | # If this repository should have package specific CI config, 6 | # remove the repository name from .goassets/.github/workflows/assets-sync.yml. 7 | # 8 | # If you want to update the shared CI config, send a PR to 9 | # https://github.com/pion/.goassets instead of this repository. 10 | # 11 | # SPDX-FileCopyrightText: 2023 The Pion community 12 | # SPDX-License-Identifier: MIT 13 | 14 | name: Fix go.sum 15 | on: 16 | push: 17 | branches: 18 | - renovate/* 19 | 20 | jobs: 21 | fix: 22 | uses: pion/.goassets/.github/workflows/renovate-go-sum-fix.reusable.yml@master 23 | secrets: 24 | token: ${{ secrets.PIONBOT_PRIVATE_KEY }} 25 | -------------------------------------------------------------------------------- /.github/workflows/reuse.yml: -------------------------------------------------------------------------------- 1 | # 2 | # DO NOT EDIT THIS FILE 3 | # 4 | # It is automatically copied from https://github.com/pion/.goassets repository. 5 | # If this repository should have package specific CI config, 6 | # remove the repository name from .goassets/.github/workflows/assets-sync.yml. 7 | # 8 | # If you want to update the shared CI config, send a PR to 9 | # https://github.com/pion/.goassets instead of this repository. 10 | # 11 | # SPDX-FileCopyrightText: 2023 The Pion community 12 | # SPDX-License-Identifier: MIT 13 | 14 | name: REUSE Compliance Check 15 | 16 | on: 17 | push: 18 | pull_request: 19 | 20 | jobs: 21 | lint: 22 | uses: pion/.goassets/.github/workflows/reuse.reusable.yml@master 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # DO NOT EDIT THIS FILE 3 | # 4 | # It is automatically copied from https://github.com/pion/.goassets repository. 5 | # If this repository should have package specific CI config, 6 | # remove the repository name from .goassets/.github/workflows/assets-sync.yml. 7 | # 8 | # If you want to update the shared CI config, send a PR to 9 | # https://github.com/pion/.goassets instead of this repository. 10 | # 11 | # SPDX-FileCopyrightText: 2023 The Pion community 12 | # SPDX-License-Identifier: MIT 13 | 14 | name: Test 15 | on: 16 | push: 17 | branches: 18 | - master 19 | pull_request: 20 | 21 | jobs: 22 | test: 23 | uses: pion/.goassets/.github/workflows/test.reusable.yml@master 24 | strategy: 25 | matrix: 26 | go: ["1.24", "1.23"] # auto-update/supported-go-version-list 27 | fail-fast: false 28 | with: 29 | go-version: ${{ matrix.go }} 30 | secrets: inherit 31 | 32 | test-i386: 33 | uses: pion/.goassets/.github/workflows/test-i386.reusable.yml@master 34 | strategy: 35 | matrix: 36 | go: ["1.24", "1.23"] # auto-update/supported-go-version-list 37 | fail-fast: false 38 | with: 39 | go-version: ${{ matrix.go }} 40 | 41 | test-wasm: 42 | uses: pion/.goassets/.github/workflows/test-wasm.reusable.yml@master 43 | with: 44 | go-version: "1.24" # auto-update/latest-go-version 45 | secrets: inherit 46 | -------------------------------------------------------------------------------- /.github/workflows/tidy-check.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # DO NOT EDIT THIS FILE 3 | # 4 | # It is automatically copied from https://github.com/pion/.goassets repository. 5 | # If this repository should have package specific CI config, 6 | # remove the repository name from .goassets/.github/workflows/assets-sync.yml. 7 | # 8 | # If you want to update the shared CI config, send a PR to 9 | # https://github.com/pion/.goassets instead of this repository. 10 | # 11 | # SPDX-FileCopyrightText: 2023 The Pion community 12 | # SPDX-License-Identifier: MIT 13 | 14 | name: Go mod tidy 15 | on: 16 | pull_request: 17 | push: 18 | branches: 19 | - master 20 | 21 | jobs: 22 | tidy: 23 | uses: pion/.goassets/.github/workflows/tidy-check.reusable.yml@master 24 | with: 25 | go-version: "1.24" # auto-update/latest-go-version 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 The Pion community 2 | # SPDX-License-Identifier: MIT 3 | 4 | ### JetBrains IDE ### 5 | ##################### 6 | .idea/ 7 | 8 | ### Emacs Temporary Files ### 9 | ############################# 10 | *~ 11 | 12 | ### Folders ### 13 | ############### 14 | bin/ 15 | vendor/ 16 | node_modules/ 17 | 18 | ### Files ### 19 | ############# 20 | *.ivf 21 | *.ogg 22 | tags 23 | cover.out 24 | *.sw[poe] 25 | *.wasm 26 | examples/sfu-ws/cert.pem 27 | examples/sfu-ws/key.pem 28 | wasm_exec.js 29 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 The Pion community 2 | # SPDX-License-Identifier: MIT 3 | 4 | run: 5 | timeout: 5m 6 | 7 | linters-settings: 8 | govet: 9 | enable: 10 | - shadow 11 | misspell: 12 | locale: US 13 | exhaustive: 14 | default-signifies-exhaustive: true 15 | gomodguard: 16 | blocked: 17 | modules: 18 | - github.com/pkg/errors: 19 | recommendations: 20 | - errors 21 | forbidigo: 22 | analyze-types: true 23 | forbid: 24 | - ^fmt.Print(f|ln)?$ 25 | - ^log.(Panic|Fatal|Print)(f|ln)?$ 26 | - ^os.Exit$ 27 | - ^panic$ 28 | - ^print(ln)?$ 29 | - p: ^testing.T.(Error|Errorf|Fatal|Fatalf|Fail|FailNow)$ 30 | pkg: ^testing$ 31 | msg: "use testify/assert instead" 32 | varnamelen: 33 | max-distance: 12 34 | min-name-length: 2 35 | ignore-type-assert-ok: true 36 | ignore-map-index-ok: true 37 | ignore-chan-recv-ok: true 38 | ignore-decls: 39 | - i int 40 | - n int 41 | - w io.Writer 42 | - r io.Reader 43 | - b []byte 44 | 45 | linters: 46 | enable: 47 | - asciicheck # Simple linter to check that your code does not contain non-ASCII identifiers 48 | - bidichk # Checks for dangerous unicode character sequences 49 | - bodyclose # checks whether HTTP response body is closed successfully 50 | - containedctx # containedctx is a linter that detects struct contained context.Context field 51 | - contextcheck # check the function whether use a non-inherited context 52 | - cyclop # checks function and package cyclomatic complexity 53 | - decorder # check declaration order and count of types, constants, variables and functions 54 | - dogsled # Checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) 55 | - dupl # Tool for code clone detection 56 | - durationcheck # check for two durations multiplied together 57 | - err113 # Golang linter to check the errors handling expressions 58 | - errcheck # Errcheck is a program for checking for unchecked errors in go programs. These unchecked errors can be critical bugs in some cases 59 | - errchkjson # Checks types passed to the json encoding functions. Reports unsupported types and optionally reports occations, where the check for the returned error can be omitted. 60 | - errname # Checks that sentinel errors are prefixed with the `Err` and error types are suffixed with the `Error`. 61 | - errorlint # errorlint is a linter for that can be used to find code that will cause problems with the error wrapping scheme introduced in Go 1.13. 62 | - exhaustive # check exhaustiveness of enum switch statements 63 | - exportloopref # checks for pointers to enclosing loop variables 64 | - forbidigo # Forbids identifiers 65 | - forcetypeassert # finds forced type assertions 66 | - gci # Gci control golang package import order and make it always deterministic. 67 | - gochecknoglobals # Checks that no globals are present in Go code 68 | - gocognit # Computes and checks the cognitive complexity of functions 69 | - goconst # Finds repeated strings that could be replaced by a constant 70 | - gocritic # The most opinionated Go source code linter 71 | - gocyclo # Computes and checks the cyclomatic complexity of functions 72 | - godot # Check if comments end in a period 73 | - godox # Tool for detection of FIXME, TODO and other comment keywords 74 | - gofmt # Gofmt checks whether code was gofmt-ed. By default this tool runs with -s option to check for code simplification 75 | - gofumpt # Gofumpt checks whether code was gofumpt-ed. 76 | - goheader # Checks is file header matches to pattern 77 | - goimports # Goimports does everything that gofmt does. Additionally it checks unused imports 78 | - gomoddirectives # Manage the use of 'replace', 'retract', and 'excludes' directives in go.mod. 79 | - goprintffuncname # Checks that printf-like functions are named with `f` at the end 80 | - gosec # Inspects source code for security problems 81 | - gosimple # Linter for Go source code that specializes in simplifying a code 82 | - govet # Vet examines Go source code and reports suspicious constructs, such as Printf calls whose arguments do not align with the format string 83 | - grouper # An analyzer to analyze expression groups. 84 | - importas # Enforces consistent import aliases 85 | - ineffassign # Detects when assignments to existing variables are not used 86 | - lll # Reports long lines 87 | - maintidx # maintidx measures the maintainability index of each function. 88 | - makezero # Finds slice declarations with non-zero initial length 89 | - misspell # Finds commonly misspelled English words in comments 90 | - nakedret # Finds naked returns in functions greater than a specified function length 91 | - nestif # Reports deeply nested if statements 92 | - nilerr # Finds the code that returns nil even if it checks that the error is not nil. 93 | - nilnil # Checks that there is no simultaneous return of `nil` error and an invalid value. 94 | - nlreturn # nlreturn checks for a new line before return and branch statements to increase code clarity 95 | - noctx # noctx finds sending http request without context.Context 96 | - predeclared # find code that shadows one of Go's predeclared identifiers 97 | - revive # golint replacement, finds style mistakes 98 | - staticcheck # Staticcheck is a go vet on steroids, applying a ton of static analysis checks 99 | - stylecheck # Stylecheck is a replacement for golint 100 | - tagliatelle # Checks the struct tags. 101 | - tenv # tenv is analyzer that detects using os.Setenv instead of t.Setenv since Go1.17 102 | - thelper # thelper detects golang test helpers without t.Helper() call and checks the consistency of test helpers 103 | - typecheck # Like the front-end of a Go compiler, parses and type-checks Go code 104 | - unconvert # Remove unnecessary type conversions 105 | - unparam # Reports unused function parameters 106 | - unused # Checks Go code for unused constants, variables, functions and types 107 | - varnamelen # checks that the length of a variable's name matches its scope 108 | - wastedassign # wastedassign finds wasted assignment statements 109 | - whitespace # Tool for detection of leading and trailing whitespace 110 | disable: 111 | - depguard # Go linter that checks if package imports are in a list of acceptable packages 112 | - funlen # Tool for detection of long functions 113 | - gochecknoinits # Checks that no init functions are present in Go code 114 | - gomodguard # Allow and block list linter for direct Go module dependencies. This is different from depguard where there are different block types for example version constraints and module recommendations. 115 | - interfacebloat # A linter that checks length of interface. 116 | - ireturn # Accept Interfaces, Return Concrete Types 117 | - mnd # An analyzer to detect magic numbers 118 | - nolintlint # Reports ill-formed or insufficient nolint directives 119 | - paralleltest # paralleltest detects missing usage of t.Parallel() method in your Go test 120 | - prealloc # Finds slice declarations that could potentially be preallocated 121 | - promlinter # Check Prometheus metrics naming via promlint 122 | - rowserrcheck # checks whether Err of rows is checked successfully 123 | - sqlclosecheck # Checks that sql.Rows and sql.Stmt are closed. 124 | - testpackage # linter that makes you use a separate _test package 125 | - tparallel # tparallel detects inappropriate usage of t.Parallel() method in your Go test codes 126 | - wrapcheck # Checks that errors returned from external packages are wrapped 127 | - wsl # Whitespace Linter - Forces you to use empty lines! 128 | 129 | issues: 130 | exclude-use-default: false 131 | exclude-dirs-use-default: false 132 | exclude-rules: 133 | # Allow complex tests and examples, better to be self contained 134 | - path: (examples|main\.go) 135 | linters: 136 | - gocognit 137 | - forbidigo 138 | - path: _test\.go 139 | linters: 140 | - gocognit 141 | 142 | # Allow forbidden identifiers in CLI commands 143 | - path: cmd 144 | linters: 145 | - forbidigo 146 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 The Pion community 2 | # SPDX-License-Identifier: MIT 3 | 4 | builds: 5 | - skip: true 6 | -------------------------------------------------------------------------------- /.reuse/dep5: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: Pion 3 | Source: https://github.com/pion/ 4 | 5 | Files: README.md DESIGN.md **/README.md AUTHORS.txt renovate.json go.mod go.sum **/go.mod **/go.sum .eslintrc.json package.json examples.json sfu-ws/flutter/.gitignore sfu-ws/flutter/pubspec.yaml c-data-channels/webrtc.h examples/examples.json 6 | Copyright: 2023 The Pion community 7 | License: MIT 8 | 9 | Files: testdata/seed/* testdata/fuzz/* **/testdata/fuzz/* api/*.txt 10 | Copyright: 2023 The Pion community 11 | License: CC0-1.0 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 The Pion community 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | Pion Explainer 4 |
5 |

6 |

Explainer decodes WebRTC... so you don't have too!

7 |

8 | Explainer 9 | join us on Discord Follow us on Bluesky 10 |
11 | GitHub Workflow Status 12 | Go Reference 13 | Coverage Status 14 | Go Report Card 15 | License: MIT 16 |

17 |
18 | 19 | Explainer provides a PeerConnection Explainer that parses WebRTC Offers/Answers then provides summaries and suggestions. It returns information like 20 | what codecs are supported, how many tracks each peer is attempting to send and ICE information. It also provides suggestions to fix common errors. 21 | 22 | The goal of this project is to make learning and debugging WebRTC easier. 23 | 24 | ### Use Cases 25 | * **Debugging** - Discover common errors without having to read and compare SDP values. 26 | * **Learning** - Learn SDP keys and values and how they effect your WebRTC sessions. 27 | * **Passive Monitoring** - Add `Explainer` to your existing Signaling and Media servers. Surface and fix existing issues. 28 | * **Custom Tooling** - Include `Explainer` with your own UI in an existing project. Make it easier for your customers to use WebRTC. 29 | 30 | ### Features 31 | * **Session Description Parsing** - Human readable JSON output explaining your Offer/Answer 32 | * **Session Description Suggestions** - Searches for errors and possible improvements, not just explaining the current values. 33 | * **Made for Learning** - Returns line numbers for suggestion and parsing. 34 | * **Portable** - Available in Browser, Go, nodejs, C/C++, Java, C# and more thanks to WASM. 35 | * **Interactive** - Web demo provides interactive discovery of the SessionDescription. 36 | * **Flexible** - Accepts SesionDescriptions or SDP, either value can be base64 37 | * **Decoupled** - Easily ship your own UI, `Explainer` can run on clients or servers 38 | 39 | #### Future Features 40 | * **getStats Parsing** - Human readable JSON output explaining the status of your PeerConnection. What it is sending and why. 41 | * **getStats Suggestions** - Understand why a certain bitrate is being sent or why you are seeing video corruption. 42 | * **getStats Graphing** - Generate values that are easily plottable in your tool of choice. 43 | 44 | ### Running 45 | Examples for different languages are in the `examples` directory. A Web UI is provided in the `web` directory. 46 | 47 | Each example will have a `README.md` describing its specific setup. 48 | 49 | ### Roadmap 50 | The library is used as a part of our WebRTC implementation. Please refer to that [roadmap](https://github.com/pion/webrtc/issues/9) to track our major milestones. 51 | 52 | ### Community 53 | Pion has an active community on the [Discord](https://discord.gg/PngbdqpFbt). 54 | 55 | Follow the [Pion Bluesky](https://bsky.app/profile/pion.ly) or [Pion Twitter](https://twitter.com/_pion) for project updates and important WebRTC news. 56 | 57 | We are always looking to support **your projects**. Please reach out if you have something to build! 58 | If you need commercial support or don't want to use public methods you can contact us at [team@pion.ly](mailto:team@pion.ly) 59 | 60 | ### Contributing 61 | Check out the [contributing wiki](https://github.com/pion/webrtc/wiki/Contributing) to join the group of amazing people making this project possible 62 | 63 | ### License 64 | MIT License - see [LICENSE](LICENSE) for full text -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | # 2 | # DO NOT EDIT THIS FILE 3 | # 4 | # It is automatically copied from https://github.com/pion/.goassets repository. 5 | # 6 | # SPDX-FileCopyrightText: 2023 The Pion community 7 | # SPDX-License-Identifier: MIT 8 | 9 | coverage: 10 | status: 11 | project: 12 | default: 13 | # Allow decreasing 2% of total coverage to avoid noise. 14 | threshold: 2% 15 | patch: 16 | default: 17 | target: 70% 18 | only_pulls: true 19 | 20 | ignore: 21 | - "examples/*" 22 | - "examples/**/*" 23 | -------------------------------------------------------------------------------- /examples/go/README.md: -------------------------------------------------------------------------------- 1 | # go 2 | 3 | go has two hardcoded SessionDescriptions and prints out the results from `explain` 4 | 5 | ## Instructions 6 | Run `go run main.go` 7 | -------------------------------------------------------------------------------- /examples/go/main.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | // Package main implements a little CLI example 5 | package main 6 | 7 | import ( 8 | "encoding/json" 9 | "os" 10 | 11 | "github.com/disgoorg/log" 12 | "github.com/pion/explainer" 13 | ) 14 | 15 | //nolint:lll 16 | const ( 17 | remoteDescription = `{"type": "offer", "sdp": "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=-\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\nm=audio 4000 RTP/AVP 111\r\na=rtpmap:111 OPUS/48000/2\r\nm=video 4002 RTP/AVP 96\r\na=rtpmap:96 VP8/90000"}` 18 | localDescription = `{"type": "answer", "sdp": "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=-\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\nm=audio 4000 RTP/AVP 111\r\na=rtpmap:111 OPUS/48000/2\r\nm=video 4002 RTP/AVP 96\r\na=rtpmap:96 VP8/90000"}` 19 | ) 20 | 21 | func main() { 22 | e := explainer.NewPeerConnectionExplainer() 23 | 24 | e.SetRemoteDescription(remoteDescription) 25 | e.SetLocalDescription(localDescription) 26 | 27 | results, err := json.MarshalIndent(e.Explain(), "", " ") 28 | if err != nil { 29 | log.Panic(err) 30 | } 31 | 32 | if _, err := os.Stdout.Write(results); err != nil { 33 | log.Error(err) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/node/README.md: -------------------------------------------------------------------------------- 1 | # node 2 | 3 | node has two hardcoded SessionDescriptions and prints out the results from `explain` 4 | 5 | ## Instructions 6 | You can download the WASM build and `wasm_exec.js` from master build OR build yourself. 7 | 8 | ### Download from `pe.pion.ly` 9 | * `wget https://pe.pion.ly/wasm.wasm` 10 | * `wget https://pe.pion.ly/wasm_exec.js` 11 | 12 | ### Build 13 | * Copy `wasm_exec.js`: `cp "$(go env GOROOT)/misc/wasm/wasm_exec_node.js" .` 14 | * Build - `GOOS=js GOARCH=wasm go build -o wasm.wasm ../../pkg/wasm` 15 | 16 | ### Run 17 | Run `node main.js` 18 | -------------------------------------------------------------------------------- /examples/node/main.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | const remoteDescription = `{"type": "offer", "sdp": "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=-\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\nm=audio 4000 RTP/AVP 111\r\na=rtpmap:111 OPUS/48000/2\r\nm=video 4002 RTP/AVP 96\r\na=rtpmap:96 VP8/90000"}` 5 | const localDescription = `{"type": "answer", "sdp": "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=-\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\nm=audio 4000 RTP/AVP 111\r\na=rtpmap:111 OPUS/48000/2\r\nm=video 4002 RTP/AVP 96\r\na=rtpmap:96 VP8/90000"}` 6 | 7 | globalThis.require = require; 8 | globalThis.fs = require("fs"); 9 | globalThis.TextEncoder = require("util").TextEncoder; 10 | globalThis.TextDecoder = require("util").TextDecoder; 11 | 12 | globalThis.performance = { 13 | now() { 14 | const [sec, nsec] = process.hrtime(); 15 | return sec * 1000 + nsec / 1000000; 16 | }, 17 | }; 18 | 19 | const crypto = require("crypto"); 20 | globalThis.crypto = { 21 | getRandomValues(b) { 22 | crypto.randomFillSync(b); 23 | }, 24 | }; 25 | 26 | require("./wasm_exec"); 27 | 28 | const go = new Go(); 29 | WebAssembly.instantiate(fs.readFileSync('wasm.wasm'), go.importObject).then((result) => { 30 | go.run(result.instance); 31 | 32 | result_str = explain(localDescription, remoteDescription) 33 | result = JSON.parse(result_str) 34 | console.log(result) 35 | }).catch((err) => { 36 | console.error(err); 37 | process.exit(1); 38 | }); -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pion/explainer 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/disgoorg/log v1.2.1 7 | github.com/stretchr/testify v1.10.0 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/pmezard/go-difflib v1.0.0 // indirect 13 | gopkg.in/yaml.v3 v3.0.1 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/disgoorg/log v1.2.1 h1:kZYAWkUBcGy4LbZcgYtgYu49xNVLy+xG5Uq3yz5VVQs= 4 | github.com/disgoorg/log v1.2.1/go.mod h1:hhQWYTFTnIGzAuFPZyXJEi11IBm9wq+/TVZt/FEwX0o= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 8 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 10 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 11 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 12 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 13 | -------------------------------------------------------------------------------- /internal/result/errors.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | // Package result contains the structured data returned to callers 5 | package result 6 | 7 | //nolint:gochecknoglobals 8 | var ( 9 | errNoIceUserFragment = "No ICE Username Fragment Found" 10 | errConflictingIceUserFragment = "Conflicting ICE Username Fragments Found" 11 | errInvalidIceUserFragment = "ICE User Fragment Found, but is invalid value" 12 | errShortIceUserFragment = "ICE User Fragment Found, but is not long enough" 13 | 14 | errNoIcePassword = "No ICE Password Found" 15 | errConflictingIcePassword = "Conflicting ICE Password Found" 16 | errInvalidIcePassword = "ICE Password Found, but is invalid value" 17 | errShortIcePassword = "ICE Password Found, but is not long enough" 18 | 19 | errNoCertificateFingerprint = "No Certificate Fingerprint Found" 20 | errConflictingCertificateFingerprints = "Conflicting Certificate Fingerprints Found" 21 | //nolint:lll 22 | errMissingSeperatorCertificateFingerprint = "Certificate Fingerprint was found, but did not contain two values separated by a space" 23 | //nolint:lll 24 | errInvalidHexCertificateFingerprint = "Certificate Fingerprint was found, but did not contain a valid hex value for certificate" 25 | ) 26 | -------------------------------------------------------------------------------- /internal/result/media_format_details.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | package result 5 | 6 | import "github.com/pion/explainer/pkg/output" 7 | 8 | // MediaFormatDetails contains the details of 9 | // a single MediaFormat. 10 | type MediaFormatDetails struct { 11 | PayloadType output.Message `json:"payloadType"` 12 | 13 | FormatSpecificParamaters output.Message `json:"formatSpecificParamaters"` 14 | } 15 | -------------------------------------------------------------------------------- /internal/result/media_section_details.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | package result 5 | 6 | import "github.com/pion/explainer/pkg/output" 7 | 8 | // MediaSectionDetails contains the details of 9 | // a single MediaSection. 10 | type MediaSectionDetails struct { 11 | // ID is commonly referred to as MID 12 | ID output.Message `json:"id"` 13 | 14 | // Audio or Video 15 | Type output.Message `json:"type"` 16 | 17 | // Transeiver Direction. Can be sendrecv, sendonly, recvonly or disabled 18 | Direction output.Message `json:"direction"` 19 | 20 | MediaFormats []MediaFormatDetails `json:"mediaFormats"` 21 | } 22 | -------------------------------------------------------------------------------- /internal/result/peer_details.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | package result 5 | 6 | import ( 7 | "strings" 8 | 9 | "github.com/pion/explainer/internal/sdp" 10 | "github.com/pion/explainer/pkg/output" 11 | ) 12 | 13 | // PeerDetails contains the details published by a single peer. This is what 14 | // a single peer Offered or Answered. 15 | type PeerDetails struct { 16 | IceUsernameFragment output.Message `json:"iceUsernameFragment"` 17 | IcePassword output.Message `json:"icePassword"` 18 | 19 | CertificateFingeprint output.Message `json:"certificateFingerprint"` 20 | 21 | MediaSections []MediaSectionDetails `json:"mediaSections"` 22 | } 23 | 24 | const ( 25 | // https://datatracker.ietf.org/doc/html/rfc5245#section-15.4 26 | attributeIceUsernameFragment = "ice-ufrag:" 27 | attributeIceUsernameFragmentMinLength = 4 28 | attributeIcePassword = "ice-pwd:" 29 | attributeIcePasswordMinLength = 22 30 | 31 | // https://datatracker.ietf.org/doc/html/rfc4572#section-5 32 | attributeCertificateFingerprint = "fingerprint:" 33 | ) 34 | 35 | // Populate takes a SessionDescription and populates the PeerDetails. 36 | // 37 | //nolint:cyclop 38 | func (p *PeerDetails) Populate(descr *sdp.SessionDescription, sourceType output.SourceType) []output.Message { 39 | msgs := []output.Message{} 40 | 41 | { 42 | iceUfrags := descr.ScanForAttribute(attributeIceUsernameFragment, true, true) 43 | trimmedValue := "" 44 | if len(iceUfrags) != 0 { 45 | trimmedValue = strings.TrimPrefix(iceUfrags[0].Value, attributeIceUsernameFragment) 46 | } 47 | 48 | switch { 49 | case trimmedValue == "": 50 | msgs = append(msgs, output.Message{Message: errNoIceUserFragment}) 51 | case !allValuesEqual(iceUfrags): 52 | msgs = append(msgs, output.Message{ 53 | Message: errConflictingIceUserFragment, Sources: sdpLinesToSources(iceUfrags, sourceType), 54 | }) 55 | case !isValidIceCharString(trimmedValue): 56 | msgs = append(msgs, output.Message{ 57 | Message: errInvalidIceUserFragment, Sources: sdpLinesToSources(iceUfrags, sourceType), 58 | }) 59 | case len(trimmedValue) < attributeIceUsernameFragmentMinLength: 60 | msgs = append(msgs, output.Message{ 61 | Message: errShortIceUserFragment, Sources: sdpLinesToSources(iceUfrags, sourceType), 62 | }) 63 | default: 64 | p.IceUsernameFragment = output.Message{ 65 | Message: trimmedValue, 66 | Sources: sdpLinesToSources(iceUfrags, sourceType), 67 | } 68 | } 69 | } 70 | 71 | { 72 | icePasswords := descr.ScanForAttribute(attributeIcePassword, true, true) 73 | trimmedValue := "" 74 | if len(icePasswords) != 0 { 75 | trimmedValue = strings.TrimPrefix(icePasswords[0].Value, attributeIcePassword) 76 | } 77 | 78 | switch { 79 | case trimmedValue == "": 80 | msgs = append(msgs, output.Message{Message: errNoIcePassword}) 81 | case !allValuesEqual(icePasswords): 82 | msgs = append(msgs, output.Message{ 83 | Message: errConflictingIcePassword, Sources: sdpLinesToSources(icePasswords, sourceType), 84 | }) 85 | case !isValidIceCharString(trimmedValue): 86 | msgs = append(msgs, output.Message{ 87 | Message: errInvalidIcePassword, Sources: sdpLinesToSources(icePasswords, sourceType), 88 | }) 89 | case len(trimmedValue) < attributeIcePasswordMinLength: 90 | msgs = append(msgs, output.Message{ 91 | Message: errShortIcePassword, Sources: sdpLinesToSources(icePasswords, sourceType), 92 | }) 93 | default: 94 | p.IcePassword = output.Message{ 95 | Message: trimmedValue, 96 | Sources: sdpLinesToSources(icePasswords, sourceType), 97 | } 98 | } 99 | } 100 | 101 | { 102 | fingerprints := descr.ScanForAttribute(attributeCertificateFingerprint, true, true) 103 | trimmedValue := "" 104 | if len(fingerprints) != 0 { 105 | trimmedValue = strings.TrimPrefix(fingerprints[0].Value, attributeCertificateFingerprint) 106 | } 107 | 108 | if trimmedValue == "" { 109 | msgs = append(msgs, output.Message{Message: errNoCertificateFingerprint}) 110 | } else if !allValuesEqual(fingerprints) { 111 | msgs = append( 112 | msgs, 113 | output.Message{ 114 | Message: errConflictingCertificateFingerprints, 115 | Sources: sdpLinesToSources(fingerprints, sourceType), 116 | }, 117 | ) 118 | } else if err := isValidCertificateFingerprint(trimmedValue); err != "" { 119 | msgs = append(msgs, output.Message{Message: err, Sources: sdpLinesToSources(fingerprints, sourceType)}) 120 | } else { 121 | p.CertificateFingeprint = output.Message{ 122 | Message: trimmedValue, 123 | Sources: sdpLinesToSources(fingerprints, sourceType), 124 | } 125 | } 126 | } 127 | 128 | return msgs 129 | } 130 | -------------------------------------------------------------------------------- /internal/result/peerdetails_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | package result 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/pion/explainer/internal/sdp" 10 | "github.com/pion/explainer/pkg/output" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | type testRun struct { 15 | name string 16 | sdp *sdp.SessionDescription 17 | peerDetails PeerDetails 18 | messages []output.Message 19 | } 20 | 21 | func runPeerDetailsTest(t *testing.T, tests []testRun) { 22 | t.Helper() 23 | 24 | for _, test := range tests { 25 | test := test 26 | t.Run(test.name, func(t *testing.T) { 27 | p := PeerDetails{} 28 | messages := p.Populate(test.sdp, 0) 29 | 30 | require.Equal(t, p, test.peerDetails) 31 | require.Subset(t, messages, test.messages) 32 | }) 33 | } 34 | } 35 | 36 | // nolint 37 | func TestPeerDetailsICE(t *testing.T) { 38 | runPeerDetailsTest(t, []testRun{ 39 | { 40 | "No ICE Values", 41 | &sdp.SessionDescription{}, 42 | PeerDetails{}, 43 | []output.Message{ 44 | {Message: errNoIceUserFragment}, 45 | {Message: errNoIcePassword}, 46 | }, 47 | }, 48 | { 49 | "Single ICE Value", 50 | &sdp.SessionDescription{ 51 | Attributes: []sdp.ValueWithLine{ 52 | {Value: attributeIceUsernameFragment + "ABCD", Line: 5}, 53 | {Value: attributeIcePassword + "ABCDEFGHIJKLMNOPQRSTUV", Line: 7}, 54 | }, 55 | }, 56 | PeerDetails{ 57 | IceUsernameFragment: output.Message{ 58 | Message: "ABCD", 59 | Sources: []output.Source{ 60 | {Line: 5}, 61 | }, 62 | }, 63 | IcePassword: output.Message{ 64 | Message: "ABCDEFGHIJKLMNOPQRSTUV", 65 | Sources: []output.Source{ 66 | {Line: 7}, 67 | }, 68 | }, 69 | }, 70 | []output.Message{}, 71 | }, 72 | { 73 | "Duplicate non-conflicting ICE Value", 74 | &sdp.SessionDescription{ 75 | Attributes: []sdp.ValueWithLine{ 76 | {Value: attributeIceUsernameFragment + "ABCD", Line: 5}, 77 | {Value: attributeIceUsernameFragment + "ABCD", Line: 6}, 78 | {Value: attributeIcePassword + "ABCDEFGHIJKLMNOPQRSTUV", Line: 7}, 79 | {Value: attributeIcePassword + "ABCDEFGHIJKLMNOPQRSTUV", Line: 8}, 80 | }, 81 | }, 82 | PeerDetails{ 83 | IceUsernameFragment: output.Message{ 84 | Message: "ABCD", 85 | Sources: []output.Source{ 86 | {Line: 5}, 87 | {Line: 6}, 88 | }, 89 | }, 90 | IcePassword: output.Message{ 91 | Message: "ABCDEFGHIJKLMNOPQRSTUV", 92 | Sources: []output.Source{ 93 | {Line: 7}, 94 | {Line: 8}, 95 | }, 96 | }, 97 | }, 98 | []output.Message{}, 99 | }, 100 | { 101 | "Duplicate conflicting ICE Value", 102 | &sdp.SessionDescription{ 103 | Attributes: []sdp.ValueWithLine{ 104 | {Value: attributeIceUsernameFragment + "foo", Line: 5}, 105 | {Value: attributeIceUsernameFragment + "bar", Line: 6}, 106 | {Value: attributeIcePassword + "foo", Line: 7}, 107 | {Value: attributeIcePassword + "bar", Line: 8}, 108 | }, 109 | }, 110 | PeerDetails{}, 111 | []output.Message{ 112 | { 113 | Message: errConflictingIceUserFragment, 114 | Sources: []output.Source{ 115 | {Line: 5}, 116 | {Line: 6}, 117 | }, 118 | }, 119 | { 120 | Message: errConflictingIcePassword, 121 | Sources: []output.Source{ 122 | {Line: 7}, 123 | {Line: 8}, 124 | }, 125 | }, 126 | }, 127 | }, 128 | { 129 | "Invalid Characters", 130 | &sdp.SessionDescription{ 131 | Attributes: []sdp.ValueWithLine{ 132 | {Value: attributeIceUsernameFragment + "foo!", Line: 5}, 133 | {Value: attributeIcePassword + "bar-", Line: 8}, 134 | }, 135 | }, 136 | PeerDetails{}, 137 | []output.Message{ 138 | { 139 | Message: errInvalidIceUserFragment, 140 | Sources: []output.Source{ 141 | {Line: 5}, 142 | }, 143 | }, 144 | { 145 | Message: errInvalidIcePassword, 146 | Sources: []output.Source{ 147 | {Line: 8}, 148 | }, 149 | }, 150 | }, 151 | }, 152 | { 153 | "Length Min", 154 | &sdp.SessionDescription{ 155 | Attributes: []sdp.ValueWithLine{ 156 | {Value: attributeIceUsernameFragment + "foo", Line: 5}, 157 | {Value: attributeIcePassword + "bar", Line: 8}, 158 | }, 159 | }, 160 | PeerDetails{}, 161 | []output.Message{ 162 | { 163 | Message: errShortIceUserFragment, 164 | Sources: []output.Source{ 165 | {Line: 5}, 166 | }, 167 | }, 168 | { 169 | Message: errShortIcePassword, 170 | Sources: []output.Source{ 171 | {Line: 8}, 172 | }, 173 | }, 174 | }, 175 | }, 176 | }) 177 | } 178 | 179 | func TestPeerDetailsCertificateFingerprint(t *testing.T) { 180 | runPeerDetailsTest(t, []testRun{ 181 | { 182 | "No Fingerprint", 183 | &sdp.SessionDescription{}, 184 | PeerDetails{}, 185 | []output.Message{ 186 | {Message: errNoCertificateFingerprint}, 187 | }, 188 | }, 189 | { 190 | "Conflicting Fingerprint", 191 | &sdp.SessionDescription{ 192 | Attributes: []sdp.ValueWithLine{ 193 | {Value: attributeCertificateFingerprint + "foo", Line: 5}, 194 | {Value: attributeCertificateFingerprint + "bar", Line: 6}, 195 | }, 196 | }, 197 | PeerDetails{}, 198 | []output.Message{ 199 | { 200 | Message: errConflictingCertificateFingerprints, 201 | Sources: []output.Source{ 202 | {Line: 5}, 203 | {Line: 6}, 204 | }, 205 | }, 206 | }, 207 | }, 208 | { 209 | "Two value, second not a split", 210 | &sdp.SessionDescription{ 211 | Attributes: []sdp.ValueWithLine{ 212 | {Value: attributeCertificateFingerprint + "invalid in:va:li:d", Line: 5}, 213 | }, 214 | }, 215 | PeerDetails{}, 216 | []output.Message{ 217 | { 218 | Message: errInvalidHexCertificateFingerprint, 219 | Sources: []output.Source{ 220 | {Line: 5}, 221 | }, 222 | }, 223 | }, 224 | }, 225 | { 226 | "Two value, second not a hex", 227 | &sdp.SessionDescription{ 228 | Attributes: []sdp.ValueWithLine{ 229 | {Value: attributeCertificateFingerprint + "invalid in:va:li:dd", Line: 5}, 230 | }, 231 | }, 232 | PeerDetails{}, 233 | []output.Message{ 234 | { 235 | Message: errInvalidHexCertificateFingerprint, 236 | Sources: []output.Source{ 237 | {Line: 5}, 238 | }, 239 | }, 240 | }, 241 | }, 242 | }) 243 | } 244 | -------------------------------------------------------------------------------- /internal/result/result.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | package result 5 | 6 | import ( 7 | "strings" 8 | 9 | "github.com/pion/explainer/internal/sdp" 10 | "github.com/pion/explainer/pkg/output" 11 | ) 12 | 13 | // SessionDetails is the combination of the Offer/Answer and what the actual state 14 | // of the WebRTC session is. 15 | type SessionDetails struct{} 16 | 17 | func allValuesEqual(vals []sdp.ValueWithLine) bool { 18 | val := "" 19 | for _, v := range vals { 20 | if val == "" { 21 | val = v.Value 22 | } else if v.Value != val { 23 | return false 24 | } 25 | } 26 | 27 | return true 28 | } 29 | 30 | func sdpLinesToSources(values []sdp.ValueWithLine, sourceType output.SourceType) (outputs []output.Source) { 31 | for _, v := range values { 32 | outputs = append(outputs, output.Source{Line: v.Line, Type: sourceType}) 33 | } 34 | 35 | return 36 | } 37 | 38 | // ice-char = ALPHA / DIGIT / "+" / "/" 39 | // https://datatracker.ietf.org/doc/html/rfc5245#section-15.1 40 | func isValidIceCharString(iceChar string) bool { //nolint:cyclop 41 | for _, c := range iceChar { 42 | switch { 43 | case c >= '0' && c <= '9': 44 | case c >= 'A' && c <= 'Z': 45 | case c >= 'a' && c <= 'z': 46 | case c == '+' || c == '/': 47 | default: 48 | return false 49 | } 50 | } 51 | 52 | return true 53 | } 54 | 55 | // https://datatracker.ietf.org/doc/html/rfc4572#section-5 56 | func isValidCertificateFingerprint(fingerprint string) string { //nolint:cyclop 57 | spaceSplit := strings.Split(fingerprint, " ") 58 | if len(spaceSplit) != 2 { 59 | return errMissingSeperatorCertificateFingerprint 60 | } 61 | 62 | for _, v := range strings.Split(spaceSplit[1], ":") { 63 | if len(v) != 2 { 64 | return errInvalidHexCertificateFingerprint 65 | } 66 | 67 | for _, c := range v { 68 | switch { 69 | case c >= '0' && c <= '9': 70 | case c >= 'A' && c <= 'F': 71 | case c >= 'a' && c <= 'f': 72 | default: 73 | return errInvalidHexCertificateFingerprint 74 | } 75 | } 76 | } 77 | 78 | return "" 79 | } 80 | -------------------------------------------------------------------------------- /internal/sdp/errors.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | package sdp 5 | 6 | //nolint:gochecknoglobals 7 | var ( 8 | errProtocolVersionNotFound = "v (protocol version) was expected, but not found" 9 | errOriginatorNotFound = "o (originator and session identifier) was expected, but not found" 10 | errSessionNameNotFound = "s (session name) was expected, but not found" 11 | 12 | errEarlyEndVersion = "session description ended before version could be found" 13 | errEarlyEndOriginator = "session description ended before originator could be found" 14 | errEarlyEndSessionName = "session description ended before session name could be found" 15 | 16 | errInvalidProtocolVersion = "Failed to take protocol version to int" 17 | 18 | errShortLine = "line is not long enough to contain both a key and value" 19 | errInvalidLine = "line is not a proper key value pair, second character is not `=`" 20 | 21 | errInvalidSessionAttribute = "invalid session attribute: " 22 | ) 23 | -------------------------------------------------------------------------------- /internal/sdp/jsep.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | package sdp 5 | 6 | // Constants for SDP attributes used in JSEP. 7 | const ( 8 | AttrKeyIdentity = "identity" 9 | AttrKeyGroup = "group" 10 | AttrKeySsrc = "ssrc" 11 | AttrKeySsrcGroup = "ssrc-group" 12 | AttrKeyMsidSemantic = "msid-semantic" 13 | AttrKeyConnectionSetup = "setup" 14 | AttrKeyMID = "mid" 15 | AttrKeyICELite = "ice-lite" 16 | AttrKeyRtcpMux = "rtcp-mux" 17 | AttrKeyRtcpRsize = "rtcp-rsize" 18 | ) 19 | 20 | // Constants for semantic tokens used in JSEP. 21 | const ( 22 | SemanticTokenLipSynchronization = "LS" 23 | SemanticTokenFlowIdentification = "FID" 24 | SemanticTokenForwardErrorCorrection = "FEC" 25 | SemanticTokenWebRTCMediaStreams = "WMS" 26 | ) 27 | -------------------------------------------------------------------------------- /internal/sdp/media_description.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | package sdp 5 | 6 | // MediaDescription represents a media type. Currently defined media are "audio", 7 | // "video", "text", "application", and "message", although this list 8 | // may be extended in the future 9 | // https://tools.ietf.org/html/rfc4566#section-5.14 10 | type MediaDescription struct { 11 | // MediaName is m= 12 | // is the media type 13 | // is the transport port to which the media stream is sent 14 | // is the transport protocol 15 | // is a media format description 16 | // https://tools.ietf.org/html/rfc4566#section-5.13 17 | MediaName ValueWithLine 18 | 19 | // SessionInformation field provides textual information about the session. There 20 | // MUST be at most one session-level SessionInformation field per session description, 21 | // and at most one SessionInformation field per media 22 | // https://tools.ietf.org/html/rfc4566#section-5.4 23 | MediaInformation ValueWithLine 24 | 25 | // ConnectionData a session description MUST contain either at least one ConnectionData field in 26 | // each media description or a single ConnectionData field at the session level. 27 | // https://tools.ietf.org/html/rfc4566#section-5.7 28 | ConnectionData ValueWithLine 29 | 30 | // Bandwidth field denotes the proposed bandwidth to be used by the 31 | // session or media 32 | // b=: 33 | // https://tools.ietf.org/html/rfc4566#section-5.8 34 | Bandwidth []ValueWithLine 35 | 36 | // EncryptionKeys if for when the SessionDescription is transported over a secure and trusted channel, 37 | // the Session Description Protocol MAY be used to convey encryption keys 38 | // https://tools.ietf.org/html/rfc4566#section-5.11 39 | EncryptionKeys []ValueWithLine 40 | 41 | // Attributes are the primary means for extending SDP. Attributes may 42 | // be defined to be used as "session-level" attributes, "media-level" 43 | // attributes, or both. 44 | // https://tools.ietf.org/html/rfc4566#section-5.12 45 | Attributes []ValueWithLine 46 | } 47 | -------------------------------------------------------------------------------- /internal/sdp/scanner.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | package sdp 5 | 6 | import ( 7 | "bufio" 8 | 9 | "github.com/pion/explainer/pkg/output" 10 | ) 11 | 12 | type sdpScanner struct { 13 | scanner *bufio.Scanner 14 | currentLine int 15 | } 16 | 17 | func (s *sdpScanner) messageForLine(message string) output.Message { 18 | return output.NewMessage( 19 | message, 20 | []output.Source{{Line: s.currentLine}}, 21 | ) 22 | } 23 | 24 | func (s *sdpScanner) messageForError(err error) output.Message { 25 | if err == nil { 26 | return output.Message{} 27 | } 28 | 29 | return s.messageForLine(err.Error()) 30 | } 31 | 32 | func (s *sdpScanner) nextLine() (key, value string, scanStatus bool, message output.Message) { 33 | s.currentLine++ 34 | if scanStatus = s.scanner.Scan(); !scanStatus { 35 | return key, value, scanStatus, s.messageForError(s.scanner.Err()) 36 | } 37 | 38 | if len(s.scanner.Text()) < 3 { 39 | return key, value, scanStatus, s.messageForLine(errShortLine) 40 | } else if s.scanner.Text()[1] != '=' { 41 | return key, value, scanStatus, s.messageForLine(errInvalidLine) 42 | } 43 | 44 | return string(s.scanner.Text()[0]), s.scanner.Text()[2:], scanStatus, output.Message{} 45 | } 46 | 47 | type attributeStatus struct { 48 | line int 49 | value string 50 | allowMultiple bool 51 | } 52 | 53 | // Detect if the current attribute is ok to be read (detect out of order errors) 54 | // or if it has already been set. 55 | func (s *sdpScanner) attributeValid(statuses []*attributeStatus, attribute string) output.Message { 56 | attrFound := false 57 | for _, v := range statuses { 58 | if attrFound && v.line != 0 { 59 | return s.messageForLine( 60 | "Attribute " + attribute + " was found, but later attribute " + v.value + " has already been set", 61 | ) 62 | } 63 | 64 | if v.value == attribute { 65 | if v.line != 0 && !v.allowMultiple { 66 | return s.messageForLine("Attribute " + attribute + " was attempted to be set twice: " + v.value) 67 | } 68 | attrFound = true 69 | v.line = s.currentLine 70 | } 71 | } 72 | 73 | return output.Message{} 74 | } 75 | -------------------------------------------------------------------------------- /internal/sdp/session_description.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | // Package sdp pravides a rfc8866 parser 5 | package sdp 6 | 7 | import "strings" 8 | 9 | // ValueWithLine contains a value and the line it was found in the source. 10 | type ValueWithLine struct { 11 | Value string 12 | Line int 13 | } 14 | 15 | // SessionDescription is a a well-defined format for conveying sufficient 16 | // information to discover and participate in a multimedia session. 17 | type SessionDescription struct { 18 | // ProtocolVersion gives the version of the Session Description Protocol 19 | // https://tools.ietf.org/html/rfc4566#section-5.1 20 | ProtocolVersion int 21 | 22 | // Origin gives the originator of the session in the form of 23 | // o= 24 | // https://tools.ietf.org/html/rfc4566#section-5.2 25 | Origin ValueWithLine 26 | 27 | // SessionName is the textual session name. There MUST be one and only one 28 | // only one "s=" field per session description 29 | // https://tools.ietf.org/html/rfc4566#section-5.3 30 | SessionName ValueWithLine 31 | 32 | // SessionInformation field provides textual information about the session. There 33 | // MUST be at most one session-level SessionInformation field per session description, 34 | // and at most one SessionInformation field per media 35 | // https://tools.ietf.org/html/rfc4566#section-5.4 36 | SessionInformation ValueWithLine 37 | 38 | // URI is a pointer to additional information about the 39 | // session. This field is OPTIONAL, but if it is present it MUST be 40 | // specified before the first media field. No more than one URI field 41 | // is allowed per session description. 42 | // https://tools.ietf.org/html/rfc4566#section-5.5 43 | URI ValueWithLine 44 | 45 | // EmailAddress specifies the email for the person responsible for the conference 46 | // https://tools.ietf.org/html/rfc4566#section-5.6 47 | EmailAddress ValueWithLine 48 | 49 | // PhoneNumber specifies the phone number for the person responsible for the conference 50 | // https://tools.ietf.org/html/rfc4566#section-5.6 51 | PhoneNumber ValueWithLine 52 | 53 | // ConnectionData a session description MUST contain either at least one ConnectionData field in 54 | // each media description or a single ConnectionData field at the session level. 55 | // https://tools.ietf.org/html/rfc4566#section-5.7 56 | ConnectionData ValueWithLine 57 | 58 | // Bandwidth field denotes the proposed bandwidth to be used by the 59 | // session or media 60 | // b=: 61 | // https://tools.ietf.org/html/rfc4566#section-5.8 62 | Bandwidth []ValueWithLine 63 | 64 | // Timing lines specify the start and stop times for a session. 65 | // t= 66 | // https://tools.ietf.org/html/rfc4566#section-5.9 67 | Timing []ValueWithLine 68 | 69 | // RepeatTimes specify repeat times for a session 70 | // r= 71 | // https://tools.ietf.org/html/rfc4566#section-5.10 72 | RepeatTimes []ValueWithLine 73 | 74 | // TimeZones schedule a repeated session that spans a change from daylight 75 | // z= 76 | // https://tools.ietf.org/html/rfc4566#section-5.11 77 | TimeZones []ValueWithLine 78 | 79 | // EncryptionKeys if for when the SessionDescription is transported over a secure and trusted channel, 80 | // the Session Description Protocol MAY be used to convey encryption keys 81 | // https://tools.ietf.org/html/rfc4566#section-5.11 82 | EncryptionKeys []ValueWithLine 83 | 84 | // Attributes are the primary means for extending SDP. Attributes may 85 | // be defined to be used as "session-level" attributes, "media-level" 86 | // attributes, or both. 87 | // https://tools.ietf.org/html/rfc4566#section-5.12 88 | Attributes []ValueWithLine 89 | 90 | // MediaDescriptions A session description may contain a number of media descriptions. 91 | // Each media description starts with an "m=" field and is terminated by 92 | // either the next "m=" field or by the end of the session description. 93 | // https://tools.ietf.org/html/rfc4566#section-5.13 94 | MediaDescriptions []*MediaDescription 95 | } 96 | 97 | // Reset cleans the SessionDescription, and sets all fields back to their default values. 98 | func (s *SessionDescription) Reset() { 99 | s.ProtocolVersion = 0 100 | s.Origin = ValueWithLine{} 101 | s.SessionName = ValueWithLine{} 102 | s.SessionInformation = ValueWithLine{} 103 | s.URI = ValueWithLine{} 104 | s.EmailAddress = ValueWithLine{} 105 | s.PhoneNumber = ValueWithLine{} 106 | s.ConnectionData = ValueWithLine{} 107 | s.Bandwidth = nil 108 | s.Timing = nil 109 | s.RepeatTimes = nil 110 | s.TimeZones = nil 111 | s.EncryptionKeys = nil 112 | s.Attributes = nil 113 | s.MediaDescriptions = nil 114 | } 115 | 116 | // ScanForAttribute searches for attributes with a given prefix. 117 | func (s *SessionDescription) ScanForAttribute(prefix string, _, _ bool) (rtrn []ValueWithLine) { 118 | for _, a := range s.Attributes { 119 | if strings.HasPrefix(a.Value, prefix) { 120 | rtrn = append(rtrn, a) 121 | } 122 | } 123 | 124 | for _, m := range s.MediaDescriptions { 125 | for _, a := range m.Attributes { 126 | if strings.HasPrefix(a.Value, prefix) { 127 | rtrn = append(rtrn, a) 128 | } 129 | } 130 | } 131 | 132 | return 133 | } 134 | -------------------------------------------------------------------------------- /internal/sdp/unmarshal.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | package sdp 5 | 6 | import ( 7 | "bufio" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/pion/explainer/pkg/output" 12 | ) 13 | 14 | // Unmarshal populates a SessionDescription from a raw string 15 | // 16 | // Some lines in each description are REQUIRED and some are OPTIONAL, 17 | // but all MUST appear in exactly the order given here (the fixed order 18 | // greatly enhances error detection and allows for a simple parser). 19 | // OPTIONAL items are marked with a "*". 20 | // v= (protocol version) 21 | // o= (originator and session identifier) 22 | // s= (session name) 23 | // i=* (session information) 24 | // u=* (URI of description) 25 | // e=* (email address) 26 | // p=* (phone number) 27 | // c=* (connection information -- not required if included in all media) 28 | // b=* (zero or more bandwidth information lines) 29 | // t=* (One or more time descriptions) 30 | // r=* (One or more repeat descriptions) 31 | // z=* (time zone adjustments) 32 | // k=* (encryption key) 33 | // a=* (zero or more session attribute lines) 34 | // Zero or more media descriptions 35 | // https://tools.ietf.org/html/rfc4566#section-5 36 | func (s *SessionDescription) Unmarshal(raw string) output.Message { //nolint:cyclop 37 | s.Reset() 38 | scanner := &sdpScanner{bufio.NewScanner(strings.NewReader(raw)), -1} 39 | var err error 40 | 41 | // v= 42 | key, value, scanStatus, msg := scanner.nextLine() 43 | if msg.Message != "" { 44 | return msg 45 | } else if !scanStatus { 46 | return scanner.messageForLine(errEarlyEndVersion) 47 | } else if key != "v" { 48 | return scanner.messageForLine(errProtocolVersionNotFound) 49 | } else if s.ProtocolVersion, err = strconv.Atoi(value); err != nil { 50 | return scanner.messageForLine(errInvalidProtocolVersion) 51 | } 52 | 53 | // o= 54 | key, value, scanStatus, msg = scanner.nextLine() 55 | switch { 56 | case msg.Message != "": 57 | return msg 58 | case !scanStatus: 59 | return scanner.messageForLine(errEarlyEndOriginator) 60 | case key != "o": 61 | return scanner.messageForLine(errOriginatorNotFound) 62 | } 63 | s.Origin = ValueWithLine{value, scanner.currentLine} 64 | 65 | // s= 66 | key, value, scanStatus, msg = scanner.nextLine() 67 | switch { 68 | case msg.Message != "": 69 | return msg 70 | case !scanStatus: 71 | return scanner.messageForLine(errEarlyEndSessionName) 72 | case key != "s": 73 | return scanner.messageForLine(errSessionNameNotFound) 74 | } 75 | s.SessionName = ValueWithLine{value, scanner.currentLine} 76 | 77 | return s.unmarshalOptionalAttributes(scanner) 78 | } 79 | 80 | func (s *SessionDescription) unmarshalOptionalAttributes(scanner *sdpScanner) output.Message { //nolint:cyclop 81 | orderedSessionAttributes := []*attributeStatus{ 82 | {value: "v"}, 83 | {value: "o"}, 84 | {value: "s"}, 85 | {value: "i"}, 86 | {value: "u"}, 87 | {value: "e"}, 88 | {value: "p"}, 89 | {value: "c"}, 90 | {value: "b", allowMultiple: true}, 91 | {value: "t", allowMultiple: true}, 92 | {value: "r", allowMultiple: true}, 93 | {value: "z", allowMultiple: true}, 94 | {value: "k", allowMultiple: true}, 95 | {value: "a", allowMultiple: true}, 96 | {value: "m", allowMultiple: true}, 97 | } 98 | 99 | for { 100 | key, value, scanStatus, m := scanner.nextLine() 101 | if m.Message != "" || !scanStatus { 102 | return m 103 | } 104 | 105 | if m = scanner.attributeValid(orderedSessionAttributes, key); m.Message != "" { 106 | return m 107 | } 108 | 109 | switch key { 110 | case "i": 111 | s.SessionInformation = ValueWithLine{value, scanner.currentLine} 112 | case "u": 113 | s.URI = ValueWithLine{value, scanner.currentLine} 114 | case "e": 115 | s.EmailAddress = ValueWithLine{value, scanner.currentLine} 116 | case "p": 117 | s.PhoneNumber = ValueWithLine{value, scanner.currentLine} 118 | case "c": 119 | s.ConnectionData = ValueWithLine{value, scanner.currentLine} 120 | case "b": 121 | s.Bandwidth = append(s.Bandwidth, ValueWithLine{value, scanner.currentLine}) 122 | case "t": 123 | s.Timing = append(s.Timing, ValueWithLine{value, scanner.currentLine}) 124 | case "r": 125 | s.RepeatTimes = append(s.RepeatTimes, ValueWithLine{value, scanner.currentLine}) 126 | case "z": 127 | s.TimeZones = append(s.TimeZones, ValueWithLine{value, scanner.currentLine}) 128 | case "k": 129 | s.EncryptionKeys = append(s.EncryptionKeys, ValueWithLine{value, scanner.currentLine}) 130 | case "a": 131 | s.Attributes = append(s.Attributes, ValueWithLine{value, scanner.currentLine}) 132 | case "m": 133 | return s.unmarshalMedias(scanner, value) 134 | default: 135 | return scanner.messageForLine(errInvalidSessionAttribute + key) 136 | } 137 | } 138 | } 139 | 140 | //nolint:cyclop 141 | func (s *SessionDescription) unmarshalMedias(scanner *sdpScanner, firstMediaName string) output.Message { 142 | currentMedia := &MediaDescription{MediaName: ValueWithLine{firstMediaName, scanner.currentLine}} 143 | orderedMediaAttributes := []*attributeStatus{ 144 | {value: "i"}, 145 | {value: "c"}, 146 | {value: "b", allowMultiple: true}, 147 | {value: "k", allowMultiple: true}, 148 | {value: "a", allowMultiple: true}, 149 | } 150 | resetMediaAttributes := func() { 151 | for _, v := range orderedMediaAttributes { 152 | v.line = 0 153 | } 154 | } 155 | 156 | for { 157 | key, value, scanStatus, m := scanner.nextLine() 158 | if m.Message != "" || !scanStatus { // This handles EOF, finish current MediaDescription 159 | s.MediaDescriptions = append(s.MediaDescriptions, currentMedia) 160 | 161 | return m 162 | } 163 | 164 | if m = scanner.attributeValid(orderedMediaAttributes, key); m.Message != "" { 165 | return m 166 | } 167 | 168 | switch key { 169 | case "m": 170 | s.MediaDescriptions = append(s.MediaDescriptions, currentMedia) 171 | resetMediaAttributes() 172 | currentMedia = &MediaDescription{MediaName: ValueWithLine{value, scanner.currentLine}} 173 | case "i": 174 | currentMedia.MediaInformation = ValueWithLine{value, scanner.currentLine} 175 | case "c": 176 | currentMedia.ConnectionData = ValueWithLine{value, scanner.currentLine} 177 | case "b": 178 | currentMedia.Bandwidth = append(currentMedia.Bandwidth, ValueWithLine{value, scanner.currentLine}) 179 | case "k": 180 | currentMedia.EncryptionKeys = append(currentMedia.EncryptionKeys, ValueWithLine{value, scanner.currentLine}) 181 | case "a": 182 | currentMedia.Attributes = append(currentMedia.Attributes, ValueWithLine{value, scanner.currentLine}) 183 | default: 184 | return scanner.messageForLine("Invalid media attribute: " + key) 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /internal/sdp/unmarshal_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | package sdp 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/pion/explainer/pkg/output" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | // Assert that we have v, o, s. 14 | func Test_GlobalValues(t *testing.T) { 15 | descr := SessionDescription{} 16 | 17 | t.Run("v", func(t *testing.T) { 18 | // No value 19 | require.Equal(t, descr.Unmarshal(""), output.NewMessage(errEarlyEndVersion, []output.Source{{Line: 0}})) 20 | 21 | // Wrong key 22 | require.Equal(t, descr.Unmarshal("a=b"), output.NewMessage(errProtocolVersionNotFound, []output.Source{{Line: 0}})) 23 | 24 | // Invalid value 25 | require.Equal(t, descr.Unmarshal("v=b"), output.NewMessage(errInvalidProtocolVersion, []output.Source{{Line: 0}})) 26 | }) 27 | 28 | t.Run("o", func(t *testing.T) { 29 | // No value 30 | require.Equal(t, descr.Unmarshal("v=2\r\n"), output.NewMessage(errEarlyEndOriginator, []output.Source{{Line: 1}})) 31 | 32 | // Wrong key 33 | require.Equal(t, descr.Unmarshal("v=2\r\na=b"), output.NewMessage(errOriginatorNotFound, []output.Source{{Line: 1}})) 34 | }) 35 | 36 | t.Run("s", func(t *testing.T) { 37 | // No value 38 | require.Equal(t, descr.Unmarshal("v=2\r\no=o"), output.NewMessage(errEarlyEndSessionName, []output.Source{{Line: 2}})) 39 | 40 | // Wrong key 41 | require.Equal(t, 42 | descr.Unmarshal("v=2\r\no=o\r\na=b"), 43 | output.NewMessage(errSessionNameNotFound, []output.Source{{Line: 2}}), 44 | ) 45 | }) 46 | } 47 | 48 | func Test_LineParsing(t *testing.T) { 49 | s := SessionDescription{} 50 | 51 | require.Equal(t, s.Unmarshal("a="), output.NewMessage(errShortLine, []output.Source{{Line: 0}})) 52 | require.Equal(t, s.Unmarshal("a!b"), output.NewMessage(errInvalidLine, []output.Source{{Line: 0}})) 53 | } 54 | -------------------------------------------------------------------------------- /peerconnection_explainer.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | // Package explainer provides APIs to make debugging and learning WebRTC easier 5 | package explainer 6 | 7 | import ( 8 | "encoding/base64" 9 | "strings" 10 | 11 | "github.com/pion/explainer/internal/sdp" 12 | "github.com/pion/explainer/pkg/output" 13 | ) 14 | 15 | // PeerConnectionExplainer mocks the PeerConnection API and returns analysis and suggestions. 16 | type PeerConnectionExplainer interface { 17 | // SetLocalDescription updates the PeerConnectionExplainer with the provided SessionDescription 18 | SetLocalDescription(input string) 19 | 20 | // GetLocalDescription returns the current SDP we are using from SetLocalDescription 21 | GetLocalDescription() string 22 | 23 | // SetRemoteDescription updates the PeerConnectionExplainer with the provided SessionDescription 24 | SetRemoteDescription(input string) 25 | 26 | // GetRemoteDescription returns the current SDP we are using from SetRemoteDescription 27 | GetRemoteDescription() string 28 | 29 | // Explain returns the result of the current PeerConnectionExplainer. 30 | Explain() Result 31 | } 32 | 33 | // NewPeerConnectionExplainer returns a new PeerConnectionExplainer. 34 | func NewPeerConnectionExplainer() PeerConnectionExplainer { 35 | return &peerConnectionExplainer{} 36 | } 37 | 38 | type peerConnectionExplainer struct { 39 | localDescription, remoteDescription sessionDescription 40 | } 41 | 42 | func (pe *peerConnectionExplainer) String() string { 43 | return "PeerConnection Explainer" 44 | } 45 | 46 | func generateSessionDescription(input string) sessionDescription { 47 | if possiblyDecoded, err := base64.StdEncoding.DecodeString(input); err == nil { 48 | input = string(possiblyDecoded) 49 | } 50 | 51 | s := sessionDescription{} 52 | if s.unmarshal(input); s.Type == "" && s.SDP == "" { 53 | s.SDP = input 54 | } 55 | 56 | s.SDP = strings.ReplaceAll(s.SDP, "\\r\\n", "\n") 57 | 58 | return s 59 | } 60 | 61 | func (pe *peerConnectionExplainer) SetLocalDescription(input string) { 62 | pe.localDescription = generateSessionDescription(input) 63 | } 64 | 65 | func (pe *peerConnectionExplainer) SetRemoteDescription(input string) { 66 | pe.remoteDescription = generateSessionDescription(input) 67 | } 68 | 69 | func (pe *peerConnectionExplainer) Explain() (result Result) { 70 | result.init() 71 | 72 | if pe.localDescription.SDP == "" { 73 | result.Warnings = append(result.Warnings, output.NewMessage(warnLocalDescriptionUnset, nil)) 74 | } 75 | if pe.remoteDescription.SDP == "" { 76 | result.Warnings = append(result.Warnings, output.NewMessage(warnRemoteDescriptionUnset, nil)) 77 | } 78 | 79 | if len(result.Warnings) == 2 { 80 | return result 81 | } 82 | 83 | if pe.localDescription.Type != "" && pe.localDescription.Type == pe.remoteDescription.Type { 84 | result.Errors = append(result.Errors, output.NewMessage(errLocalAndRemoteSameType, nil)) 85 | } 86 | 87 | parsed := &sdp.SessionDescription{} 88 | 89 | if pe.localDescription.SDP != "" { 90 | if m := parsed.Unmarshal(pe.localDescription.SDP); m.Message != "" { 91 | m.Sources[0].Type = output.SourceTypeLocal 92 | result.Errors = append(result.Errors, m) 93 | } else { 94 | errors := result.LocalDetails.Populate(parsed, output.SourceTypeLocal) 95 | setSourcesType(errors, output.SourceTypeLocal) 96 | result.Errors = append(result.Errors, errors...) 97 | } 98 | } 99 | 100 | if pe.remoteDescription.SDP != "" { 101 | if m := parsed.Unmarshal(pe.localDescription.SDP); m.Message != "" { 102 | m.Sources[0].Type = output.SourceTypeRemote 103 | result.Errors = append(result.Errors, m) 104 | } else { 105 | errors := result.LocalDetails.Populate(parsed, output.SourceTypeRemote) 106 | setSourcesType(errors, output.SourceTypeRemote) 107 | result.Errors = append(result.Errors, errors...) 108 | } 109 | } 110 | 111 | return result 112 | } 113 | 114 | func (pe *peerConnectionExplainer) GetLocalDescription() string { return pe.localDescription.SDP } 115 | func (pe *peerConnectionExplainer) GetRemoteDescription() string { return pe.remoteDescription.SDP } 116 | -------------------------------------------------------------------------------- /peerconnection_explainer_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | //go:build !js && !wasm 5 | // +build !js,!wasm 6 | 7 | package explainer 8 | 9 | import ( 10 | "testing" 11 | 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func Test_InputHeuristics(t *testing.T) { 16 | t.Run("base64", func(t *testing.T) { 17 | pe := NewPeerConnectionExplainer() 18 | 19 | pe.SetRemoteDescription(`eyJ0eXBlIjogIm9mZmVyIiwgInNkcCI6ICJGb29iYXIifQ==`) 20 | pe.SetLocalDescription(`eyJ0eXBlIjogIm9mZmVyIiwgInNkcCI6ICJGb29iYXIifQ==`) 21 | 22 | require.Equal(t, pe.Explain().Errors[0].Message, errLocalAndRemoteSameType) 23 | }) 24 | 25 | t.Run("SDP", func(t *testing.T) { 26 | pe := NewPeerConnectionExplainer() 27 | 28 | pe.SetRemoteDescription("v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=-\r\nc=IN IP4 127.0.0.1\r\nt=0 0") 29 | pe.SetLocalDescription("v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=-\r\nc=IN IP4 127.0.0.1\r\nt=0 0") 30 | 31 | explained := pe.Explain() 32 | 33 | require.NotEqual(t, 0, len(explained.Errors)) 34 | require.Equal(t, 0, len(explained.Warnings)) 35 | require.Equal(t, 0, len(explained.Suggestions)) 36 | }) 37 | } 38 | 39 | func Test_Missing_Description(t *testing.T) { 40 | t.Run("Local", func(t *testing.T) { 41 | pe := NewPeerConnectionExplainer() 42 | 43 | pe.SetRemoteDescription(`A`) 44 | for _, w := range pe.Explain().Warnings { 45 | require.NotEqual(t, w.Message, warnRemoteDescriptionUnset) 46 | } 47 | }) 48 | 49 | t.Run("Remote", func(t *testing.T) { 50 | pe := NewPeerConnectionExplainer() 51 | 52 | pe.SetLocalDescription(`B`) 53 | for _, w := range pe.Explain().Warnings { 54 | require.NotEqual(t, w.Message, warnLocalDescriptionUnset) 55 | } 56 | }) 57 | } 58 | 59 | func Test_Conflicting_Type(t *testing.T) { 60 | pe := NewPeerConnectionExplainer() 61 | 62 | pe.SetRemoteDescription(`{"type": "offer", "sdp": "Foobar"}`) 63 | pe.SetLocalDescription(`{"type": "offer", "sdp": "Foobar"}`) 64 | 65 | require.Equal(t, pe.Explain().Errors[0].Message, errLocalAndRemoteSameType) 66 | } 67 | 68 | func Test_Unescape(t *testing.T) { 69 | require.Equal(t, generateSessionDescription(`{"type": "offer", "sdp": "Foo\r\nBar"}`).SDP, "Foo\nBar") 70 | } 71 | -------------------------------------------------------------------------------- /pkg/output/output.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | // Package output contains structure that are returned by Explainers 5 | package output 6 | 7 | import "bytes" 8 | 9 | // Message contains a string description and the sources that caused it to be generated. 10 | type Message struct { 11 | Message string `json:"message"` 12 | Sources []Source `json:"source"` 13 | } 14 | 15 | // NewMessage creates a Message and handles nil Sources. 16 | func NewMessage(message string, sources []Source) Message { 17 | if sources == nil { 18 | sources = make([]Source, 0) 19 | } 20 | 21 | return Message{message, sources} 22 | } 23 | 24 | // Source is the file that caused this message to be generated. 25 | type Source struct { 26 | Type SourceType `json:"type"` 27 | Line int `json:"line"` 28 | } 29 | 30 | // SourceType communicates if the source is from the local or remote description. 31 | type SourceType int 32 | 33 | func (s SourceType) String() string { 34 | switch s { 35 | case SourceTypeLocal: 36 | return "local" 37 | case SourceTypeRemote: 38 | return "remote" 39 | default: 40 | return "" 41 | } 42 | } 43 | 44 | // MarshalJSON marshals the enum as a quoted json string. 45 | func (s SourceType) MarshalJSON() ([]byte, error) { 46 | buffer := bytes.NewBufferString(`"`) 47 | buffer.WriteString(s.String()) 48 | buffer.WriteString(`"`) 49 | 50 | return buffer.Bytes(), nil 51 | } 52 | 53 | // Constants. 54 | const ( 55 | SourceTypeLocal SourceType = iota + 1 56 | SourceTypeRemote 57 | ) 58 | -------------------------------------------------------------------------------- /pkg/wasm/main.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | //go:build js 5 | // +build js 6 | 7 | // Package main implements a WASM example 8 | package main 9 | 10 | import ( 11 | "encoding/json" 12 | "syscall/js" 13 | 14 | "github.com/pion/explainer" 15 | ) 16 | 17 | //nolint:gochecknoglobals 18 | var ( 19 | exp explainer.PeerConnectionExplainer 20 | ) 21 | 22 | func explain(_ js.Value, inputs []js.Value) interface{} { 23 | if len(inputs) != 2 { 24 | panic("invalid number of inputs") //nolint:forbidigo 25 | } 26 | 27 | localDescription := inputs[0].String() 28 | remoteDescription := inputs[1].String() 29 | 30 | exp.SetLocalDescription(localDescription) 31 | exp.SetRemoteDescription(remoteDescription) 32 | 33 | out, err := json.Marshal(exp.Explain()) 34 | if err != nil { 35 | panic(err) //nolint:forbidigo 36 | } 37 | 38 | return string(out) 39 | } 40 | 41 | func main() { 42 | exp = explainer.NewPeerConnectionExplainer() 43 | 44 | js.Global().Set("explain", js.FuncOf(explain)) 45 | 46 | select {} 47 | } 48 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>pion/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /result.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | package explainer 5 | 6 | import ( 7 | "github.com/pion/explainer/internal/result" 8 | "github.com/pion/explainer/pkg/output" 9 | ) 10 | 11 | // PeerDetails contains the details published by a single peer. This is what 12 | // a single peer Offered or Answered. 13 | type PeerDetails = result.PeerDetails 14 | 15 | // SessionDetails is the combination of the Offer/Answer and what the actual state 16 | // of the WebRTC session is. 17 | type SessionDetails = result.SessionDetails 18 | 19 | // Result is the current status of the PeerConnectionExplainer. 20 | type Result struct { 21 | Errors []output.Message `json:"errors"` 22 | Warnings []output.Message `json:"warnings"` 23 | Suggestions []output.Message `json:"suggestions"` 24 | 25 | LocalDetails PeerDetails `json:"localDetails"` 26 | RemoteDetails PeerDetails `json:"remoteDetails"` 27 | 28 | SessionDetails SessionDetails `json:"sessionDetails"` 29 | } 30 | 31 | func (r *Result) init() { 32 | r.Warnings = make([]output.Message, 0) 33 | r.Errors = make([]output.Message, 0) 34 | r.Suggestions = make([]output.Message, 0) 35 | } 36 | 37 | func setSourcesType(messages []output.Message, sourceType output.SourceType) { 38 | for _, m := range messages { 39 | if len(m.Sources) == 0 { 40 | m.Sources = []output.Source{{}} 41 | } 42 | for _, s := range m.Sources { 43 | s.Type = sourceType 44 | } 45 | } 46 | } 47 | 48 | // nolint golint 49 | var ( 50 | errLocalAndRemoteSameType = "local and remote description are the same type" 51 | 52 | warnLocalDescriptionUnset = "local description has not been set, full analysis not available" 53 | warnRemoteDescriptionUnset = "remote description has not been set, full analysis not available" 54 | ) 55 | -------------------------------------------------------------------------------- /sessiondescription.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The Pion community 2 | // SPDX-License-Identifier: MIT 3 | 4 | package explainer 5 | 6 | // sessionDescription is generated by a WebRTC Agent. This is used to transport 7 | // Offers and Answers. 8 | type sessionDescription struct { 9 | Type string `json:"type"` 10 | SDP string `json:"sdp"` 11 | } 12 | 13 | func (s *sessionDescription) unmarshal(in string) { //nolint:cyclop 14 | inTag := false 15 | inString := false 16 | 17 | tagValue := "" 18 | 19 | for i := 0; i < len(in); i++ { 20 | switch { 21 | case in[i] == '"': 22 | switch { 23 | case inString: 24 | inString = false 25 | tagValue = "" 26 | case inTag: 27 | inTag = false 28 | case tagValue != "": 29 | inString = true 30 | default: 31 | inTag = true 32 | } 33 | case inTag: 34 | tagValue += string(in[i]) 35 | case inString: 36 | switch tagValue { 37 | case "type": 38 | s.Type += string(in[i]) 39 | case "sdp": 40 | s.SDP += string(in[i]) 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # web 2 | 3 | Web provides a UI that accepts a Remote and Local Description and prints out an analysis. 4 | 5 | ## Instructions 6 | You can download the WASM build and `wasm_exec.js` from master build OR build yourself. 7 | 8 | ### Download from `pe.pion.ly` 9 | * `wget https://pe.pion.ly/wasm.wasm` 10 | * `wget https://pe.pion.ly/wasm_exec.js` 11 | 12 | ### Build 13 | * Copy `wasm_exec.js`: `cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" .` 14 | * Build - `GOOS=js GOARCH=wasm go build -o wasm.wasm ../pkg/wasm` 15 | 16 | ### Run 17 | You can now run using any HTTP server. If you have Python available `python -m SimpleHTTPServer` is a good option. 18 | You can access at [http://localhost:8000](http://localhost:8000) 19 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | PeerConnection Explainer 11 | 108 | 109 | 110 | 111 | 115 | 116 |
117 |
118 | Local:
119 | 120 |
121 | 122 |
123 | Remote:
124 | 125 |
126 |
127 | 128 | 146 | 147 | 153 | 154 | 155 | 156 | 284 | 285 | --------------------------------------------------------------------------------