/*
 * Check for modules that are required across the network, as well as modules
 * that *aren't* even allowed (deny/require module { } blocks)
 * (C) Copyright 2019 Gottem and the UnrealIRCd team
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 1, or (at your option)
 * any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
 */

#include "unrealircd.h"

#define MSG_SMOD "SMOD"
#define SMOD_FLAG_REQUIRED 'R'
#define SMOD_FLAG_GLOBAL 'G'
#define SMOD_FLAG_LOCAL 'L'

ModuleHeader MOD_HEADER = {
	"require-module",
	"5.0.1",
	"Require/deny modules across the network",
	"UnrealIRCd Team",
	"unrealircd-6",
};

typedef struct _denymod DenyMod;
struct _denymod {
	DenyMod *prev, *next;
	char *name;
	char *reason;
};

typedef struct _requiremod ReqMod;
struct _requiremod {
	ReqMod *prev, *next;
	char *name;
	char *minversion;
};

// Forward declarations
Module *find_modptr_byname(char *name, unsigned strict);
DenyMod *find_denymod_byname(char *name);
ReqMod *find_reqmod_byname(char *name);

int reqmods_configtest(ConfigFile *cf, ConfigEntry *ce, int type, int *errs);
int reqmods_configrun(ConfigFile *cf, ConfigEntry *ce, int type);

int reqmods_configtest_deny(ConfigFile *cf, ConfigEntry *ce, int type, int *errs);
int reqmods_configrun_deny(ConfigFile *cf, ConfigEntry *ce, int type);

int reqmods_configtest_require(ConfigFile *cf, ConfigEntry *ce, int type, int *errs);
int reqmods_configrun_require(ConfigFile *cf, ConfigEntry *ce, int type);

CMD_FUNC(cmd_smod);
int reqmods_hook_serverconnect(Client *client);

// Globals
extern MODVAR Module *Modules;
DenyMod *DenyModList = NULL;
ReqMod *ReqModList = NULL;

MOD_TEST()
{
	HookAdd(modinfo->handle, HOOKTYPE_CONFIGTEST, 0, reqmods_configtest);
	return MOD_SUCCESS;
}

MOD_INIT()
{
	MARK_AS_OFFICIAL_MODULE(modinfo);
	MARK_AS_GLOBAL_MODULE(modinfo);
	HookAdd(modinfo->handle, HOOKTYPE_CONFIGRUN, 0, reqmods_configrun);
	HookAdd(modinfo->handle, HOOKTYPE_SERVER_CONNECT, 0, reqmods_hook_serverconnect);
	CommandAdd(modinfo->handle, MSG_SMOD, cmd_smod, MAXPARA, CMD_SERVER);
	return MOD_SUCCESS;
}

MOD_LOAD()
{
	if (ModuleGetError(modinfo->handle) != MODERR_NOERROR)
	{
		config_error("A critical error occurred when loading module %s: %s", MOD_HEADER.name, ModuleGetErrorStr(modinfo->handle));
		return MOD_FAILED;
	}
	return MOD_SUCCESS;
}

MOD_UNLOAD()
{
	DenyMod *dmod, *dnext;
	ReqMod *rmod, *rnext;
	for (dmod = DenyModList; dmod; dmod = dnext)
	{
		dnext = dmod->next;
		safe_free(dmod->name);
		safe_free(dmod->reason);
		DelListItem(dmod, DenyModList);
		safe_free(dmod);
	}
	for (rmod = ReqModList; rmod; rmod = rnext)
	{
		rnext = rmod->next;
		safe_free(rmod->name);
		safe_free(rmod->minversion);
		DelListItem(rmod, ReqModList);
		safe_free(rmod);
	}
	DenyModList = NULL;
	ReqModList = NULL;
	return MOD_SUCCESS;
}

Module *find_modptr_byname(char *name, unsigned strict)
{
	Module *mod;
	for (mod = Modules; mod; mod = mod->next)
	{
		// Let's not be too strict with the name
		if (!strcasecmp(mod->header->name, name))
		{
			if (strict && !(mod->flags & MODFLAG_LOADED))
				mod = NULL;
			return mod;
		}
	}
	return NULL;
}

DenyMod *find_denymod_byname(char *name)
{
	DenyMod *dmod;
	for (dmod = DenyModList; dmod; dmod = dmod->next)
	{
		if (!strcasecmp(dmod->name, name))
			return dmod;
	}
	return NULL;
}

ReqMod *find_reqmod_byname(char *name)
{
	ReqMod *rmod;
	for (rmod = ReqModList; rmod; rmod = rmod->next)
	{
		if (!strcasecmp(rmod->name, name))
			return rmod;
	}
	return NULL;
}

