/*
 * pppoe_acs_server.c - utility tool to discover ACS server over
 *                      PPPoE interface using DHCP inform
 *
 * Copyright (C) 2026-2027, IOPSYS Software Solutions AB.
 *
 *	  Author Suvendhu Hansa <suvendhu.hansa@iopsys.eu>
 *
 * See LICENSE file for license related information.
 *
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <limits.h>
#include <syslog.h>
#include <sys/socket.h>
#include <sys/ioctl.h>
#include <sys/time.h>
#include <net/if.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define DHCP_SERVER_PORT   67
#define DHCP_CLIENT_PORT   68
#define DHCP_MAGIC_COOKIE  0x63825363
#define DHCP_INFORM        8
#define DHCP_TIMEOUT_SEC   5
#define DHCP_RETRY_DEFAULT 3

struct dhcp_packet {
	uint8_t  op;
	uint8_t  htype;
	uint8_t  hlen;
	uint8_t  hops;
	uint32_t xid;
	uint16_t secs;
	uint16_t flags;
	uint32_t ciaddr;
	uint32_t yiaddr;
	uint32_t siaddr;
	uint32_t giaddr;
	uint8_t  chaddr[16];
	uint8_t  sname[64];
	uint8_t  file[128];
	// cppcheck-suppress cert-API01-C
	uint32_t magic;
	uint8_t  options[308];
} __attribute__((packed));

/* BOOTP header (236) + magic cookie (4) + options (308) = 548 bytes */
_Static_assert(sizeof(struct dhcp_packet) == 548,
	       "struct dhcp_packet size does not match DHCP wire format");

/* All sub-options extracted from DHCP Option 43 (DSL Forum / TR-069) */
struct dhcp_opt43 {
	char url[256];         /* sub-option 1: ACS URL */
	char prov_code[256];   /* sub-option 2: Provisioning Code */
	char min_wait_ivl[32]; /* sub-option 3: CWMPRetryMinimumWaitInterval */
	char ivl_mult[32];     /* sub-option 4: CWMPRetryIntervalMultiplier */
};

static void build_dhcp_inform(struct dhcp_packet *pkt, const char *client_ip,
			      const uint8_t *mac, const char *vendor_class)
{
	uint8_t vlen;
	int opt = 0;

	memset(pkt, 0, sizeof(*pkt));

	syslog(LOG_INFO, "using MAC %02x:%02x:%02x:%02x:%02x:%02x",
	       mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);

	pkt->op    = 1;  /* BOOTREQUEST */
	pkt->htype = 1;  /* Ethernet */
	pkt->hlen  = 6;
	pkt->hops  = 0;
	pkt->xid   = htonl((uint32_t)getpid());
	pkt->secs  = 0;
	pkt->flags = 0;  /* unicast reply */

	pkt->ciaddr = inet_addr(client_ip);
	pkt->yiaddr = 0;
	pkt->siaddr = 0;
	pkt->giaddr = 0;

	memcpy(pkt->chaddr, mac, 6);
	pkt->magic = htonl(DHCP_MAGIC_COOKIE);

	/* option 53: DHCP Message Type = INFORM */
	pkt->options[opt++] = 53;
	pkt->options[opt++] = 1;
	pkt->options[opt++] = DHCP_INFORM;

	/* option 60: Vendor Class Identifier */
	vlen = (uint8_t)strlen(vendor_class);
	pkt->options[opt++] = 60;
	pkt->options[opt++] = vlen;
	memcpy(&pkt->options[opt], vendor_class, vlen);
	opt += vlen;

	/* option 55: Parameter Request List -- request option 43 only */
	pkt->options[opt++] = 55;
	pkt->options[opt++] = 1;
	pkt->options[opt++] = 43;

	/* option 255: End */
	pkt->options[opt++] = 255;
}

static int hex_digit(uint8_t c)
{
	if (c >= '0' && c <= '9') return c - '0';
	if (c >= 'a' && c <= 'f') return c - 'a' + 10;
	if (c >= 'A' && c <= 'F') return c - 'A' + 10;
	return -1;
}

/*
 * Decode a hex-encoded string (e.g. "687474703a2f2f...") into printable ASCII.
 * Returns 1 and fills out on success, 0 if data is not valid hex or decodes
 * to non-printable bytes.
 */
