#!/bin/sh

# SPDX-FileCopyrightText: Copyright 2025-2026, macmpi
# SPDX-License-Identifier: MIT

## Extended Multifunction Composite USB Gadget
# works across most host OS computers: Linux/mcOS/Windows
# without additional host-side drivers or configuration required.
# Enables USB Serial, Ethernet (ECM or RNDIS) ports and Mass Storage functions
# Does necessary sanity-checks on ports beforehand and returns clean if not relevant.
# Actual ports configs like serial options, console bring-up, networking adresses are
# application-specific and out of this scope, but this program will leverage such configs
# to try to bring-up console login and network port.

VERSION="2.0"

SCRIPT="${0##*/}"
error() {
	echo "$SCRIPT: $1" >&2
}

! [ "$(id -u)" -eq 0 ] && { error "Please run with administrator privileges."; exit 1; }

alias _logger='logger -st "$SCRIPT"'

: "${TIMEOUT_DTC:=1}"
: "${RETRY_INTERVAL:=5}"	# Port detection may need 2 tries with slower hosts
: "${TIMEOUT_ETH:=2}"		# allow enough time for slower Win PC hosts to detect network interface.
CONFIGFS="/sys/kernel/config/usb_gadget/$SCRIPT"

_is_eth_up() {
# Check with timeout if ethernet interface (may not always be usb0) goes-up (carrier_up_count not 0)
# returns error if not up (timeout),
timeout "$TIMEOUT_ETH" sh <<-EOF >/dev/null 2>&1
	trap 'exit 1' TERM	# silence propagated Terminated message on timeout
	eth_iface="$( find /sys/class/udc/"$UDC"/gadget/net/* -maxdepth 0 -exec basename {} \; 2>/dev/null )"
	while grep -wq "0" /sys/class/udc/"$UDC"/gadget/net/"\$eth_iface"/carrier_up_count 2>/dev/null; do
		sleep 0.2
		eth_iface="$( find /sys/class/udc/"$UDC"/gadget/net/* -maxdepth 0 -exec basename {} \; 2>/dev/null )"
	done
	EOF
}

_detect_udc() {
# We need to verify that interface on peripheral mode is actually
# connected to host (link speed not UNKNOWN), and identify it
# as target UDC (take first if several...while unlikely).
# For that, temporarly start serial gadget and check ports speed
# (some hosts may fail to respond fisrt time, so may need to do twice).
# If in watch mode, then we will poll regularly to react to USB insertion
# (since we can not monitor /sys/class with inotifyd, we will just poll...)

# if gadget is not setup, pull-in g_serial to test connection
modprobe g_serial
# Wait for gadget to settle and some interface declares valid link speed.
UDC="$( timeout "$TIMEOUT_DTC" sh <<-EOF 2>/dev/null
	trap 'exit 1' TERM	# silence propagated Terminated message on timeout
	found=""
	while [ -z "\$found" ]; do
		for iface in $UDC_list; do
			grep -vq UNKNOWN /sys/class/udc/"\$iface"/current_speed 2>/dev/null && found="\$iface" && break 2
		done
		sleep 0.2
	done
	echo "\$found"
	EOF
)"
modprobe -r g_serial
}

_init() {
# We assume dwc2/dwc3 driver is pre-loaded
# Do not setup if already existing
local i
grep -q '.' "$CONFIGFS"/UDC 2>/dev/null && _logger "Gadget is already configured !" && exit 1

# get a list of interfaces in peripheral mode (some devices may have several)
UDC_list="$( grep -l "0" /sys/class/udc/*/is_a_peripheral 2>/dev/null | \
	paste -s - | sed 's|/is_a_peripheral||g' | sed 's|/sys/class/udc/||g' )"
[ -z "$UDC_list" ] && _logger "No interface set in peripheral mode for gadget !" && exit 1 # exit right-away if none

# Remove conflicting modules in case they were initially loaded (cmdline.txt or al.).
modprobe -r g_serial g_ether g_cdc g_multi
i=1
while true; do  # some slow hosts may fail detection if tried only once when plugged before startup
	_detect_udc
	[ -n "$UDC" ] && break  # we found UDC live port and continue
	if [ "$i" -le "2" ]; then
		if [ "$i" -eq "1" ]; then
			_logger "Gadget USB port is not connected, will retry in $RETRY_INTERVAL seconds..."
		else
			[ -z "$polling" ] && \
				_logger "Gadget USB port is not connected !" && \
				exit 1  # aborting after 2 tries if not polling
			RETRY_INTERVAL="$polling"
			_logger "Will poll Gadget USB port connection every $polling seconds..."
		fi
	fi
	sleep "$RETRY_INTERVAL" &
	wait $!
	i=$((i + 1))
done
}