int reqmods_configtest(ConfigFile *cf, ConfigEntry *ce, int type, int *errs)
{
	if (type == CONFIG_DENY)
		return reqmods_configtest_deny(cf, ce, type, errs);

	if (type == CONFIG_REQUIRE)
		return reqmods_configtest_require(cf, ce, type, errs);

	return 0;
}

int reqmods_configrun(ConfigFile *cf, ConfigEntry *ce, int type)
{
	if (type == CONFIG_DENY)
		return reqmods_configrun_deny(cf, ce, type);

	if (type == CONFIG_REQUIRE)
		return reqmods_configrun_require(cf, ce, type);

	return 0;
}

int reqmods_configtest_deny(ConfigFile *cf, ConfigEntry *ce, int type, int *errs)
{
	int errors = 0;
	ConfigEntry *cep;
	int has_name, has_reason;

	// We are only interested in deny module { }
	if (strcmp(ce->value, "module"))
		return 0;

	has_name = has_reason = 0;
	for (cep = ce->items; cep; cep = cep->next)
	{
		if (!strlen(cep->name))
		{
			config_error("%s:%i: blank directive for deny module { } block", cep->file->filename, cep->line_number);
			errors++;
			continue;
		}

		if (!cep->value || !strlen(cep->value))
		{
			config_error("%s:%i: blank %s without value for deny module { } block", cep->file->filename, cep->line_number, cep->name);
			errors++;
			continue;
		}

		if (!strcmp(cep->name, "name"))
		{
			if (has_name)
			{
				config_error("%s:%i: duplicate %s for deny module { } block", cep->file->filename, cep->line_number, cep->name);
				continue;
			}

			// We do a loose check here because a module might not be fully loaded yet
			if (find_modptr_byname(cep->value, 0))
			{
				config_error("[require-module] Module '%s' was specified as denied but we've actually loaded it ourselves", cep->value);
				errors++;
			}
			has_name = 1;
			continue;
		}

		if (!strcmp(cep->name, "reason")) // Optional
		{
			// Still check for duplicate directives though
			if (has_reason)
			{
				config_error("%s:%i: duplicate %s for deny module { } block", cep->file->filename, cep->line_number, cep->name);
				errors++;
				continue;
			}
			has_reason = 1;
			continue;
		}

		config_error("%s:%i: unknown directive %s for deny module { } block", cep->file->filename, cep->line_number, cep->name);
		errors++;
	}

	if (!has_name)
	{
		config_error("%s:%i: missing required 'name' directive for deny module { } block", ce->file->filename, ce->line_number);
		errors++;
	}

	*errs = errors;
	return errors ? -1 : 1;
}

int reqmods_configrun_deny(ConfigFile *cf, ConfigEntry *ce, int type)
{
	ConfigEntry *cep;
	DenyMod *dmod;

	if (strcmp(ce->value, "module"))
		return 0;

	dmod = safe_alloc(sizeof(DenyMod));
	for (cep = ce->items; cep; cep = cep->next)
	{
		if (!strcmp(cep->name, "name"))
		{
			safe_strdup(dmod->name, cep->value);
			continue;
		}

		if (!strcmp(cep->name, "reason"))
		{
			safe_strdup(dmod->reason, cep->value);
			continue;
		}
	}

	// Just use a default reason if none was specified (since it's optional)
	if (!dmod->reason || !strlen(dmod->reason))
		 safe_strdup(dmod->reason, "no reason");
	AddListItem(dmod, DenyModList);
	return 1;
}