static int decode_hex_url(const uint8_t *data, int len, char *out, size_t out_len)
{
	int i;
	int out_len_int;
	uint8_t byte;

	if (len == 0 || len % 2 != 0)
		return 0;

	for (i = 0; i < len; i++) {
		if (hex_digit(data[i]) < 0)
			return 0;
	}

	out_len_int = len / 2;
	if ((size_t)out_len_int >= out_len)
		out_len_int = (int)out_len - 1;

	for (i = 0; i < out_len_int; i++) {
		byte = (uint8_t)((hex_digit(data[i * 2]) << 4) | hex_digit(data[i * 2 + 1]));
		if (byte < 0x20 || byte > 0x7e)
			return 0;
		out[i] = (char)byte;
	}
	out[out_len_int] = '\0';
	return 1;
}

/*
 * Decode a numeric sub-option value into a decimal string.
 * Tries hex-encoded ASCII first (e.g. "3500" -> "5\0\0" - no, "35 30 30" = "500"),
 * then raw 4-byte big-endian integer, then raw printable ASCII.
 * Returns 1 on success, 0 on failure.
 */
static int decode_numeric(const uint8_t *data, int len, char *out, size_t out_len)
{
	uint32_t val;
	size_t copy;
	int j;

	/* Try hex-encoded ASCII (DHCP servers commonly use this format) */
	if (decode_hex_url(data, len, out, out_len))
		return 1;

	/* Try raw 4-byte big-endian integer (as defined in TR-069 Annex G) */
	if (len == 4) {
		val = ((uint32_t)data[0] << 24) | ((uint32_t)data[1] << 16) |
		      ((uint32_t)data[2] << 8)  |  (uint32_t)data[3];
		snprintf(out, out_len, "%u", val);
		return 1;
	}

	/* Fall back to raw printable ASCII */
	if (len > 0) {
		copy = (size_t)len < out_len - 1 ? (size_t)len : out_len - 1;
		for (j = 0; j < (int)copy; j++) {
			if (data[j] < 0x20 || data[j] > 0x7e)
				return 0;
		}
		memcpy(out, data, copy);
		out[copy] = '\0';
		return 1;
	}

	return 0;
}

/*
 * Parse DHCP Option 43 (Vendor Encapsulated Options) into opt.
 * Handles all DSL Forum sub-options (1-4) in TLV format, with hex-encoded
 * and raw ASCII fallbacks. Also handles plain-string (non-TLV) ACS URLs.
 * Returns 1 if at least the ACS URL (sub-option 1) was found, 0 otherwise.
 */