_setup() {
_logger "Creating Extended Multifunction USB-gadget v$VERSION by macmpi..."

USB_VENDORID="0x1d6b"  # Linux Foundation http://www.linux-usb.org/usb.ids
USB_PRODUCTID="0x0104" # Multifunction Composite Gadget
USB_MANUF="macmpi"
USB_PRODUCT="Extended Multifunction USB-gadget v$VERSION"
USB_SERIAL="In_Tartiflette_We_Trust"
USB_ATTRIBUTES="0x80" # Bus-powered
USB_MAX_POWER="250" # 2mA increments on USB2 (8mA on USB3)
HOST_MAC="${host_mac:-f6:67:ce:b3:c0:ea}" # Default LAA randomized MAC
DEVICE_MAC="${dev_mac:-ea:64:2f:e8:19:94}" # Default LAA randomized MAC
USB_DEVICE_CLASS="0xef"
USB_DEVICE_SUBCLASS="0x02"
USB_DEVICE_PROTOCOL="0x01"
MS_VENDOR_CODE="0xcd" # Microsoft
MS_QW_SIGN="MSFT100" # also Microsoft (if you couldn't tell)
RNDIS_CLASS="0xef"
RNDIS_SUBCLASS="0x04"
RNDIS_PROTOCOL="0x01"
MS_COMPAT_ID="RNDIS" # matches Windows RNDIS Drivers
MS_SUBCOMPAT_ID="5162001" # matches Windows RNDIS 6.0 Driver

# doc at https://docs.kernel.org/usb/gadget_configfs.html
modprobe libcomposite

# Create all required directories
mkdir -p "$CONFIGFS"/strings/0x409

# Setup IDs and strings
echo $USB_VENDORID > "$CONFIGFS"/idVendor
echo $USB_PRODUCTID > "$CONFIGFS"/idProduct
echo $USB_DEVICE_CLASS > "$CONFIGFS"/bDeviceClass
echo $USB_DEVICE_SUBCLASS > "$CONFIGFS"/bDeviceSubClass
echo $USB_DEVICE_PROTOCOL > "$CONFIGFS"/bDeviceProtocol
echo $USB_MANUF > "$CONFIGFS"/strings/0x409/manufacturer
echo "$USB_PRODUCT" > "$CONFIGFS"/strings/0x409/product
echo $USB_SERIAL > "$CONFIGFS"/strings/0x409/serialnumber

# Create ACM (serial) function
mkdir -p "$CONFIGFS"/functions/acm.GS0

# Create ECM (ethernet) function
mkdir -p "$CONFIGFS"/functions/ecm.usb0
echo "$HOST_MAC" > "$CONFIGFS"/functions/ecm.usb0/host_addr
echo "$DEVICE_MAC" > "$CONFIGFS"/functions/ecm.usb0/dev_addr

# Create Mass Storage function
# https://www.kernel.org/doc/Documentation/usb/mass-storage.txt
# https://www.kernel.org/doc/Documentation/ABI/testing/configfs-usb-gadget-mass-storage
if [ -n "$MASS_STORAGE_LUN" ]; then
	mkdir -p "$CONFIGFS"/functions/mass_storage.usb0
	echo 1 > "$CONFIGFS"/functions/mass_storage.usb0/stall # Linux default
	# note on possible dmesg debug logs: https://github.com/raspberrypi/linux/issues/7452
	echo 1 > "$CONFIGFS"/functions/mass_storage.usb0/lun.0/removable
	echo "$MASS_STORAGE_LUN" > "$CONFIGFS"/functions/mass_storage.usb0/lun.0/file
fi

# Windows has some complex contraints on composite gadget setup
# (RNDIS vs ECM, not supporting multiple configurations without custom.inf, etc...)
# So we try Linux/Mac first with ECM and then rebuild for MS RNDIS if failing.
# note: MS seems to recommend std NCM from Win11, but also keeps legacy RNDIS support.
# https://learn.microsoft.com/en-us/windows-hardware/drivers/usbcon/supported-usb-classes

# Create base configuration for Linux/macOS
mkdir -p "$CONFIGFS"/configs/c.1
mkdir -p "$CONFIGFS"/configs/c.1/strings/0x409

echo "CDC" > "$CONFIGFS"/configs/c.1/strings/0x409/configuration
echo "$USB_ATTRIBUTES" > "$CONFIGFS"/configs/c.1/bmAttributes
echo "$USB_MAX_POWER" > "$CONFIGFS"/configs/c.1/MaxPower
# It is required for ECM to go first, so Linux and Mac switch to this configuration
ln -s "$CONFIGFS"/functions/ecm.usb0 "$CONFIGFS"/configs/c.1
ln -s "$CONFIGFS"/functions/acm.GS0 "$CONFIGFS"/configs/c.1
[ -n "$MASS_STORAGE_LUN" ] && ln -s "$CONFIGFS"/functions/mass_storage.usb0 "$CONFIGFS"/configs/c.1

_logger "Enabling USB-gadget with ECM & al..."
echo "$UDC" > "$CONFIGFS"/UDC

# Check if ECM ethernet goes-up; switch to RNDIS if not.
if ! _is_eth_up; then
	_logger "CDC-ECM mode failed, switching to MS RNDIS..."
	echo "" > "$CONFIGFS"/UDC
	rm -f "$CONFIGFS"/configs/c.1/ecm.usb0 \
		"$CONFIGFS"/configs/c.1/acm.GS0 \
		"$CONFIGFS"/configs/c.1/mass_storage.usb0 >/dev/null 2>&1
	rmdir "$CONFIGFS"/functions/ecm.usb0

	# On Windows 7 and later, the RNDIS 5.1 driver would be used by default,
	# but it does not work very well. The RNDIS 6.0 driver works better. In
	# order to get this driver to load automatically, we have to use a
	# Microsoft-specific extension of USB.

	echo "1" > "$CONFIGFS"/os_desc/use
	echo "${MS_VENDOR_CODE}" > "$CONFIGFS"/os_desc/b_vendor_code
	echo "${MS_QW_SIGN}" > "$CONFIGFS"/os_desc/qw_sign

	# Create rndis (ethernet) function
	mkdir -p "$CONFIGFS"/functions/rndis.usb0
	echo "$HOST_MAC" > "$CONFIGFS"/functions/rndis.usb0/host_addr
	echo "$DEVICE_MAC" > "$CONFIGFS"/functions/rndis.usb0/dev_addr
	echo $RNDIS_CLASS > "$CONFIGFS"/functions/rndis.usb0/class
	echo $RNDIS_SUBCLASS > "$CONFIGFS"/functions/rndis.usb0/subclass
	echo $RNDIS_PROTOCOL > "$CONFIGFS"/functions/rndis.usb0/protocol
	echo $MS_COMPAT_ID > "$CONFIGFS"/functions/rndis.usb0/os_desc/interface.rndis/compatible_id
	echo $MS_SUBCOMPAT_ID > "$CONFIGFS"/functions/rndis.usb0/os_desc/interface.rndis/sub_compatible_id

	# Create RNDIS configuration
	echo "RNDIS" > "$CONFIGFS"/configs/c.1/strings/0x409/configuration
	ln -s "$CONFIGFS"/configs/c.1 "$CONFIGFS"/os_desc
	# It is required for RNDIS to go first for Windows to detect this properly
	ln -s "$CONFIGFS"/functions/rndis.usb0 "$CONFIGFS"/configs/c.1
	ln -s "$CONFIGFS"/functions/acm.GS0 "$CONFIGFS"/configs/c.1
	[ -n "$MASS_STORAGE_LUN" ] && ln -s "$CONFIGFS"/functions/mass_storage.usb0 "$CONFIGFS"/configs/c.1

	_logger "Re-enabling USB-gadget with RNDIS & al..."
	echo "$UDC" > "$CONFIGFS"/UDC
fi

# Final check after eventual ECM to RNDIS switch
if _is_eth_up; then
	# Default serial config: xon/xoff flow control
	if [ -c /dev/ttyGS0 ]; then
		# wait for ttyGS0 to be ready
		while ! stty -F /dev/ttyGS0 >/dev/null 2>&1; do sleep 0.2; done
		# best effort serial console start if previously configured
		[ -x /etc/init.d/agetty.ttyGS0 ] && _logger "Trying to start console..." && \
			setsid service agetty.ttyGS0 restart >/dev/null 2>&1
		[ -s /usr/lib/systemd/system/serial-getty@ttyGS0.service ] && _logger "Trying to start console..." && \
			setsid systemctl restart serial-getty@ttyGS0 >/dev/null 2>&1
	fi
	# best effort network interface bring-up if configured
	eth_iface="$( find /sys/class/udc/"$UDC"/gadget/net/* -maxdepth 0 -exec basename {} \; 2>/dev/null )"
	if nmcli device status 2>/dev/null | grep "^$eth_iface" | grep -q diconnected; then
		_logger "Trying to enable $eth_iface..."
		nmcli device connect "$eth_iface" >/dev/null 2>&1
	else
		ifquery "$eth_iface" >/dev/null 2>&1 && \
			ip link show "$eth_iface" 2>/dev/null | grep -q "state DOWN" && \
			_logger "Trying to enable $eth_iface..." && \
			ifup -f "$eth_iface" >/dev/null 2>&1
	fi

	_logger "Gadget is now setup."
else
	_remove
	_logger "Gadget setup failed, exiting."
	exit 1
fi
}

