config OpenSim.ini additions (or maybe could be separate AgentGate.ini but it's so short) [AgeGate] Enabled = true WelcomeRegionName = G-Welcome ; exact region name to redirect HG into WelcomeLanding = 128,128,25 BlockLocalChatForUnverified = true ; HG can't speak on channel 0 RedirectOnFail = true ; always redirect HG from M/A AllowedGBuildersGroup = "G Builders" ; who may build on G - or maybe just use groups system that exists? ; or AllowedGBuildersGroupID = 00000000-0000-0000-0000-000000000000 Then we build a plugin "AgeGateModule.cs" so we don't have to modify OpenSim code base Fiona.AgentGate.addin.xml AgentGateModule.cs // Fiona Sweet free to use/modify for any purpose // if you alter the code put your name so i don't get the blame :) // AgeGateModule.cs - OpenSimulator 0.9.3.9333 // Enforces: locals == verified; HG == G-only (auto-redirect to Welcome). // Blocks local chat (channel 0) for unverified (HG) users. // Locks G land to staff/allowed group only (best-effort; pair with parcel flags). using System; using System.Collections.Generic; using Nini.Config; using OpenMetaverse; using OpenSim.Framework; using OpenSim.Region.Framework.Interfaces; using OpenSim.Region.Framework.Scenes; using OpenSim.Services.Interfaces; namespace Fiona.AgeGate { public class AgeGateModule : ISharedRegionModule { const byte MATURITY_GENERAL = 0; // G const byte MATURITY_MATURE = 1; // M const byte MATURITY_ADULT = 2; // A private readonly List _scenes = new List(); // Config private bool _enabled = true; private string _welcomeRegionName = "G-Welcome"; private Vector3 _welcomeLanding = new Vector3(128f, 128f, 25f); private bool _blockLocalChatForUnverified = true; // affects HG only (locals are "verified") private bool _redirectOnFail = true; // always true per rule (10) private string _allowedGGroupName = ""; // optional group name allowed to build on G private UUID _allowedGGroupID = UUID.Zero; // optional explicit group UUID public string Name => "AgeGateModule"; public Type ReplaceableInterface => null; public void Initialise(IConfigSource source) { IConfig cfg = source.Configs["AgeGate"]; if (cfg == null) { // Create a default section so admins see the knobs cfg = source.AddConfig("AgeGate"); cfg.Set("Enabled", _enabled); cfg.Set("WelcomeRegionName", _welcomeRegionName); cfg.Set("WelcomeLanding", "128,128,25"); cfg.Set("BlockLocalChatForUnverified", _blockLocalChatForUnverified); cfg.Set("RedirectOnFail", _redirectOnFail); cfg.Set("AllowedGBuildersGroup", _allowedGGroupName); cfg.Set("AllowedGBuildersGroupID", _allowedGGroupID.ToString()); } _enabled = cfg.GetBoolean("Enabled", true); _welcomeRegionName = cfg.GetString("WelcomeRegionName", _welcomeRegionName); _blockLocalChatForUnverified = cfg.GetBoolean("BlockLocalChatForUnverified", true); _redirectOnFail = cfg.GetBoolean("RedirectOnFail", true); _allowedGGroupName = cfg.GetString("AllowedGBuildersGroup", _allowedGGroupName); UUID parsed; if (UUID.TryParse(cfg.GetString("AllowedGBuildersGroupID", UUID.Zero.ToString()), out parsed)) _allowedGGroupID = parsed; // landing vector parsing "x,y,z" var v = cfg.GetString("WelcomeLanding", "128,128,25").Split(','); if (v.Length == 3) { float x, y, z; if (float.TryParse(v[0], out x) && float.TryParse(v[1], out y) && float.TryParse(v[2], out z)) _welcomeLanding = new Vector3(x, y, z); } } public void PostInitialise() { } public void Close() { _scenes.Clear(); } public void AddRegion(Scene scene) { if (!_enabled) return; _scenes.Add(scene); scene.EventManager.OnNewClient += OnNewClient; scene.EventManager.OnClientClosed += OnClientClosed; scene.EventManager.OnSignificantClientMovement += OnMove; scene.EventManager.OnObjectBeingAddedToScene += OnObjectBeingAddedToScene; scene.EventManager.OnLandObjectUpdated += OnLandObjectUpdated; } public void RemoveRegion(Scene scene) { if (!_enabled) return; scene.EventManager.OnNewClient -= OnNewClient; scene.EventManager.OnClientClosed -= OnClientClosed; scene.EventManager.OnSignificantClientMovement -= OnMove; scene.EventManager.OnObjectBeingAddedToScene -= OnObjectBeingAddedToScene; scene.EventManager.OnLandObjectUpdated -= OnLandObjectUpdated; _scenes.Remove(scene); } public void RegionLoaded(Scene scene) { } // -------------------- Client lifecycle -------------------- private void OnNewClient(IClientAPI client) { // Cache "verified" flag per session: bool verified = IsVerifiedLocal(client); SetVerifiedFlag(client, verified); // Gate teleports up-front as well: client.OnTeleportLocationRequest += OnTeleportLocationRequest; // Block local chat for unverified (HG) if configured if (_blockLocalChatForUnverified) client.OnChatFromClient += OnChatFromClient; } private void OnClientClosed(UUID agentID, Scene scene) { /* nothing */ } // -------------------- Core policy helpers -------------------- private static void SetVerifiedFlag(IClientAPI client, bool isVerified) { // stash in the SceneAgent service URLs map as a simple cache try { if (client?.SceneAgent != null) { client.SceneAgent.ServiceURLs["AgeVerified"] = isVerified ? "1" : "0"; } } catch { /* ignore */ } } private static bool GetVerifiedFlag(IClientAPI client) { try { string v; if (client?.SceneAgent != null && client.SceneAgent.ServiceURLs.TryGetValue("AgeVerified", out v)) return v == "1"; } catch { } return false; } private bool IsVerifiedLocal(IClientAPI client) { // Rule (9): all locals are considered verified; HG visitors are not. bool isForeign = IsForeignUser(client); return !isForeign; } private bool IsForeignUser(IClientAPI client) { try { var um = (client.Scene as Scene)?.RequestModuleInterface(); if (um != null) return !um.IsLocalGridUser(client.AgentId); } catch { } // Be conservative: if we can't tell, treat as foreign return true; } // -------------------- Movement / TP gating -------------------- private void OnMove(ScenePresence sp) { if (sp == null || sp.IsChildAgent || sp.Scene == null) return; ILandObject land; if (!TryGetParcel(sp.Scene, sp.AbsolutePosition, out land)) return; GateAccess(sp.Scene, sp.ControllingClient, land); } private void OnTeleportLocationRequest(IClientAPI client, ulong regionHandle, Vector3 position, Vector3 lookAt, uint flags) { // Try to pre-gate based on destination maturity if we can resolve the parcel/region. var scene = client.Scene as Scene; if (scene == null) return; ILandObject destLand; if (TryResolveDestinationParcel(scene, regionHandle, position, out destLand)) { if (GateAccess(scene, client, destLand)) return; // we redirected; stop normal TP } // else: allow TP and we'll catch it on arrival via OnMove } // Returns true if it handled (redirected / blocked) private bool GateAccess(Scene scene, IClientAPI client, ILandObject land) { if (land == null) return false; byte maturity = land.LandData.Maturity; bool isForeign = IsForeignUser(client); // HG => unverified bool verified = GetVerifiedFlag(client); // locals => true if (maturity > MATURITY_GENERAL && (!verified || isForeign)) { // Rule (10): redirect to G Welcome client.SendAgentAlertMessage("This area requires local (age-verified) access. Redirecting to the Welcome region.", false); if (_redirectOnFail) { TeleportToWelcome(scene, client); return true; } client.SendTeleportFailed("Destination requires local (age-verified) access."); return true; } return false; } // -------------------- G-land UGC lock -------------------- private void OnObjectBeingAddedToScene(SceneObjectGroup sog) { try { if (sog == null) return; var scene = sog.Scene; var owner = sog.OwnerID; ILandObject land; if (!TryGetParcel(scene, sog.AbsolutePosition, out land)) return; if (land.LandData.Maturity == MATURITY_GENERAL) { // Allow only staff / allowed group to build in G parcels var sp = scene.GetScenePresence(owner); var client = sp?.ControllingClient; if (client == null) { sog.DeleteGroup(false); return; } if (!IsAllowedGBuild(client, land)) { client.SendAgentAlertMessage("Building is disabled in G areas (staff/allowed group only).", false); sog.DeleteGroup(false); } } } catch { } } private void OnLandObjectUpdated(uint localID, ILandObject land, IClientAPI who, LandUpdateArgs args) { // Prevent users from setting parcels to G unless staff try { if (args == null || land == null || who == null) return; if (args.Maturity == MATURITY_GENERAL && !IsStaff(who)) { who.SendAlertMessage("Only staff may set parcels to General (G) on this grid."); // Best-effort revert to previous maturity if we can if (land.LandData.Maturity != MATURITY_GENERAL) args.Maturity = land.LandData.Maturity; else args.Maturity = MATURITY_MATURE; } } catch { } } private bool IsAllowedGBuild(IClientAPI client, ILandObject land) { if (IsStaff(client)) return true; // Optional group check if (_allowedGGroupID != UUID.Zero) { var groups = (client.Scene as Scene)?.RequestModuleInterface(); if (groups != null && groups.IsGroupMember(client.AgentId, _allowedGGroupID)) return true; } else if (!string.IsNullOrEmpty(_allowedGGroupName)) { var groups = (client.Scene as Scene)?.RequestModuleInterface(); if (groups != null) { GroupRecord rec; if (groups.TryGetGroupRecord(_allowedGGroupName, out rec) && rec != null && rec.GroupID != UUID.Zero) { if (groups.IsGroupMember(client.AgentId, rec.GroupID)) return true; } } } // Encourage also setting parcel flags to disable build; we enforce here as well. return false; } private bool IsStaff(IClientAPI client) { try { var scene = client.Scene as Scene; if (scene == null) return false; var est = scene.RegionInfo.EstateSettings; if (client.AgentId == est.EstateOwner) return true; foreach (UUID m in est.EstateManagers) if (m == client.AgentId) return true; // God mode check return scene.Permissions.IsGod(client.AgentId); } catch { } return false; } // -------------------- Utilities -------------------- private static bool TryGetParcel(Scene scene, Vector3 pos, out ILandObject land) { land = null; try { if (scene == null || scene.LandChannel == null) return false; land = scene.LandChannel.GetLandObject(pos.X, pos.Y); return land != null; } catch { return false; } } // Try to resolve destination parcel from region handle + position (pre-TP gate) private bool TryResolveDestinationParcel(Scene currentScene, ulong regionHandle, Vector3 pos, out ILandObject land) { land = null; try { // If the TP is within the same region, just read local land if (regionHandle == currentScene.RegionInfo.RegionHandle) return TryGetParcel(currentScene, pos, out land); // If cross-region: ask GridService, then defer to OnMove after arrival return false; } catch { return false; } } private void TeleportToWelcome(Scene scene, IClientAPI client) { var grid = scene.GridService; if (grid == null) { client.SendAgentAlertMessage("Welcome region unavailable (no GridService).", false); return; } var regs = grid.GetRegionsByName(scene.RegionInfo.ScopeID, _welcomeRegionName, 1); if (regs == null || regs.Count == 0) { client.SendAgentAlertMessage($"Welcome region '{_welcomeRegionName}' not found.", false); return; } var wel = regs[0]; try { scene.RequestTeleportLocation(client, wel.RegionHandle, _welcomeLanding, Vector3.Zero, (uint)TeleportFlags.ViaRegionID); } catch { client.SendAgentAlertMessage("Teleport to Welcome region failed.", false); } } private void OnChatFromClient(IClientAPI client, OSChatMessage msg) { try { if (msg.Channel == 0) { bool verified = GetVerifiedFlag(client); // locals => true if (!verified) { msg.Handled = true; client.SendAgentAlertMessage("Local chat is available to local (age-verified) users only.", false); } } } catch { } } } // Minimal IGrou​psModule helpers to avoid compile surprises across minor versions public static class GroupsModuleExtensions { public static bool IsGroupMember(this IGroupsModule gm, UUID agent, UUID group) { try { return gm?.IsAgentMemberOfGroup(agent, group) ?? false; } catch { return false; } } public static bool TryGetGroupRecord(this IGroupsModule gm, string name, out GroupRecord rec) { rec = null; try { rec = gm.GetGroupRecord(name); return rec != null; } catch { return false; } } } }