static int parse_option43(uint8_t *data, int len, struct dhcp_opt43 *opt)
{
	char hexbuf[512];
	int i = 0;
	int j;
	int pos;
	int found = 0;
	size_t copy;

	pos = 0;
	for (j = 0; j < len && pos < (int)sizeof(hexbuf) - 3; j++)
		pos += snprintf(hexbuf + pos, sizeof(hexbuf) - pos, "%02x ", data[j]);
	syslog(LOG_DEBUG, "option 43 raw (%d bytes): %s", len, hexbuf);

	/* Try TLV format: Type(1) + Length(1) + Value */
	while (i + 2 <= len) {
		uint8_t type = data[i++];
		uint8_t vlen = data[i++];

		if (i + vlen > len)
			break;

		switch (type) {
		case 1: /* ACS URL */
			if (decode_hex_url(&data[i], vlen, opt->url, sizeof(opt->url))) {
				syslog(LOG_INFO, "option 43 ACS URL (TLV hex-encoded): %s", opt->url);
			} else {
				copy = (size_t)vlen < sizeof(opt->url) - 1 ? (size_t)vlen : sizeof(opt->url) - 1;
				memcpy(opt->url, &data[i], copy);
				opt->url[copy] = '\0';
				syslog(LOG_INFO, "option 43 ACS URL (TLV): %s", opt->url);
			}
			found = 1;
			break;
		case 2: /* Provisioning Code */
			if (decode_hex_url(&data[i], vlen, opt->prov_code, sizeof(opt->prov_code))) {
				syslog(LOG_INFO, "option 43 Provisioning Code (hex-encoded): %s", opt->prov_code);
			} else {
				copy = (size_t)vlen < sizeof(opt->prov_code) - 1 ? (size_t)vlen : sizeof(opt->prov_code) - 1;
				memcpy(opt->prov_code, &data[i], copy);
				opt->prov_code[copy] = '\0';
				syslog(LOG_INFO, "option 43 Provisioning Code: %s", opt->prov_code);
			}
			break;
		case 3: /* CWMPRetryMinimumWaitInterval */
			if (decode_numeric(&data[i], vlen, opt->min_wait_ivl, sizeof(opt->min_wait_ivl)))
				syslog(LOG_INFO, "option 43 RetryMinWaitInterval: %s", opt->min_wait_ivl);
			break;
		case 4: /* CWMPRetryIntervalMultiplier */
			if (decode_numeric(&data[i], vlen, opt->ivl_mult, sizeof(opt->ivl_mult)))
				syslog(LOG_INFO, "option 43 RetryIntervalMultiplier: %s", opt->ivl_mult);
			break;
		default:
			pos = 0;
			for (j = 0; j < vlen && pos < (int)sizeof(hexbuf) - 3; j++)
				pos += snprintf(hexbuf + pos, sizeof(hexbuf) - pos, "%02x ", data[i + j]);
			syslog(LOG_DEBUG, "option 43 sub-option %d (len=%d): %s", type, vlen, hexbuf);
			break;
		}
		i += vlen;
	}

	if (!found) {
		/* Fall back to plain ASCII string if no TLV sub-option 1 was found */
		int printable = 1;
		for (j = 0; j < len; j++) {
			if (data[j] < 0x20 || data[j] > 0x7e) {
				printable = 0;
				break;
			}
		}
		if (printable && len > 0) {
			copy = (size_t)len < sizeof(opt->url) - 1 ? (size_t)len : sizeof(opt->url) - 1;
			memcpy(opt->url, data, copy);
			opt->url[copy] = '\0';
			syslog(LOG_INFO, "option 43 ACS URL (plain string): %s", opt->url);
			found = 1;
		}
	}

	return found;
}

/*
 * Parse DHCP options from a received DHCPACK.
 * Returns 1 and fills opt if Option 43 contains an ACS URL, 0 otherwise.
 */
static int parse_response(struct dhcp_packet *pkt, struct dhcp_opt43 *opt)
{
	uint8_t *opts = pkt->options;
	uint8_t code;
	uint8_t olen;
	int i = 0;
	int found = 0;

	syslog(LOG_INFO, "DHCPACK received");

	while (i < (int)sizeof(pkt->options)) {
		code = opts[i++];

		if (code == 255)
			break;
		if (code == 0)
			continue;

		if (i >= (int)sizeof(pkt->options))
			break;

		olen = opts[i++];

		if (i + olen > (int)sizeof(pkt->options))
			break;

		switch (code) {
		case 43:
			if (parse_option43(&opts[i], olen, opt))
				found = 1;
			break;
		case 53:
			syslog(LOG_DEBUG, "DHCP message type: %d", opts[i]);
			break;
		case 54:
			syslog(LOG_DEBUG, "server ID: %d.%d.%d.%d",
			       opts[i], opts[i + 1], opts[i + 2], opts[i + 3]);
			break;
		default:
			break;
		}
		i += olen;
	}

	return found;
}

static int parse_log_level(const char *s)
{
	if (strcmp(s, "debug")   == 0) return LOG_DEBUG;
	if (strcmp(s, "info")    == 0) return LOG_INFO;
	if (strcmp(s, "notice")  == 0) return LOG_NOTICE;
	if (strcmp(s, "warning") == 0) return LOG_WARNING;
	if (strcmp(s, "err")     == 0) return LOG_ERR;
	if (strcmp(s, "error")   == 0) return LOG_ERR;
	if (strcmp(s, "crit")    == 0) return LOG_CRIT;
	return -1;
}

