|
|
@@ -0,0 +1,612 @@
|
|
|
+CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
|
+
|
|
|
+CREATE TYPE user_role AS ENUM (
|
|
|
+ 'admin',
|
|
|
+ 'operator',
|
|
|
+ 'viewer'
|
|
|
+);
|
|
|
+
|
|
|
+CREATE TYPE agent_status AS ENUM (
|
|
|
+ 'online',
|
|
|
+ 'offline',
|
|
|
+ 'busy',
|
|
|
+ 'error'
|
|
|
+);
|
|
|
+
|
|
|
+CREATE TYPE command_status AS ENUM (
|
|
|
+ 'pending',
|
|
|
+ 'running',
|
|
|
+ 'completed',
|
|
|
+ 'failed',
|
|
|
+ 'cancelled'
|
|
|
+);
|
|
|
+
|
|
|
+CREATE TYPE transfer_direction AS ENUM (
|
|
|
+ 'upload',
|
|
|
+ 'download'
|
|
|
+);
|
|
|
+
|
|
|
+CREATE TYPE actor_type_enum AS ENUM (
|
|
|
+ 'user',
|
|
|
+ 'agent',
|
|
|
+ 'system'
|
|
|
+);
|
|
|
+
|
|
|
+-- USERS
|
|
|
+
|
|
|
+CREATE TABLE users (
|
|
|
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
+
|
|
|
+ username TEXT NOT NULL UNIQUE,
|
|
|
+ password_hash TEXT NOT NULL,
|
|
|
+
|
|
|
+ role user_role NOT NULL DEFAULT 'viewer',
|
|
|
+
|
|
|
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
|
+
|
|
|
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
|
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
|
+);
|
|
|
+
|
|
|
+CREATE INDEX idx_users_role
|
|
|
+ON users(role);
|
|
|
+
|
|
|
+CREATE INDEX idx_users_active
|
|
|
+ON users(is_active);
|
|
|
+
|
|
|
+-- AGENTS
|
|
|
+
|
|
|
+CREATE TABLE agents (
|
|
|
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
+
|
|
|
+ hostname TEXT NOT NULL UNIQUE,
|
|
|
+
|
|
|
+ token_hash TEXT NOT NULL,
|
|
|
+
|
|
|
+ cert_fingerprint TEXT,
|
|
|
+
|
|
|
+ os_info JSONB,
|
|
|
+
|
|
|
+ status agent_status NOT NULL DEFAULT 'offline',
|
|
|
+
|
|
|
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
|
+
|
|
|
+ last_seen TIMESTAMPTZ,
|
|
|
+
|
|
|
+ registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
|
+
|
|
|
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
|
+);
|
|
|
+
|
|
|
+CREATE INDEX idx_agents_status
|
|
|
+ON agents(status);
|
|
|
+
|
|
|
+CREATE INDEX idx_agents_last_seen
|
|
|
+ON agents(last_seen);
|
|
|
+
|
|
|
+CREATE INDEX idx_agents_active
|
|
|
+ON agents(is_active);
|
|
|
+
|
|
|
+CREATE INDEX idx_agents_os_info_gin
|
|
|
+ON agents
|
|
|
+USING GIN(os_info);
|
|
|
+
|
|
|
+-- COMMAND TYPES
|
|
|
+
|
|
|
+CREATE TABLE command_types (
|
|
|
+ id SMALLSERIAL PRIMARY KEY,
|
|
|
+ name TEXT NOT NULL UNIQUE
|
|
|
+);
|
|
|
+
|
|
|
+INSERT INTO command_types(name)
|
|
|
+VALUES
|
|
|
+('shell'),
|
|
|
+('powershell'),
|
|
|
+('upload'),
|
|
|
+('download'),
|
|
|
+('system'),
|
|
|
+('custom');
|
|
|
+
|
|
|
+-- COMMANDS
|
|
|
+
|
|
|
+CREATE TABLE commands (
|
|
|
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
+
|
|
|
+ agent_id UUID NOT NULL
|
|
|
+ REFERENCES agents(id)
|
|
|
+ ON DELETE CASCADE,
|
|
|
+
|
|
|
+ issued_by UUID
|
|
|
+ REFERENCES users(id)
|
|
|
+ ON DELETE SET NULL,
|
|
|
+
|
|
|
+ command_type_id SMALLINT
|
|
|
+ REFERENCES command_types(id),
|
|
|
+
|
|
|
+ command TEXT NOT NULL,
|
|
|
+
|
|
|
+ status command_status NOT NULL DEFAULT 'pending',
|
|
|
+
|
|
|
+ output TEXT,
|
|
|
+
|
|
|
+ exit_code INTEGER,
|
|
|
+
|
|
|
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
|
+
|
|
|
+ completed_at TIMESTAMPTZ,
|
|
|
+
|
|
|
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
|
+
|
|
|
+ CHECK (
|
|
|
+ completed_at IS NULL
|
|
|
+ OR completed_at >= created_at
|
|
|
+ ),
|
|
|
+
|
|
|
+ CHECK (
|
|
|
+ exit_code IS NULL
|
|
|
+ OR exit_code BETWEEN -255 AND 255
|
|
|
+ )
|
|
|
+);
|
|
|
+
|
|
|
+CREATE INDEX idx_commands_agent
|
|
|
+ON commands(agent_id);
|
|
|
+
|
|
|
+CREATE INDEX idx_commands_issued_by
|
|
|
+ON commands(issued_by);
|
|
|
+
|
|
|
+CREATE INDEX idx_commands_status
|
|
|
+ON commands(status);
|
|
|
+
|
|
|
+CREATE INDEX idx_commands_created_at
|
|
|
+ON commands(created_at DESC);
|
|
|
+
|
|
|
+CREATE INDEX idx_commands_pending
|
|
|
+ON commands(status)
|
|
|
+WHERE status = 'pending';
|
|
|
+
|
|
|
+CREATE INDEX idx_commands_running
|
|
|
+ON commands(status)
|
|
|
+WHERE status = 'running';
|
|
|
+
|
|
|
+-- FILE TRANSFERS
|
|
|
+
|
|
|
+CREATE TABLE file_transfers (
|
|
|
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
+
|
|
|
+ agent_id UUID
|
|
|
+ REFERENCES agents(id)
|
|
|
+ ON DELETE CASCADE,
|
|
|
+
|
|
|
+ initiated_by UUID
|
|
|
+ REFERENCES users(id)
|
|
|
+ ON DELETE SET NULL,
|
|
|
+
|
|
|
+ command_id UUID
|
|
|
+ REFERENCES commands(id)
|
|
|
+ ON DELETE SET NULL,
|
|
|
+
|
|
|
+ direction transfer_direction NOT NULL,
|
|
|
+
|
|
|
+ filename TEXT NOT NULL,
|
|
|
+
|
|
|
+ sha256 CHAR(64),
|
|
|
+
|
|
|
+ size_bytes BIGINT,
|
|
|
+
|
|
|
+ status command_status NOT NULL DEFAULT 'pending',
|
|
|
+
|
|
|
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
|
+
|
|
|
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
|
+
|
|
|
+ CHECK (
|
|
|
+ size_bytes IS NULL
|
|
|
+ OR size_bytes >= 0
|
|
|
+ )
|
|
|
+);
|
|
|
+
|
|
|
+CREATE INDEX idx_file_transfers_agent
|
|
|
+ON file_transfers(agent_id);
|
|
|
+
|
|
|
+CREATE INDEX idx_file_transfers_status
|
|
|
+ON file_transfers(status);
|
|
|
+
|
|
|
+CREATE INDEX idx_file_transfers_sha256
|
|
|
+ON file_transfers(sha256);
|
|
|
+
|
|
|
+CREATE INDEX idx_file_transfers_created
|
|
|
+ON file_transfers(created_at DESC);
|
|
|
+
|
|
|
+-- AUDIT LOGS
|
|
|
+
|
|
|
+CREATE TABLE audit_logs (
|
|
|
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
+
|
|
|
+ actor_id UUID,
|
|
|
+
|
|
|
+ actor_type actor_type_enum NOT NULL,
|
|
|
+
|
|
|
+ action TEXT NOT NULL,
|
|
|
+
|
|
|
+ target TEXT,
|
|
|
+
|
|
|
+ details JSONB,
|
|
|
+
|
|
|
+ ip_address INET,
|
|
|
+
|
|
|
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
|
+);
|
|
|
+
|
|
|
+CREATE INDEX idx_audit_actor
|
|
|
+ON audit_logs(actor_id);
|
|
|
+
|
|
|
+CREATE INDEX idx_audit_action
|
|
|
+ON audit_logs(action);
|
|
|
+
|
|
|
+CREATE INDEX idx_audit_created
|
|
|
+ON audit_logs(created_at DESC);
|
|
|
+
|
|
|
+CREATE INDEX idx_audit_details_gin
|
|
|
+ON audit_logs
|
|
|
+USING GIN(details);
|
|
|
+
|
|
|
+-- AGENT HEARTBEATS
|
|
|
+
|
|
|
+CREATE TABLE agent_heartbeats (
|
|
|
+ id BIGSERIAL PRIMARY KEY,
|
|
|
+
|
|
|
+ agent_id UUID NOT NULL
|
|
|
+ REFERENCES agents(id)
|
|
|
+ ON DELETE CASCADE,
|
|
|
+
|
|
|
+ received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
|
+
|
|
|
+ metadata JSONB
|
|
|
+);
|
|
|
+
|
|
|
+CREATE INDEX idx_heartbeats_agent
|
|
|
+ON agent_heartbeats(agent_id);
|
|
|
+
|
|
|
+CREATE INDEX idx_heartbeats_received
|
|
|
+ON agent_heartbeats(received_at DESC);
|
|
|
+
|
|
|
+-- USER SESSIONS
|
|
|
+
|
|
|
+CREATE TABLE user_sessions (
|
|
|
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
+
|
|
|
+ user_id UUID NOT NULL
|
|
|
+ REFERENCES users(id)
|
|
|
+ ON DELETE CASCADE,
|
|
|
+
|
|
|
+ refresh_token_hash TEXT NOT NULL,
|
|
|
+
|
|
|
+ user_agent TEXT,
|
|
|
+
|
|
|
+ ip_address INET,
|
|
|
+
|
|
|
+ expires_at TIMESTAMPTZ NOT NULL,
|
|
|
+
|
|
|
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
|
+);
|
|
|
+
|
|
|
+CREATE INDEX idx_sessions_user
|
|
|
+ON user_sessions(user_id);
|
|
|
+
|
|
|
+CREATE INDEX idx_sessions_expires
|
|
|
+ON user_sessions(expires_at);
|
|
|
+
|
|
|
+-- AGENT GROUPS
|
|
|
+
|
|
|
+CREATE TABLE agent_groups (
|
|
|
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
+
|
|
|
+ name TEXT NOT NULL UNIQUE,
|
|
|
+
|
|
|
+ description TEXT,
|
|
|
+
|
|
|
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
|
+);
|
|
|
+
|
|
|
+CREATE TABLE agent_group_members (
|
|
|
+ agent_id UUID NOT NULL
|
|
|
+ REFERENCES agents(id)
|
|
|
+ ON DELETE CASCADE,
|
|
|
+
|
|
|
+ group_id UUID NOT NULL
|
|
|
+ REFERENCES agent_groups(id)
|
|
|
+ ON DELETE CASCADE,
|
|
|
+
|
|
|
+ PRIMARY KEY (agent_id, group_id)
|
|
|
+);
|
|
|
+
|
|
|
+-- UPDATE updated_at FUNCTION
|
|
|
+
|
|
|
+CREATE OR REPLACE FUNCTION update_updated_at_column()
|
|
|
+RETURNS TRIGGER AS
|
|
|
+$$
|
|
|
+BEGIN
|
|
|
+ NEW.updated_at = NOW();
|
|
|
+ RETURN NEW;
|
|
|
+END;
|
|
|
+$$ LANGUAGE plpgsql;
|
|
|
+
|
|
|
+-- updated_at TRIGGERS
|
|
|
+
|
|
|
+CREATE TRIGGER trg_users_updated_at
|
|
|
+BEFORE UPDATE ON users
|
|
|
+FOR EACH ROW
|
|
|
+EXECUTE FUNCTION update_updated_at_column();
|
|
|
+
|
|
|
+CREATE TRIGGER trg_agents_updated_at
|
|
|
+BEFORE UPDATE ON agents
|
|
|
+FOR EACH ROW
|
|
|
+EXECUTE FUNCTION update_updated_at_column();
|
|
|
+
|
|
|
+CREATE TRIGGER trg_commands_updated_at
|
|
|
+BEFORE UPDATE ON commands
|
|
|
+FOR EACH ROW
|
|
|
+EXECUTE FUNCTION update_updated_at_column();
|
|
|
+
|
|
|
+CREATE TRIGGER trg_file_transfers_updated_at
|
|
|
+BEFORE UPDATE ON file_transfers
|
|
|
+FOR EACH ROW
|
|
|
+EXECUTE FUNCTION update_updated_at_column();
|
|
|
+
|
|
|
+-- COMMAND COMPLETION TRIGGER
|
|
|
+
|
|
|
+CREATE OR REPLACE FUNCTION update_agent_after_command()
|
|
|
+RETURNS TRIGGER AS
|
|
|
+$$
|
|
|
+BEGIN
|
|
|
+ IF NEW.status = 'completed'
|
|
|
+ AND OLD.status IS DISTINCT FROM 'completed'
|
|
|
+ THEN
|
|
|
+ UPDATE agents
|
|
|
+ SET
|
|
|
+ last_seen = NOW(),
|
|
|
+ status = 'online'
|
|
|
+ WHERE id = NEW.agent_id;
|
|
|
+ END IF;
|
|
|
+
|
|
|
+ RETURN NEW;
|
|
|
+END;
|
|
|
+$$ LANGUAGE plpgsql;
|
|
|
+
|
|
|
+CREATE TRIGGER trg_update_agent_after_command
|
|
|
+AFTER UPDATE ON commands
|
|
|
+FOR EACH ROW
|
|
|
+EXECUTE FUNCTION update_agent_after_command();
|
|
|
+
|
|
|
+-- AUDIT LOG COMMAND CREATION
|
|
|
+
|
|
|
+CREATE OR REPLACE FUNCTION log_command_creation()
|
|
|
+RETURNS TRIGGER AS
|
|
|
+$$
|
|
|
+BEGIN
|
|
|
+ INSERT INTO audit_logs (
|
|
|
+ actor_id,
|
|
|
+ actor_type,
|
|
|
+ action,
|
|
|
+ target,
|
|
|
+ details
|
|
|
+ )
|
|
|
+ VALUES (
|
|
|
+ NEW.issued_by,
|
|
|
+ 'user',
|
|
|
+ 'command_created',
|
|
|
+ NEW.id::TEXT,
|
|
|
+ jsonb_build_object(
|
|
|
+ 'command_length', length(NEW.command),
|
|
|
+ 'status', NEW.status
|
|
|
+ )
|
|
|
+ );
|
|
|
+
|
|
|
+ RETURN NEW;
|
|
|
+END;
|
|
|
+$$ LANGUAGE plpgsql;
|
|
|
+
|
|
|
+CREATE TRIGGER trg_log_command_creation
|
|
|
+AFTER INSERT ON commands
|
|
|
+FOR EACH ROW
|
|
|
+EXECUTE FUNCTION log_command_creation();
|
|
|
+
|
|
|
+-- AGENT STATUS CHANGE LOGGING
|
|
|
+
|
|
|
+CREATE OR REPLACE FUNCTION log_agent_status_change()
|
|
|
+RETURNS TRIGGER AS
|
|
|
+$$
|
|
|
+BEGIN
|
|
|
+ IF OLD.status IS DISTINCT FROM NEW.status THEN
|
|
|
+
|
|
|
+ INSERT INTO audit_logs (
|
|
|
+ actor_id,
|
|
|
+ actor_type,
|
|
|
+ action,
|
|
|
+ target,
|
|
|
+ details
|
|
|
+ )
|
|
|
+ VALUES (
|
|
|
+ NEW.id,
|
|
|
+ 'agent',
|
|
|
+ 'status_changed',
|
|
|
+ NEW.hostname,
|
|
|
+ jsonb_build_object(
|
|
|
+ 'old_status', OLD.status,
|
|
|
+ 'new_status', NEW.status
|
|
|
+ )
|
|
|
+ );
|
|
|
+
|
|
|
+ END IF;
|
|
|
+
|
|
|
+ RETURN NEW;
|
|
|
+END;
|
|
|
+$$ LANGUAGE plpgsql;
|
|
|
+
|
|
|
+CREATE TRIGGER trg_log_agent_status_change
|
|
|
+AFTER UPDATE ON agents
|
|
|
+FOR EACH ROW
|
|
|
+EXECUTE FUNCTION log_agent_status_change();
|
|
|
+
|
|
|
+
|
|
|
+-- IMMUTABLE AUDIT LOGS
|
|
|
+
|
|
|
+CREATE OR REPLACE FUNCTION prevent_audit_log_modification()
|
|
|
+RETURNS TRIGGER AS
|
|
|
+$$
|
|
|
+BEGIN
|
|
|
+ RAISE EXCEPTION 'audit_logs table is immutable';
|
|
|
+END;
|
|
|
+$$ LANGUAGE plpgsql;
|
|
|
+
|
|
|
+CREATE TRIGGER trg_prevent_audit_update
|
|
|
+BEFORE UPDATE ON audit_logs
|
|
|
+FOR EACH ROW
|
|
|
+EXECUTE FUNCTION prevent_audit_log_modification();
|
|
|
+
|
|
|
+CREATE TRIGGER trg_prevent_audit_delete
|
|
|
+BEFORE DELETE ON audit_logs
|
|
|
+FOR EACH ROW
|
|
|
+EXECUTE FUNCTION prevent_audit_log_modification();
|
|
|
+
|
|
|
+-- VIEWS
|
|
|
+
|
|
|
+CREATE VIEW active_agents_view AS
|
|
|
+SELECT
|
|
|
+ a.id,
|
|
|
+ a.hostname,
|
|
|
+ a.status,
|
|
|
+ a.last_seen,
|
|
|
+ a.registered_at,
|
|
|
+
|
|
|
+ COUNT(c.id) AS total_commands,
|
|
|
+
|
|
|
+ COUNT(*) FILTER (
|
|
|
+ WHERE c.status = 'completed'
|
|
|
+ ) AS completed_commands,
|
|
|
+
|
|
|
+ COUNT(*) FILTER (
|
|
|
+ WHERE c.status = 'pending'
|
|
|
+ ) AS pending_commands
|
|
|
+
|
|
|
+FROM agents a
|
|
|
+LEFT JOIN commands c
|
|
|
+ON a.id = c.agent_id
|
|
|
+
|
|
|
+WHERE a.is_active = TRUE
|
|
|
+
|
|
|
+GROUP BY
|
|
|
+ a.id,
|
|
|
+ a.hostname,
|
|
|
+ a.status,
|
|
|
+ a.last_seen,
|
|
|
+ a.registered_at;
|
|
|
+
|
|
|
+
|
|
|
+-- RECENT COMMANDS VIEW
|
|
|
+
|
|
|
+CREATE VIEW recent_commands_view AS
|
|
|
+SELECT
|
|
|
+ c.id,
|
|
|
+ c.command,
|
|
|
+ c.status,
|
|
|
+ c.created_at,
|
|
|
+ c.completed_at,
|
|
|
+
|
|
|
+ a.hostname AS agent_hostname,
|
|
|
+
|
|
|
+ u.username AS issued_by
|
|
|
+
|
|
|
+FROM commands c
|
|
|
+
|
|
|
+JOIN agents a
|
|
|
+ON c.agent_id = a.id
|
|
|
+
|
|
|
+LEFT JOIN users u
|
|
|
+ON c.issued_by = u.id;
|
|
|
+
|
|
|
+-- AUDIT LOGS VIEW
|
|
|
+
|
|
|
+CREATE VIEW audit_logs_view AS
|
|
|
+SELECT
|
|
|
+ al.id,
|
|
|
+ al.action,
|
|
|
+ al.target,
|
|
|
+ al.created_at,
|
|
|
+ al.ip_address,
|
|
|
+
|
|
|
+ u.username AS actor_username,
|
|
|
+
|
|
|
+ a.hostname AS agent_hostname
|
|
|
+
|
|
|
+FROM audit_logs al
|
|
|
+
|
|
|
+LEFT JOIN users u
|
|
|
+ON al.actor_id = u.id
|
|
|
+AND al.actor_type = 'user'
|
|
|
+
|
|
|
+LEFT JOIN agents a
|
|
|
+ON al.actor_id = a.id
|
|
|
+AND al.actor_type = 'agent';
|
|
|
+
|
|
|
+-- CLEANUP
|
|
|
+
|
|
|
+CREATE OR REPLACE PROCEDURE cleanup_old_commands(
|
|
|
+ days_to_keep INTEGER
|
|
|
+)
|
|
|
+LANGUAGE plpgsql
|
|
|
+AS
|
|
|
+$$
|
|
|
+DECLARE
|
|
|
+ deleted_rows INTEGER;
|
|
|
+BEGIN
|
|
|
+
|
|
|
+ DELETE FROM commands
|
|
|
+ WHERE status = 'completed'
|
|
|
+ AND completed_at < NOW() - (days_to_keep || ' days')::INTERVAL;
|
|
|
+
|
|
|
+ GET DIAGNOSTICS deleted_rows = ROW_COUNT;
|
|
|
+
|
|
|
+ INSERT INTO audit_logs (
|
|
|
+ actor_type,
|
|
|
+ action,
|
|
|
+ target,
|
|
|
+ details
|
|
|
+ )
|
|
|
+ VALUES (
|
|
|
+ 'system',
|
|
|
+ 'cleanup_completed_commands',
|
|
|
+ 'commands',
|
|
|
+ jsonb_build_object(
|
|
|
+ 'days_to_keep', days_to_keep,
|
|
|
+ 'deleted_rows', deleted_rows
|
|
|
+ )
|
|
|
+ );
|
|
|
+
|
|
|
+END;
|
|
|
+$$;
|
|
|
+
|
|
|
+-- SYSTEM STATS
|
|
|
+
|
|
|
+CREATE OR REPLACE FUNCTION get_system_stats()
|
|
|
+RETURNS TABLE (
|
|
|
+ total_users BIGINT,
|
|
|
+ total_agents BIGINT,
|
|
|
+ online_agents BIGINT,
|
|
|
+ pending_commands BIGINT
|
|
|
+)
|
|
|
+LANGUAGE plpgsql
|
|
|
+AS
|
|
|
+$$
|
|
|
+BEGIN
|
|
|
+
|
|
|
+ RETURN QUERY
|
|
|
+ SELECT
|
|
|
+ (SELECT COUNT(*) FROM users WHERE is_active = TRUE),
|
|
|
+ (SELECT COUNT(*) FROM agents),
|
|
|
+ (SELECT COUNT(*) FROM agents WHERE status = 'online'),
|
|
|
+ (SELECT COUNT(*) FROM commands WHERE status = 'pending');
|
|
|
+
|
|
|
+END;
|
|
|
+$$;
|
|
|
+
|