Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow blocking IPs from transmitting WebRTC video #571

Merged
merged 11 commits into from
Nov 17, 2023
Merged
9 changes: 7 additions & 2 deletions src/components/mini-widgets/MiniVideoRecorder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { useMouseInElement, useTimestamp } from '@vueuse/core'
import { format, intervalToDuration } from 'date-fns'
import { saveAs } from 'file-saver'
import fixWebmDuration from 'fix-webm-duration'
import { storeToRefs } from 'pinia'
import Swal, { type SweetAlertResult } from 'sweetalert2'
import { computed, onBeforeMount, onBeforeUnmount, ref, toRefs, watch } from 'vue'
import adapter from 'webrtc-adapter'
Expand All @@ -65,8 +66,12 @@ import { WebRTCManager } from '@/composables/webRTC'
import type { Stream } from '@/libs/webrtc/signalling_protocol'
import { useMainVehicleStore } from '@/stores/mainVehicle'
import { useMissionStore } from '@/stores/mission'
import { useVideoStore } from '@/stores/video'
import type { MiniWidget } from '@/types/miniWidgets'

const videoStore = useVideoStore()
const { allowedIceIps } = storeToRefs(videoStore)

const { rtcConfiguration, webRTCSignallingURI } = useMainVehicleStore()
const { missionName } = useMissionStore()

Expand All @@ -82,7 +87,7 @@ const miniWidget = toRefs(props).miniWidget