_remove() {
if grep -q '.' "$CONFIGFS"/UDC 2>/dev/null; then
	_logger "Removing gadget configuration..."
	echo "" > "$CONFIGFS"/UDC
	rm -f "$CONFIGFS"/os_desc/c.1 >/dev/null 2>&1
	rm -f "$CONFIGFS"/configs/c.1/acm.GS0 >/dev/null 2>&1
	rm -f "$CONFIGFS"/configs/c.1/ecm.usb0 >/dev/null 2>&1
	rm -f "$CONFIGFS"/configs/c.1/rndis.usb0 >/dev/null 2>&1
	rm -f "$CONFIGFS"/configs/c.1/mass_storage.usb0 >/dev/null 2>&1
	rmdir "$CONFIGFS"/configs/c.1/strings/0x409 >/dev/null 2>&1
	rmdir "$CONFIGFS"/configs/c.1 >/dev/null 2>&1
	rmdir "$CONFIGFS"/functions/ecm.usb0 >/dev/null 2>&1
	rmdir "$CONFIGFS"/functions/rndis.usb0 >/dev/null 2>&1
	rmdir "$CONFIGFS"/functions/acm.GS0 >/dev/null 2>&1
	rmdir "$CONFIGFS"/functions/mass_storage.usb0 >/dev/null 2>&1
	rmdir "$CONFIGFS"/strings/0x409 >/dev/null 2>&1
	rmdir "$CONFIGFS" >/dev/null 2>&1
	_logger "Gadget is now removed."
else
	_logger "No gadget is configured !"
	exit 1
fi
}