int reqmods_configtest_require(ConfigFile *cf, ConfigEntry *ce, int type, int *errs)
{
	int errors = 0;
	ConfigEntry *cep;
	int has_name, has_minversion;

	// We are only interested in require module { }
	if (strcmp(ce->value, "module"))
		return 0;

	has_name = has_minversion = 0;
	for (cep = ce->items; cep; cep = cep->next)
	{
		if (!strlen(cep->name))
		{
			config_error("%s:%i: blank directive for require module { } block", cep->file->filename, cep->line_number);
			errors++;
			continue;
		}

		if (!cep->value || !strlen(cep->value))
		{
			config_error("%s:%i: blank %s without value for require module { } block", cep->file->filename, cep->line_number, cep->name);
			errors++;
			continue;
		}

		if (!strcmp(cep->name, "name"))
		{
			if (has_name)
			{
				config_error("%s:%i: duplicate %s for require module { } block", cep->file->filename, cep->line_number, cep->name);
				continue;
			}

			if (!find_modptr_byname(cep->value, 0))
			{
				config_error("[require-module] Module '%s' was specified as required but we didn't even load it ourselves (maybe double check the name?)", cep->value);
				errors++;
			}

			// Let's be nice and let configrun handle adding this module to the list
			has_name = 1;
			continue;
		}

		if (!strcmp(cep->name, "min-version")) // Optional
		{
			// Still check for duplicate directives though
			if (has_minversion)
			{
				config_error("%s:%i: duplicate %s for require module { } block", cep->file->filename, cep->line_number, cep->name);
				errors++;
				continue;
			}
			has_minversion = 1;
			continue;
		}

		// Reason directive is not used for require module { }, so error on that too
		config_error("%s:%i: unknown directive %s for require module { } block", cep->file->filename, cep->line_number, cep->name);
		errors++;
	}

	if (!has_name)
	{
		config_error("%s:%i: missing required 'name' directive for require module { } block", ce->file->filename, ce->line_number);
		errors++;
	}

	*errs = errors;
	return errors ? -1 : 1;
}

int reqmods_configrun_require(ConfigFile *cf, ConfigEntry *ce, int type)
{
	ConfigEntry *cep;
	Module *mod;
	ReqMod *rmod;
	char *name, *minversion;

	if (strcmp(ce->value, "module"))
		return 0;

	name = minversion = NULL;
	for (cep = ce->items; cep; cep = cep->next)
	{
		if (!strcmp(cep->name, "name"))
		{
			if (!(mod = find_modptr_byname(cep->value, 0)))
			{
				// Something went very wrong :D
				config_warn("[require-module] [BUG?] Passed configtest_require() but not configrun_require() for module '%s' (seems to not be loaded after all)", cep->value);
				continue;
			}

			name = cep->value;
			continue;
		}

		if (!strcmp(cep->name, "min-version"))
		{
			minversion = cep->value;
			continue;
		}
	}

	// While technically an error, let's not kill the entire server over it
	if (!name)
		return 1;

	rmod = safe_alloc(sizeof(ReqMod));
	safe_strdup(rmod->name, name);
	if (minversion)
		safe_strdup(rmod->minversion, minversion);
	AddListItem(rmod, ReqModList);
	return 1;
}

CMD_FUNC(cmd_smod)
{
	char modflag, name[64], *version;
	char buf[BUFSIZE];
	char *tmp, *p, *modbuf;
	Module *mod;
	DenyMod *dmod;
	int i;
	int abort;

	// A non-server client shouldn't really be possible here, but still :D
	if (!MyConnect(client) || !IsServer(client) || BadPtr(parv[1]))
		return;

	// Module strings are passed as 1 space-delimited parameter
	strlcpy(buf, parv[1], sizeof(buf));
	abort = 0;
	for (modbuf = strtoken(&tmp, buf, " "); modbuf; modbuf = strtoken(&tmp, NULL, " "))
	{
		/* The order of checks is:
		 * 1: deny module { } -- SQUIT always
		 * 2 (if module not loaded): require module { } -- SQUIT always
		 * 3 (if module not loaded): warn, but only if MOD_OPT_GLOBAL
		 * 4 (optional, if module loaded only): require module::min-version
		 */
		p = strchr(modbuf, ':');
		if (!p)
			continue; /* malformed request */
		modflag = *modbuf; // Get the module flag (FIXME: parses only first letter atm)
		modbuf = p+1;
		strlcpy(name, modbuf, sizeof(name)); // Let's work on a copy of the param

		version = strchr(name, ':');
		if (!version)
			continue; /* malformed request */
		*version++ = '\0';

		// Even if a denied module is only required locally, let's still prevent a server that uses it from linking in
		if ((dmod = find_denymod_byname(name)))
		{
			// Send this particular notice to local opers only
			unreal_log(ULOG_ERROR, "link", "LINK_DENY_MODULE", client,
			           "Server $client is using module '$module_name', "
			           "which is specified in a deny module { } config block (reason: $ban_reason) -- aborting link",
			           log_data_string("module_name", name),
			           log_data_string("ban_reason", dmod->reason));
			abort = 1; // Always SQUIT because it was explicitly denied by admins
			continue;
		}

		// Doing a strict check for the module being fully loaded so we can emit an alert in that case too :>
		mod = find_modptr_byname(name, 1);
		if (!mod)
		{
			/* Since only the server missing the module will report it, we need to broadcast the warning network-wide ;]
			 * Obviously we won't take any real action if the module seems to be locally required only, except if it's marked as required
			 */
			if (modflag == 'R')
			{
				// We don't need to check the version yet because there's nothing to compare it to, so we'll treat it as if no require module::min-version was specified
				unreal_log(ULOG_ERROR, "link", "LINK_MISSING_REQUIRED_MODULE", client,
				           "Server $me is missing module '$module_name' which "
				           "is required by server $client. -- aborting link",
				           log_data_client("me", &me),
				           log_data_string("module_name", name));
				abort = 1; // Always SQUIT here too (explicitly required by admins)
			}
			else if (modflag == 'G')
			{
				unreal_log(ULOG_WARNING, "link", "LINK_MISSING_GLOBAL_MODULE", client,
				           "Server $me is missing module '$module_name', which is "
				           "marked as global at $client",
				           log_data_client("me", &me),
				           log_data_string("module_name", name));
			}
			continue;
		}

		// Further checks are only necessary for explicitly required mods
		if (modflag != 'R')
			continue;

		// Module is loaded on both servers and the other end is require { }'ing a specific module version
		// An explicit version was specified in require module { } but our module version is less than that
		if (*version != '*' && strnatcasecmp(mod->header->version, version) < 0)
		{
			unreal_log(ULOG_ERROR, "link", "LINK_MODULE_OLD_VERSION", client,
			           "Server $me is using an old version of module '$module_name'. "
			           "Server $client requires us to have version $minimum_module_version or later (we have $our_module_version). "
			           "-- aborting link",
			           log_data_client("me", &me),
			           log_data_string("module_name", name),
			           log_data_string("minimum_module_version", version),
			           log_data_string("our_module_version", mod->header->version));
			abort = 1;
		}
	}

	if (abort)
	{
		exit_client_fmt(client, NULL, "Link aborted due to missing or banned modules (see previous errors)");
		return;
	}
}

