diff --git a/xtrn/wttr.in/locator.js b/xtrn/wttr.in/locator.js
new file mode 100644
index 0000000000000000000000000000000000000000..7dc236e7ab4ac6aec9dc2aacecbf33011df61d0f
--- /dev/null
+++ b/xtrn/wttr.in/locator.js
@@ -0,0 +1,99 @@
+/*
+ * Just IP address lookup for now
+ * wttr.in does the geoip for us (albeit inaccurately)
+ * To do: do something with user.location / zipcode as an alternative?
+ *
+ * IP address lookup stuff adapted from syncWXremix
+ * (Contributed by echicken)
+ * https://github.com/KenDB3/syncWXremix
+ * Copyright (c) 2015,  Kenny DiBattista <kendb3@bbs.kd3.us>
+ * 
+ * ISC License
+ *
+ * Copyright (c) 2022 Zaidhaan Hussain
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+ * REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+ * AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+ * INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+ * LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+ * OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+ * PERFORMANCE OF THIS SOFTWARE.
+ */
+
+// To do: I think this is only good for IPv4
+function wstsGetIPAddress() {
+	const ip = [];
+	const data = [];
+
+	try {
+		console.lock_input(true);
+		console.telnet_cmd(253, 28); // DO TTYLOC
+
+		const stime = system.timer;
+		while (system.timer - stime < 1) {
+			if (!client.socket.data_waiting) continue;
+			data.push(client.socket.recvBin(1));
+			if (data.length >= 14 && data[data.length - 3] !== 255 && data[data.length - 2] === 255 && data[data.length - 1] === 240) break;
+		}
+
+		// Check for a valid reply
+		if (data.length < 14 || // Minimum response length
+			// Should start like this
+			data[0] !== 255 || // IAC
+			data[1] !== 250 || // SB
+			data[2] !== 28 || // TTYLOC
+			data[3] !== 0 || // FORMAT
+			// Should end like this
+			data[data.length - 2] !== 255 || // IAC
+			data[data.length - 1] !== 240 // SE
+		) {
+			throw 'Invalid reply to TTYLOC command.';
+		}
+
+		for (var d = 4; d < data.length - 2; d++) {
+			ip.push(data[d]);
+			if (data[d] === 255) d++;
+		}
+		if (ip.length !== 8) throw 'Invalid reply to TTYLOC command.';
+	} catch (err) {
+		log(LOG_DEBUG, err);
+	} finally {
+		console.lock_input(false);
+		if (ip.length !== 8) return;
+		return ip.slice(0, 4).join('.');
+	}
+}
+
+// for webv4 ... I think
+function wsrsGetIPAddress() {
+	var fn = format('%suser/%04d.web', system.data_dir, user.number);
+	if (!file_exists(fn)) return;
+	var f = new File(fn);
+	if (!f.open('r')) return;
+	var session = f.iniGetObject();
+	f.close();
+	if (typeof session.ip_address === 'undefined') return;
+	return session.ip_address;
+}
+
+function getAddress() {
+	const addrRe = /^(127\.)|(192\.168\.)|(10\.)|(172\.1[6-9]\.)|(172\.2[0-9]\.)|(172\.3[0-1]\.)|(169\.254\.)|(::1$)|([fF][cCdD])/;
+	if (user.ip_address.search(addrRe) > -1) {
+		var addr;
+		if (client.protocol === 'Telnet') {
+			addr = wstsGetIPAddress();
+		} else if (bbs.sys_status&SS_RLOGIN) {
+			addr = wsrsGetIPAddress();
+		}
+		if (addr === undefined || addr.search(addrRe) > -1) return;
+		return addr;
+	}
+	return user.ip_address;
+}
+
+this;
\ No newline at end of file
diff --git a/xtrn/wttr.in/readme.txt b/xtrn/wttr.in/readme.txt
new file mode 100644
index 0000000000000000000000000000000000000000..d57c59beaf53909050995bc5d7a76810f070a65d
--- /dev/null
+++ b/xtrn/wttr.in/readme.txt
@@ -0,0 +1,46 @@
+wttr.in viewer for Synchronet BBS
+by echicken -at- bbs.electronicchicken.com
+
+Contents
+
+	1) About
+	2) Installation
+	3) Customization
+
+1) About
+
+	This script pulls a weather report from https://wttr.in and displays it in
+	the terminal. wttr.in is a console-oriented weather forecast service
+	created and hosted by Igor Chubin (https://github.com/chubin/wttr.in).
+
+2) Installation
+
+	In SCFG, go to 'External Programs', then 'Online Programs (Doors)', and
+	choose the section to which you'd like to add this mod. Fill out the new
+	entry with the following details:
+
+		Name                       wttr.in Weather Forecast
+		Internal Code              WTTR
+		Start-up Directory         /sbbs/xtrn/wttr.in
+		Command Line               ?wttr.js
+		Multiple Concurrent Users  Yes
+
+	If you want this mod to run during your logon process, set the following:
+
+		Execute on Event			logon
+
+	All other options can be left at their default settings.
+
+3) Customization
+
+	Please see https://wttr.in/:help for a list of options. You may pass any
+	of the 'Units' and 'View options' values to this script on the command
+	line to customize the output, for example:
+
+		Command Line	?wttr.js m0AFn
+
+	The default is 'AFn' for ANSI, no 'Follow' line, and narrow output.
+
+	Note that your command line argument will completely replace the default
+	parameters. You will probably want to specify 'A' and 'n' in addition to
+	your chosen values.
diff --git a/xtrn/wttr.in/wttr.js b/xtrn/wttr.in/wttr.js
new file mode 100644
index 0000000000000000000000000000000000000000..aca8871abd4adf132d08ca4d626fd7217b6138ad
--- /dev/null
+++ b/xtrn/wttr.in/wttr.js
@@ -0,0 +1,35 @@
+require('sbbsdefs.js', 'P_UTF8');
+require('http.js', 'HTTPRequest');
+const xterm = load({}, js.exec_dir + 'xterm-colors.js');
+const locator = load({}, js.exec_dir + 'locator.js');
+
+function uReplace(str) {
+	return str.replace(/\xE2\x9A\xA1/g, '/ '); // U+26A1 Lightning bolt
+}
+
+function fetchWeather(addr) {
+	const qs = argc > 0 ? argv.join('') : 'AFn';
+	const http = new HTTPRequest();
+	if (addr !== undefined) http.extra_headers = { 'X-Forwarded-For': addr };
+	const body = http.Get('https://wttr.in/?' + qs);
+	if (http.response_code !== 200) throw new Error('wttr.in response had status ' + http.response_code);
+	return body;
+}
+
+function main() {
+	const addr = locator.getAddress();
+	const weather = fetchWeather(addr);
+	const text = uReplace(weather);
+	const ansi = xterm.convertColors(text);
+	const attr = console.attributes;
+	console.clear(BG_BLACK|LIGHTGRAY);
+	console.putmsg(ansi, P_UTF8);
+	console.pause();
+	console.attributes = attr;
+}
+
+try {
+	main();
+} catch (err) {
+	log(LOG_ERROR, err);
+}
\ No newline at end of file
diff --git a/xtrn/wttr.in/xterm-colors.js b/xtrn/wttr.in/xterm-colors.js
new file mode 100644
index 0000000000000000000000000000000000000000..ac0b7ba9e0c944e5ab10363808044abd9192a720
--- /dev/null
+++ b/xtrn/wttr.in/xterm-colors.js
@@ -0,0 +1,116 @@
+/*
+ * Adapted from
+ * https://github.com/zaidhaan/xterm2ansi
+ * Copyright (c) 2022 Zaidhaan Hussain
+ * 
+ * ISC License
+ *
+ * Copyright (c) 2022 Zaidhaan Hussain
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+ * REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+ * AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+ * INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+ * LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+ * OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+ * PERFORMANCE OF THIS SOFTWARE.
+ */
+
+const colors = [
+	{ r: 0x00, g: 0x00, b: 0x00, code: 30, bold: 0 }, // black
+	{ r: 0xCD, g: 0x00, b: 0x00, code: 31, bold: 0 }, // red
+	{ r: 0x00, g: 0xCD, b: 0x00, code: 32, bold: 0 }, // green
+	{ r: 0xCD, g: 0xCD, b: 0x00, code: 33, bold: 0 }, // yellow
+	{ r: 0x00, g: 0x00, b: 0xEE, code: 34, bold: 0 }, // blue
+	{ r: 0xCD, g: 0x00, b: 0xCD, code: 35, bold: 0 }, // magenta
+	{ r: 0x00, g: 0xCD, b: 0xCD, code: 36, bold: 0 }, // cyan
+	{ r: 0xE5, g: 0xE5, b: 0xE5, code: 37, bold: 0 }, // white
+	{ r: 0x7F, g: 0x7F, b: 0x7F, code: 30, bold: 1 }, // bright black
+	{ r: 0xFF, g: 0x00, b: 0x00, code: 31, bold: 1 }, // bright red
+	{ r: 0x00, g: 0xFF, b: 0x00, code: 32, bold: 1 }, // bright green
+	{ r: 0xFF, g: 0xFF, b: 0x00, code: 33, bold: 1 }, // bright yellow
+	{ r: 0x5C, g: 0x5C, b: 0xFF, code: 34, bold: 1 }, // bright blue
+	{ r: 0xFF, g: 0x00, b: 0xFF, code: 35, bold: 1 }, // bright magenta
+	{ r: 0x00, g: 0xFF, b: 0xFF, code: 36, bold: 1 }, // bright cyan
+	{ r: 0xFF, g: 0xFF, b: 0xFF, code: 37, bold: 1 }, // bright white
+];
+
+function colorDistance(r1, g1, b1, r2, g2, b2) {
+	return Math.sqrt(Math.pow(r2 - r1, 2) + Math.pow(b2 - b1, 2) + Math.pow(g2 - g1, 2));
+}
+
+function rgbToANSI(rgb) {
+	var nearest = 0;
+	var minDistance = 256;
+	for (var i = 0; i < 16; i++) {
+		var currentColor = colors[i];
+		var distance = colorDistance(rgb.r, rgb.g, rgb.b, currentColor.r, currentColor.g, currentColor.b);
+		if (distance < minDistance) {
+			minDistance = distance;
+			nearest = currentColor;
+		}
+	}
+	return nearest;
+}
+
+function xterm256ToRGB(color) {
+	var r = 0;
+	var g = 0;
+	var b = 0;
+	if (color < 16) {
+		r = ansi_colors[color].r;
+		g = ansi_colors[color].g;
+		b = ansi_colors[color].b;
+	} else if (color < 232) {
+		color -= 16;
+		const _r = (color / 36);
+		const _g = (color % 36) / 6;
+		const _b = (color % 6);
+		r = _r ? _r * 40 + 55 : 0;
+		g = _g ? _g * 40 + 55 : 0;
+		b = _b ? _b * 40 + 55 : 0;
+	} else {
+		color -= 232;
+		r = g = b = (color * 10) + 8;
+	}
+	return { r: r, g: g, b: b };
+}
+
+function xterm256ToANSI(color) {
+	const rgb = xterm256ToRGB(color);
+	return rgbToANSI(rgb);
+}
+
+function replace256(match, p1, p2, p3) {
+	const color = parseInt(p2, 10);
+	if (isNaN(color)) return '';
+	const ansiColor = xterm256ToANSI(color);
+	const code = (p1 === '48') ? (ansiColor.code + 10) : ansiColor.code;
+	return '\x1b[' + ansiColor.bold + ';' + code + p3 + 'm';
+}
+
+function replaceRGB(match, p1, p2, p3, p4, p5) {
+	const r = parseInt(p2, 10);
+	if (isNaN(r)) return '';
+	const g = parseInt(p3, 10);
+	if (isNaN(g)) return '';
+	const b = parseInt(p4, 10);
+	if (isNaN(b)) return '';
+	const ansiColor = rgbToANSI({ r: r, g: g, b:b });
+	const code = (p1 === '48') ? (ansiColor.code + 10): ansiColor.code;
+	return '\x1b[' + ansiColor.bold + ';' + code + p5 + 'm';
+}
+
+function convertColors(str) {
+	return str.replace(
+		/\x1b\[([34]8);5;(\d+)(;\d+)?m/g, replace256
+	).replace(
+		/\x1b\[([34]8);2;(\d+);(\d+);(\d+)(;\d+)?m/g, replaceRGB
+	);
+}
+
+this;
\ No newline at end of file