Lighttpd and UBUS over JSON-RPC

The UBUS over http bridge is implemented as a plugin for uhttpd. There is also nginx-mod-ubus. But how about other webservers like Lighttpd or Apache?
On my router I using Lighttpd for WebDAV share and on the same server I running my pet-project (yurt-page, a small homepage CMS). And the app was built upon ubus api and it's broken now.
I created a simple ubus.sh cgi script that redirects JSON-RPC calls into ubus command:

#!/bin/sh
body=$(cat)

UBUS_METHOD=$(echo -n "$body" | jsonfilter -e '@.method')
UBUS_SID=$(echo -n "$body" | jsonfilter -e '@.params[0]')
UBUS_SERVICE=$(echo -n "$body" | jsonfilter -e '@.params[1]')
UBUS_CMD=$(echo -n "$body" | jsonfilter -e '@.params[2]')
UBUS_PAYLOAD=$(echo -n "$body" | jsonfilter -e '@.params[3]')
UBUS_RESP=$(ubus "$UBUS_METHOD" "$UBUS_SERVICE" "$UBUS_CMD" "$UBUS_PAYLOAD")
UBUS_ERR=$?
if [ ${UBUS_ERR} -eq 0 ]; then
  printf 'Content-Type: application/json\r\n\r\n{"result":[0,%s]}' "$UBUS_RESP"
else
  printf 'Content-Type: application/json\r\n\r\n{"error":{"code":-32603,"message":"Internal JSON-RPC error."}}'
fi

What if instead of the shell wscript create a CGI program based on the uhttpd/ubus.c? Does anybody interested in it?

Also since the ubus command must be executed as root but the Lighttpd is running win http user I also had to comment out server.username and server.groupname in the /etc/lighttpd/lighttpd.conf.
Maybe I can somehow allow to the http user to call ubus? I saw there is a ubus group so maybe I can add the http user into it.

Another problem is that the ubus_rpc_session is not checked when called from the command line. I can verify it myself by an additional ubus call but it will look ugly and slow.

Maybe it worth to add an additional "session" param to ubus utility? I see that many ubus services methods requires the session:
ubus -v list | grep ubus_rpc_session

BTW the lighttpd is used by GL.Inet and Turris by default.

Why not simply reverse proxy to uhttpd or nginx w/ ubus plugin? I suppose the runtime memory footprint of a single uhttpd instance only serving /ubus will not be much higher than a custom CGI script written in a script language of your choice.

If you want to go the CGI route you might want to consider using Lua and turn LuCI's /ubus fallback gateway into a standalone application.

1 Like

Thank you for the idea, this may be a good option. My WR1043N router can handle both uhttpd and lighttpd + mod_proxy. This will make configuration slightly complicated and use more resources but may work as temporary solution.

I still think about creation of cgi-ubus in future because I want to create a separate OpenWrt distro for 4mb devices and use BusyBox httpd there instead of uhttpd.
The reason to this because I need more space to fit a home page and each kilobyte is valuable. My main users will be pupils or students that can't buy an expensive router.

Btw, you can slightly optimize your original script like that:

#!/bin/sh

# - pass /proc/self/fd/0 to let jsonfilter read stdin directly
# - use `VAR=expr` notation to let it create shell compatible export statements
# - eval result to import variables
eval $(jsonfilter -i /proc/self/fd/0 \
    -e 'UBUS_METHOD=@.method' \
    -e 'UBUS_SID=@.params[0]' \
    -e 'UBUS_SERVICE=@.params[1]' \
    -e 'UBUS_CMD=@.params[2]' \
    -e 'UBUS_PAYLOAD=@.params[3]')

UBUS_RESP=$(ubus "$UBUS_METHOD" "$UBUS_SERVICE" "$UBUS_CMD" "$UBUS_PAYLOAD")
UBUS_ERR=$?

if [ ${UBUS_ERR} -eq 0 ]; then
  printf 'Content-Type: application/json\r\n\r\n{"result":[0,%s]}' "$UBUS_RESP"
else
  printf 'Content-Type: application/json\r\n\r\n{"error":{"code":-32603,"message":"Internal JSON-RPC error."}}'
fi
1 Like

Thank you for the sample. Unfortunately it doesn't works because the params[3] is a complex object that is serialized incorrectly then using the VAR= notation.

Indeed, I didn't consider that. Below is a tested version that implements session checking, embeds ubus_rpc_session if needed and supports the brief and detailed list commands.

#!/bin/sh

access() {
	local sid=$1
	local obj=$2
	local fun=$3

	local req=$(printf '{ "ubus_rpc_session": "%s", "scope": "ubus", "object": "%s", "function": "%s" }' "$sid" "$obj" "$fun")
	local res=$(ubus call session access "$req" | jsonfilter -e '@.access')

	[ "$res" = "true" ]
}