int reqmods_hook_serverconnect(Client *client)
{
	/* This function simply dumps a list of modules and their version to the other server,
	 * which will then run through the received list and check the names/versions
	 */
	char modflag;
	char modbuf[64];
	char *modversion;
	/* Try to use a large buffer, but take into account the hostname, command, spaces, etc */
	char sendbuf[BUFSIZE - HOSTLEN - 16];
	Module *mod;
	ReqMod *rmod;
	size_t len, modlen;

	/* Let's not have leaves directly connected to the hub send their module list to other *leaves* as well =]
	 * Since the hub will introduce all servers currently linked to it, this hook is actually called for every separate node
	 */
	if (!MyConnect(client))
		return HOOK_CONTINUE;

	sendbuf[0] = '\0';
	len = 0;

	/* At this stage we don't care if a module isn't global (or not fully loaded), we'll dump all modules so we can properly deny
	 * certain ones across the network
	 * Also, the G flag is only used for modules that tag themselves as global, since we're keeping separate lists for require (R flag) and deny
	 */
	for (mod = Modules; mod; mod = mod->next)
	{
		modflag = SMOD_FLAG_LOCAL;
		modversion = mod->header->version;

		// require { }'d modules should be loaded on this server anyways, meaning we don't have to use a separate loop for those =]
		if ((rmod = find_reqmod_byname(mod->header->name)))
		{
			// require module::min-version overrides the version found in the module's header
			modflag = SMOD_FLAG_REQUIRED;
			modversion = (rmod->minversion ? rmod->minversion : "*");
		}

		else if ((mod->options & MOD_OPT_GLOBAL))
			modflag = SMOD_FLAG_GLOBAL;

		ircsnprintf(modbuf, sizeof(modbuf), "%c:%s:%s", modflag, mod->header->name, modversion);
		modlen = strlen(modbuf);
		if (len + modlen + 2 > sizeof(sendbuf)) // Account for space and nullbyte, otherwise the last module entry might be cut off
		{
			// "Flush" current list =]
			sendto_one(client, NULL, ":%s %s :%s", me.id, MSG_SMOD, sendbuf);
			sendbuf[0] = '\0';
			len = 0;
		}

		/* Maybe account for the space between modules, can't do this earlier because otherwise the ircsnprintf() would skip past the nullbyte
		 * of the previous module (which in turn terminates the string prematurely)
		 */
		ircsnprintf(sendbuf + len, sizeof(sendbuf) - len, "%s%s", (len > 0 ? " " : ""), modbuf);
		if (len)
			len++;
		len += modlen;
	}

	// May have something left
	if (sendbuf[0])
		sendto_one(client, NULL, ":%s %s :%s", me.id, MSG_SMOD, sendbuf);
	return HOOK_CONTINUE;
}