usage() {
[ "$2" ] && error "$2"
local outfh=2
[ "$1" -eq 0 ] && outfh=1

cat >&$outfh <<-__EOF__

usage: xg_multi [OPTIONS]

Setup (or remove) Extended Multifunction USB-gadget: serial, ethernet (ECM/RNDIS),
and mass-storage (if valid path is specified).
Pre-configured interfaces (i.e console, networking) will be activated.

Options: -D|--Device <MAC address>  Specify MAC address for device
         -H|--Host <MAC address>    Specify MAC address for host
         -V|--Volume <file path>    Path to device/LUN file to use as mass-storage
         -r|--remove                Remove gadgets
         -w|--watch <seconds>       Regularly watch for an ad-hoc Host connection
         -h|--help                  Help information and usage

__EOF__
exit "$1"
}

OPTS=$(getopt -l Device:,help,Host:,remove,Volume:,watch: -n "$SCRIPT" -o D:hH:rV:w: -- "$@") || usage 1
eval set -- "$OPTS"
while true; do
	case "$1" in
		-D|--Device) shift
					if echo "$1" | grep -q "[[:xdigit:]:]\{17\}"; then
						dev_mac="$1"
					else
						usage 1 "MAC address pattern must be in HEX form xx:xx:xx:xx:xx:xx"
					fi
			;;
		-h|--help) echo "$SCRIPT $VERSION"; usage 0;;
		-H|--Host) shift
					if echo "$1" | grep -q "[[:xdigit:]:]\{17\}"; then
						host_mac="$1"
					else
						usage 1 "MAC address pattern must be in HEX form xx:xx:xx:xx:xx:xx"
					fi
			;;
		-r|--remove) action="remove";;
		-V|--Volume) shift
					if [ -e "$1" ]; then
						MASS_STORAGE_LUN="$1" # mass-storage device/file
					else
						usage 1 "Volume storage not existing !"
					fi
			;;
		-w|--watch) shift
					if  [ "$1" -gt "0" ] 2>/dev/null; then
						polling="$1"
					else
						usage 1 "Polling intervall must be a positive integer (seconds)"
					fi
			;;
		--) break;;
	esac
	shift
done

if ! [ "$action" = "remove" ]; then
	_init
	_setup
else
	_remove
fi