const selectedStream = ref<Stream | undefined>()
const webRTCManager = new WebRTCManager(webRTCSignallingURI.val, rtcConfiguration)
const { availableStreams: externalStreams, mediaStream } = webRTCManager.startStream(selectedStream)
const { availableStreams: externalStreams, mediaStream } = webRTCManager.startStream(selectedStream, allowedIceIps)
const mediaRecorder = ref<MediaRecorder>()
const recorderWidget = ref()
const { isOutside } = useMouseInElement(recorderWidget)
Expand Down Expand Up @@ -244,7 +249,7 @@ watch(externalStreams, () => {
// Retrieve stream from the saved stream name, otherwise choose the first available stream as a fallback
const savedStream = savedStreamName ? availableStreams.value.find((s) => s.name === savedStreamName) : undefined

if (savedStream !== undefined && savedStream !== selectedStream.value && selectedStream.value === undefined) {
if (savedStream !== undefined && savedStream.id !== selectedStream.value?.id && selectedStream.value === undefined) {
console.debug!('[WebRTC] trying to set stream...')
updateCurrentStream(savedStream)
}
Expand Down
40 changes: 38 additions & 2 deletions src/components/widgets/VideoPlayer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@
hide-details
return-object
/>
<v-combobox
v-model="selectedICEIPsField"
multiple
:items="videoStore.availableIceIps"
label="Allowed WebRTC remote IP Addresses"
class="w-full my-3 uri-input"
variant="outlined"
chips
clearable
hint="IP Addresses of the Vehicle allowed to be used for the WebRTC ICE Routing. Usually, the IP of the tether/cabled interface. Blank means any route. E.g: 192.168.2.2"
/>
<v-banner-text>Saved stream name: "{{ widget.options.streamName }}"</v-banner-text>
<v-banner-text>Signaller Status: {{ signallerStatus }}</v-banner-text>
<v-banner-text>Stream Status: {{ streamStatus }}</v-banner-text>
Expand All @@ -58,14 +69,24 @@
</template>

<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onBeforeMount, onBeforeUnmount, ref, toRefs, watch } from 'vue'
import adapter from 'webrtc-adapter'

import { WebRTCManager } from '@/composables/webRTC'
import { isValidNetworkAddress } from '@/libs/utils'
import type { Stream } from '@/libs/webrtc/signalling_protocol'
import { useMainVehicleStore } from '@/stores/mainVehicle'
import { useVideoStore } from '@/stores/video'
import type { Widget } from '@/types/widgets'

const videoStore = useVideoStore()
const { allowedIceIps } = storeToRefs(videoStore)

const isValidHostAddress = (value: string): boolean | string => {
return isValidNetworkAddress(value) ?? 'Invalid host address. Should be an IP address or a hostname'
}

const { rtcConfiguration, webRTCSignallingURI } = useMainVehicleStore()

console.debug('[WebRTC] Using webrtc-adapter for', adapter.browserDetails)
Expand All @@ -79,10 +100,14 @@ const props = defineProps<{

const widget = toRefs(props).widget

const selectedICEIPsField = ref<string[]>(allowedIceIps.value)
const selectedStream = ref<Stream | undefined>()
const videoElement = ref<HTMLVideoElement | undefined>()
const webRTCManager = new WebRTCManager(webRTCSignallingURI.val, rtcConfiguration)
const { availableStreams, mediaStream, signallerStatus, streamStatus } = webRTCManager.startStream(selectedStream)
const { availableStreams, availableICEIPs, mediaStream, signallerStatus, streamStatus } = webRTCManager.startStream(
selectedStream,
allowedIceIps
)

onBeforeMount(() => {
// Set initial widget options if they don't exist
Expand Down Expand Up @@ -119,6 +144,17 @@ watch(mediaStream, async (newStream, oldStream) => {
})
})

watch(selectedICEIPsField, () => {
const validSelectedIPs = selectedICEIPsField.value.filter((address) => isValidHostAddress(address))
allowedIceIps.value = validSelectedIPs
})

setInterval(() => {
const combinedIps = [...videoStore.availableIceIps, ...availableICEIPs.value]
const uniqueIps = combinedIps.filter((value, index, array) => array.indexOf(value) === index)
videoStore.availableIceIps = uniqueIps
}, 1000)

watch(selectedStream, () => (widget.value.options.streamName = selectedStream.value?.name))

watch(availableStreams, () => {
Expand All @@ -133,7 +169,7 @@ watch(availableStreams, () => {
? availableStreams.value.find((s) => s.name === savedStreamName)
: availableStreams.value.first()

if (savedStream !== undefined && savedStream !== selectedStream.value) {
if (savedStream !== undefined && savedStream.id !== selectedStream.value?.id) {
console.debug!('[WebRTC] trying to set stream...')
selectedStream.value = savedStream
}
Expand Down
33 changes: 32 additions & 1 deletion src/composables/webRTC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ interface startStreamReturn {
* A list of Available WebRTC streams from Mavlink Camera Manager to be chosen from
*/
availableStreams: Ref<Array<Stream>>
/**
* A list of IPs from WebRTC candidates that are available
*/
availableICEIPs: Ref<Array<string>>
/**
* MediaStream object, if WebRTC stream is chosen
*/
Expand All @@ -34,13 +38,15 @@ interface startStreamReturn {
*/
export class WebRTCManager {
private availableStreams: Ref<Array<Stream>> = ref(new Array<Stream>())
private availableICEIPs: Ref<Array<string>> = ref(new Array<string>())
private mediaStream: Ref<MediaStream | undefined> = ref()
private signallerStatus: Ref<string> = ref('waiting...')
private streamStatus: Ref<string> = ref('waiting...')
private consumerId: string | undefined
private streamName: string | undefined
private session: Session | undefined
private rtcConfiguration: RTCConfiguration
private selectedICEIPs: string[] = []

private hasEnded = false
private signaller: Signaller
Expand Down Expand Up @@ -78,9 +84,12 @@ export class WebRTCManager {
/**
*
* @param { Ref<Stream | undefined> } selectedStream - Stream to receive stream from
* @param { Ref<string[]> } selectedICEIPs
* @returns { startStreamReturn }
*/
public startStream(selectedStream: Ref<Stream | undefined>): startStreamReturn {
public startStream(selectedStream: Ref<Stream | undefined>, selectedICEIPs: Ref<string[]>): startStreamReturn {
this.selectedICEIPs = selectedICEIPs.value

watch(selectedStream, (newStream, oldStream) => {
if (newStream?.id === oldStream?.id) {
return
Expand All @@ -97,8 +106,28 @@ export class WebRTCManager {
}
})

watch(selectedICEIPs, (newIps, oldIps) => {
if (newIps === oldIps) {
return
}

const msg = `Selected IPs changed from "${oldIps}" to "${newIps}".`
console.debug('[WebRTC] ' + msg)

this.selectedICEIPs = newIps

if (this.streamName !== undefined) {
this.stopSession(msg)
}

if (this.streamName !== undefined) {
this.startSession()
}
})

return {
availableStreams: this.availableStreams,
availableICEIPs: this.availableICEIPs,
mediaStream: this.mediaStream,
signallerStatus: this.signallerStatus,
streamStatus: this.streamStatus,
Expand Down Expand Up @@ -301,7 +330,9 @@ export class WebRTCManager {
stream,
this.signaller,
this.rtcConfiguration,
this.selectedICEIPs,
(event: RTCTrackEvent): void => this.onTrackAdded(event),
(availableICEIPs: string[]) => (this.availableICEIPs.value = availableICEIPs),
(_sessionId, reason) => this.onSessionClosed(reason)
)

Expand Down
19 changes: 19 additions & 0 deletions src/libs/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,22 @@ export const resetCanvas = (context: CanvasRenderingContext2D): void => {
context.clearRect(0, 0, context.canvas.width, context.canvas.height)
context.globalCompositeOperation = 'source-over'
}

export const isValidNetworkAddress = (maybeAddress: string): boolean => {
if (maybeAddress && maybeAddress.length >= 255) {
return false
}

// Regexes from https://stackoverflow.com/a/106223/3850957
const ipRegex = new RegExp(
'^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$'
)
const hostnameRegex = new RegExp(
'^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])$'
)

if (ipRegex.test(maybeAddress) || hostnameRegex.test(maybeAddress)) {
return true
}
return false
}
35 changes: 35 additions & 0 deletions src/libs/webrtc/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Stream } from '@/libs/webrtc/signalling_protocol'

type OnCloseCallback = (sessionId: string, reason: string) => void
type OnTrackAddedCallback = (event: RTCTrackEvent) => void
type onNewIceRemoteAddressCallback = (availableICEIPs: string[]) => void

/**
* An abstraction for the Mavlink Camera Manager WebRTC Session
Expand All @@ -16,8 +17,11 @@ export class Session {
private ended: boolean
private signaller: Signaller
private peerConnection: RTCPeerConnection
private availableICEIPs: string[]
private selectedICEIPs: string[]
public rtcConfiguration: RTCConfiguration
public onTrackAdded?: OnTrackAddedCallback
public onNewIceRemoteAddress?: onNewIceRemoteAddressCallback
public onClose?: OnCloseCallback

/**
Expand All @@ -27,7 +31,9 @@ export class Session {
* @param {Stream} stream - The Stream instance for which this Session will be created with, given by the signalling server
* @param {Signaller} signaller - The Signaller instance for this Session to use
* @param {RTCConfiguration} rtcConfiguration - Configuration for the RTC connection, such as Turn and Stun servers
* @param {string[]} selectedICEIPs - A whitelist for ICE IP addresses, ignored if empty
* @param {OnTrackAddedCallback} onTrackAdded - An optional callback for when a track is added to this session
* @param {onNewIceRemoteAddressCallback} onNewIceRemoteAddress - An optional callback for when a new ICE candidate IP addres is available
* @param {OnCloseCallback} onClose - An optional callback for when this session closes
*/
constructor(
Expand All @@ -36,18 +42,23 @@ export class Session {
stream: Stream,
signaller: Signaller,
rtcConfiguration: RTCConfiguration,
selectedICEIPs: string[] = [],
onTrackAdded?: OnTrackAddedCallback,
onNewIceRemoteAddress?: onNewIceRemoteAddressCallback,
onClose?: OnCloseCallback
) {
this.id = sessionId
this.consumerId = consumerId
this.stream = stream
this.onTrackAdded = onTrackAdded
this.onNewIceRemoteAddress = onNewIceRemoteAddress
this.onClose = onClose
this.status = ''
this.signaller = signaller
this.rtcConfiguration = rtcConfiguration
this.ended = false
this.availableICEIPs = []
this.selectedICEIPs = selectedICEIPs

this.peerConnection = this.createRTCPeerConnection(rtcConfiguration)

Expand Down Expand Up @@ -165,6 +176,29 @@ export class Session {
* @param {RTCIceCandidateInit} candidate - The ICE candidate received from the signalling server
*/
public onIncomingICE(candidate: RTCIceCandidateInit): void {
// Save the candidate IP address (only accepting IPv4 for now) on a list of remote available IPs
const ipv4Regex = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g
const extractIPv4 = (cand: string): string | undefined => {
const matches = cand.match(ipv4Regex)
return matches?.find((ip) => !ip.includes(':'))
}
const ipAddress = extractIPv4(candidate.candidate!)
if (ipAddress && !this.availableICEIPs.includes(ipAddress) && this.onNewIceRemoteAddress) {
this.availableICEIPs.push(ipAddress)
this.onNewIceRemoteAddress(this.availableICEIPs)
}

// Ignores unwanted routes, useful, for example, to prevent WebRTC to chose the wrong route, like when the OS default is WiFi but you want to receive the video via tether because of reliability
if (
candidate.candidate &&
Array.isArray(this.selectedICEIPs) &&
!this.selectedICEIPs.isEmpty() &&
!this.selectedICEIPs.some((address) => candidate.candidate!.includes(address))
) {
console.debug(`[WebRTC] [Session] ICE candidate ignored: ${JSON.stringify(candidate, null, 4)}`)
return
}

this.peerConnection
.addIceCandidate(candidate)
.then(() => console.debug(`[WebRTC] [Session] ICE candidate added: ${JSON.stringify(candidate, null, 4)}`))
Expand Down Expand Up @@ -261,6 +295,7 @@ export class Session {

// Unlink parent callbacks
this.onTrackAdded = undefined
this.onNewIceRemoteAddress = undefined
this.onClose = undefined

this.ended = true
Expand Down
10 changes: 10 additions & 0 deletions src/stores/video.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { reactive } from 'vue'

export const useVideoStore = defineStore('video', () => {
const availableIceIps = reactive<string[]>([])
const allowedIceIps = useStorage<string[]>('cockpit-allowed-stream-ips', [])

return { availableIceIps, allowedIceIps }
})
Loading