int main(int argc, char *argv[])
{
	const char *iface;
	const char *server_ip;
	const char *client_ip;
	const char *vendor = "dslforum.org";
	uint8_t mac[6];
	int retry = DHCP_RETRY_DEFAULT;
	int log_level = LOG_ERR;
	int sock;
	int on = 1;
	int n;
	int attempt;
	int got_response = 0;
	struct dhcp_packet pkt;
	struct dhcp_packet resp;
	struct dhcp_opt43 opt43;
	struct sockaddr_in server_addr;
	struct sockaddr_in bind_addr;
	struct sockaddr_in from;
	struct timeval tv;
	struct ifreq ifr;
	socklen_t from_len;

	if (argc == 2 && strcmp(argv[1], "--help") == 0) {
		printf("Usage:   %s <iface> <client_ip> <client_mac> <server_ip> [retry] [log_level]\n", argv[0]);
		printf("\n");
		printf("  iface       PPPoE interface (e.g. pppoe-wan)\n");
		printf("  client_ip   IPCP-assigned IP address\n");
		printf("  client_mac  Client MAC address (xx:xx:xx:xx:xx:xx)\n");
		printf("  server_ip   DHCP server unicast IP address\n");
		printf("  retry       Send/receive attempts (default: %d)\n", DHCP_RETRY_DEFAULT);
		printf("  log_level   Syslog level: debug, info, notice, warning, err, crit (default: err)\n");
		printf("\n");
		printf("On success: prints shell KEY=VALUE pairs to stdout and exits 0.\n");
		printf("  DHCP_ACS_URL        ACS URL (sub-option 1, always present on success)\n");
		printf("  DHCP_PROV_CODE      Provisioning Code (sub-option 2, if present)\n");
		printf("  DHCP_MIN_WAIT_IVL   Retry min wait interval (sub-option 3, if present)\n");
		printf("  DHCP_IVL_MULTIPLIER Retry interval multiplier (sub-option 4, if present)\n");
		printf("On failure: exits 1 with no stdout output.\n");
		printf("\n");
		printf("Example:\n");
		printf("  %s pppoe-wan 10.102.95.100 aa:bb:cc:dd:ee:ff 10.102.95.1\n", argv[0]);
		printf("  result=$(%s pppoe-wan 10.102.95.100 aa:bb:cc:dd:ee:ff 10.102.95.1)\n", argv[0]);
		return 0;
	}

	/* Pre-parse log_level so setlogmask() is set before any syslog() call */
	if (argc == 7) {
		log_level = parse_log_level(argv[6]);
		if (log_level < 0)
			log_level = LOG_ERR;
	}

	openlog("pppoe-acs-discover", LOG_PID | LOG_NDELAY, LOG_DAEMON);
	setlogmask(LOG_UPTO(log_level));

	if (argc < 5 || argc > 7) {
		syslog(LOG_ERR, "usage: %s <iface> <client_ip> <client_mac> <server_ip> [retry] [log_level]",
		       argv[0]);
		closelog();
		return 1;
	}

	iface     = argv[1];
	client_ip = argv[2];

	if (inet_addr(client_ip) == INADDR_NONE) {
		syslog(LOG_ERR, "invalid client IP address: %s", client_ip);
		closelog();
		return 1;
	}

	if (sscanf(argv[3], "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx",
		   &mac[0], &mac[1], &mac[2], &mac[3], &mac[4], &mac[5]) != 6) {
		syslog(LOG_ERR, "invalid MAC address: %s", argv[3]);
		closelog();
		return 1;
	}

	server_ip = argv[4];

	if (inet_addr(server_ip) == INADDR_NONE) {
		syslog(LOG_ERR, "invalid server IP address: %s", server_ip);
		closelog();
		return 1;
	}

	if (argc >= 6) {
		char *end;
		long val = strtol(argv[5], &end, 10);
		if (*end != '\0' || val <= 0 || val > INT_MAX) {
			syslog(LOG_ERR, "retry must be a positive integer");
			closelog();
			return 1;
		}
		retry = (int)val;
	}

	syslog(LOG_INFO, "starting: iface=%s client_ip=%s server_ip=%s retry=%d",
	       iface, client_ip, server_ip, retry);

	build_dhcp_inform(&pkt, client_ip, mac, vendor);

	/*
	 * PPPoE socket ordering constraint (kernel 5.4.55):
	 * bind() to INADDR_ANY:68 must come before SO_BINDTODEVICE.
	 * SO_BINDTODEVICE after bind forces outgoing packets through the PPPoE
	 * interface while INADDR_ANY still receives the unicast DHCPACK correctly.
	 */
	sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
	if (sock < 0) {
		syslog(LOG_ERR, "socket: %m");
		closelog();
		return 1;
	}

	setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

	memset(&bind_addr, 0, sizeof(bind_addr));
	bind_addr.sin_family      = AF_INET;
	bind_addr.sin_port        = htons(DHCP_CLIENT_PORT);
	bind_addr.sin_addr.s_addr = htonl(INADDR_ANY);

	if (bind(sock, (struct sockaddr *)&bind_addr, sizeof(bind_addr)) < 0) {
		syslog(LOG_ERR, "bind: %m");
		close(sock);
		closelog();
		return 1;
	}

	memset(&ifr, 0, sizeof(ifr));
	strncpy(ifr.ifr_name, iface, IFNAMSIZ - 1);
	if (setsockopt(sock, SOL_SOCKET, SO_BINDTODEVICE, &ifr, sizeof(ifr)) < 0) {
		syslog(LOG_ERR, "SO_BINDTODEVICE: %m");
		close(sock);
		closelog();
		return 1;
	}

	memset(&server_addr, 0, sizeof(server_addr));
	server_addr.sin_family      = AF_INET;
	server_addr.sin_port        = htons(DHCP_SERVER_PORT);
	server_addr.sin_addr.s_addr = inet_addr(server_ip);

	tv.tv_sec  = DHCP_TIMEOUT_SEC;
	tv.tv_usec = 0;
	setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));

	for (attempt = 1; attempt <= retry && !got_response; attempt++) {
		if (attempt > 1)
			sleep(1);

		syslog(LOG_INFO, "sending DHCPINFORM (attempt %d/%d)", attempt, retry);

		n = sendto(sock, &pkt, sizeof(pkt), 0,
			   (struct sockaddr *)&server_addr, sizeof(server_addr));
		if (n < 0) {
			syslog(LOG_WARNING, "sendto failed (attempt %d/%d): %m", attempt, retry);
			continue;
		}

		syslog(LOG_DEBUG, "sent %d bytes, waiting for DHCPACK (%ds timeout)",
		       n, DHCP_TIMEOUT_SEC);

		while (1) {
			memset(&from, 0, sizeof(from));
			from_len = sizeof(from);

			n = recvfrom(sock, &resp, sizeof(resp), 0,
				     (struct sockaddr *)&from, &from_len);
			if (n < 0) {
				if (errno == EAGAIN || errno == EWOULDBLOCK)
					syslog(LOG_WARNING, "attempt %d/%d: no DHCPACK within %ds",
					       attempt, retry, DHCP_TIMEOUT_SEC);
				else
					syslog(LOG_ERR, "recvfrom: %m");
				break;
			}

			/* minimum valid DHCP: BOOTP header (236) + magic cookie (4) */
			if (n < 240)
				continue;

			if (resp.op != 2)  /* must be BOOTREPLY */
				continue;

			if (resp.xid != htonl((uint32_t)getpid()))  /* match transaction ID */
				continue;

			syslog(LOG_INFO, "reply from %s", inet_ntoa(from.sin_addr));
			got_response = 1;
			break;
		}
	}

	close(sock);

	if (!got_response) {
		syslog(LOG_ERR, "no DHCPACK received after %d attempt(s)", retry);
		closelog();
		return 1;
	}

	memset(&opt43, 0, sizeof(opt43));
	if (!parse_response(&resp, &opt43) || opt43.url[0] == '\0') {
		syslog(LOG_ERR, "DHCPACK contains no ACS URL in option 43");
		closelog();
		return 1;
	}

	/*
	 * Output shell KEY=VALUE pairs for the caller to parse line by line.
	 * ACS URL is always present; other fields are printed only if discovered.
	 */
	printf("DHCP_ACS_URL=%s\n", opt43.url);
	if (opt43.prov_code[0])
		printf("DHCP_PROV_CODE=%s\n", opt43.prov_code);
	if (opt43.min_wait_ivl[0])
		printf("DHCP_MIN_WAIT_IVL=%s\n", opt43.min_wait_ivl);
	if (opt43.ivl_mult[0])
		printf("DHCP_IVL_MULTIPLIER=%s\n", opt43.ivl_mult);

	closelog();
	return 0;
}