error() {
	local code=$1
	local mesg=$2

	printf '{ "jsonrpc": "2.0", "id": "%s", "error": { "code": %d, "message": "%s" } }'	\
		"${RPC_ID:-null}" "$code" "$mesg"

	exit 1
}

request=$(cat)

# - pass /proc/self/fd/0 to let jsonfilter read stdin directly
# - use `VAR=expr` notation to let it create shell compatible export statements
# - eval result to import variables
eval $(jsonfilter -s "$request" \
	-e 'RPC_ID=@.id' \
	-e 'RPC_VERSION=@.jsonrpc' \
	-e 'RPC_METHOD=@.method' \
	-e 'RPC_SESSION_ARG=@.params[3].ubus_rpc_session' \
	-e 'UBUS_SID=@.params[0]' \
	-e 'UBUS_SERVICE=@.params[1]' \
	-e 'UBUS_CMD=@.params[2]')

printf 'Content-Type: application/json\r\n\r\n'

# verify JSON-RPC framing
if [ -z "$RPC_ID" ] || [ "$RPC_VERSION" != "2.0" ]; then
	error -32600 "Invalid request"
fi

# reject invalid values to prevent shell injection
case "$RPC_ID$UBUS_SID$UBUS_SERVICE$UBUS_CMD" in
	*[^a-zA-Z0-9_.-]*) error -32600 "Invalid request" ;;
esac

case "$RPC_METHOD" in
	call)
		UBUS_PAYLOAD=$(jsonfilter -s "$request" -e '@.params[3]')

		# ensure that payload is a dictionary or empty
		case "$UBUS_PAYLOAD" in
			""|{*}) : ;;
			*) error -32602 "Invalid parameters" ;;
		esac

		# merge ubus_rpc_session parameter
		if [ -z "$UBUS_PAYLOAD" ] || [ "$UBUS_PAYLOAD" = "{ }" ]; then
			UBUS_PAYLOAD=$(printf '{ "ubus_rpc_session": "%s" }' "$UBUS_SID")
		else
			UBUS_PAYLOAD=$(printf '{ "ubus_rpc_session": "%s", %s' "$UBUS_SID" "${UBUS_PAYLOAD#\{ }")
		fi

		# reject requests with embedded ubus_rpc_session
		if [ -n "$RPC_SESSION_ARG" ]; then
			error -32602 "Invalid parameters"
		fi

		# check access
		if ! access "$UBUS_SID" "$UBUS_SERVICE" "$UBUS_CMD"; then
			error -32002 "Access denied"
		fi

		ubus_reply=$(ubus call "$UBUS_SERVICE" "$UBUS_CMD" "$UBUS_PAYLOAD")
		ubus_status=$?

		printf '{ "jsonrpc": "2.0", "id": "%s", "result": [ %d, %s ] }' \
			"$RPC_ID" "$ubus_status" "${ubus_reply:-null}"
	;;
	list)
		RPC_PARAMS=$(jsonfilter -s "$request" -e '@.params')

		# ensure that payload is an array or empty
		case "${RPC_PARAMS:-[ ]}" in
			\[*\]) : ;;
			*) error -32602 "Invalid parameters" ;;
		esac

		# empty payload should result in list of services
		if [ "${RPC_PARAMS:-[ ]}" = "[ ]" ]; then
			services=''

			for service in $(ubus list); do
				services="${services:+$services, }\"$service\""
			done

			printf '{ "jsonrpc": "2.0", "id": "%s", "result": [ %s ] }' \
				"$RPC_ID" "$services"

		# list of services should result in { service => { method => signature } } replies
		else
			signatures=''

			eval $(jsonfilter -s "$RPC_PARAMS" -e 'indexes=@')

			for i in $indexes; do
				service=$(jsonfilter -s "$RPC_PARAMS" -e "@[$i]")
				signature=''

				IFS=$'\n\t'
				for line in $(ubus -v list "$service" | tail -n +2); do
					signature="${signature:+$signature, }$line"
				done
				IFS=$' \n\t'

				signatures="${signatures:+$signatures, }\"$service\": { $signature }"
			done

			printf '{ "jsonrpc": "2.0", "id": "%s", "result": { %s } }' \
				"$RPC_ID" "$signatures"
		fi
	;;
	*)
		error -32601 "Method not found"
	;;
esac
1 Like

Thank you! I put the script into https://github.com/yurt-page/cgi-ubus and later I'll create a feed to simplify it's installation. Also I mentioned the script on ubus wiki page.

Maybe it worth to add an additional "session" param to ubus utility?

I mean that instead of:

ubus call file list '{"path": "/etc/", "ubus_rpc_session": "SESSION"}'

use

ubus call file list '{"path": "/etc/"}' -s SESSION

I think this will make it more clear (and safe).
Does this proposition makes any sense?

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.