--
-- Licensed to the Apache Software Foundation (ASF) under one or more
-- contributor license agreements.  See the NOTICE file distributed with
-- this work for additional information regarding copyright ownership.
-- The ASF licenses this file to You under the Apache License, Version 2.0
-- (the "License"); you may not use this file except in compliance with
-- the License.  You may obtain a copy of the License at
--
--     http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--
local require = require
local core = require("apisix.core")
local rr_balancer = require("apisix.balancer.roundrobin")
local plugin = require("apisix.plugin")
local t1k = require "resty.t1k"
local expr = require("resty.expr.v1")

local ngx = ngx
local ngx_now = ngx.now
local string = string
local fmt = string.format
local tostring = tostring
local tonumber = tonumber
local ipairs = ipairs

local plugin_name = "chaitin-waf"

local vars_schema = {
    type = "array",
}

local lrucache = core.lrucache.new({
    ttl = 300, count = 1024
})

local match_schema = {
    type = "array",
    items = {
        type = "object",
        properties = {
            vars = vars_schema
        }
    },
}

local plugin_schema = {
    type = "object",
    properties = {
        mode = {
            type = "string",
            enum = { "off", "monitor", "block" }
        },
        match = match_schema,
        append_waf_resp_header = {
            type = "boolean",
            default = true
        },
        append_waf_debug_header = {
            type = "boolean",
            default = false
        },
        config = {
            type = "object",
            properties = {
                connect_timeout = {
                    type = "integer",
                },
                send_timeout = {
                    type = "integer",
                },
                read_timeout = {
                    type = "integer",
                },
                req_body_size = {
                    type = "integer",
                },
                keepalive_size = {
                    type = "integer",
                },
                keepalive_timeout = {
                    type = "integer",
                },
                real_client_ip = {
                    type = "boolean"
                }
            },
        },
    },
}

local metadata_schema = {
    type = "object",
    properties = {
        mode = {
            type = "string",
            enum = { "off", "monitor", "block" }
        },
        nodes = {
            type = "array",
            items = {
                type = "object",
                properties = {
                    host = {
                        type = "string",
                        pattern = "^\\*?[0-9a-zA-Z-._\\[\\]:/]+$"
                    },
                    port = {
                        type = "integer",
                        minimum = 1,
                        default = 80
                    },
                },
                required = { "host" }
            },
            minItems = 1,
        },
        config = {
            type = "object",
            properties = {
                connect_timeout = {
                    type = "integer",
                    default = 1000 -- milliseconds
                },
                send_timeout = {
                    type = "integer",
                    default = 1000 -- milliseconds
                },
                read_timeout = {
                    type = "integer",
                    default = 1000 -- milliseconds
                },
                req_body_size = {
                    type = "integer",
                    default = 1024 -- milliseconds
                },
                -- maximum concurrent idle connections to
                -- the SafeLine WAF detection service
                keepalive_size = {
                    type = "integer",
                    default = 256
                },
                keepalive_timeout = {
                    type = "integer",
                    default = 60000 -- milliseconds
                },
                real_client_ip = {
                    type = "boolean",
                    default = true
                }
            },
            default = {},
        },
    },
    required = { "nodes" },
}

local _M = {
    version = 0.1,
    priority = 2700,
    name = plugin_name,
    schema = plugin_schema,
    metadata_schema = metadata_schema
}

local global_server_picker

local HEADER_CHAITIN_WAF = "X-APISIX-CHAITIN-WAF"
local HEADER_CHAITIN_WAF_ERROR = "X-APISIX-CHAITIN-WAF-ERROR"
local HEADER_CHAITIN_WAF_TIME = "X-APISIX-CHAITIN-WAF-TIME"
local HEADER_CHAITIN_WAF_STATUS = "X-APISIX-CHAITIN-WAF-STATUS"
local HEADER_CHAITIN_WAF_ACTION = "X-APISIX-CHAITIN-WAF-ACTION"
local HEADER_CHAITIN_WAF_SERVER = "X-APISIX-CHAITIN-WAF-SERVER"
local blocked_message = [[{"code": %s, "success":false, ]] ..
        [["message": "blocked by Chaitin SafeLine Web Application Firewall", "event_id": "%s"}]]
local warning_message = "chaitin-waf monitor mode: request would have been rejected, event_id: "


function _M.check_schema(conf, schema_type)
    if schema_type == core.schema.TYPE_METADATA then
        return core.schema.check(metadata_schema, conf)
    end

    local ok, err = core.schema.check(plugin_schema, conf)

    if not ok then
        return false, err
    end

    if conf.match then
        for _, m in ipairs(conf.match) do
            local ok, err = expr.new(m.vars)
            if not ok then
                return false, "failed to validate the 'vars' expression: " .. err
            end
        end
    end

    return true
end


local function get_healthy_chaitin_server_nodes(metadata, checker)
    local nodes = metadata.nodes
    local new_nodes = core.table.new(0, #nodes)

    for i = 1, #nodes do
        local host, port = nodes[i].host, nodes[i].port
        new_nodes[host .. ":" .. tostring(port)] = 1
    end

    return new_nodes
end


local function get_chaitin_server(metadata, ctx)
    if not global_server_picker or global_server_picker.upstream ~= metadata.value.nodes then
        local up_nodes = get_healthy_chaitin_server_nodes(metadata.value)
        if core.table.nkeys(up_nodes) == 0 then
            return nil, nil, "no healthy nodes"
        end
        core.log.info("chaitin-waf nodes: ", core.json.delay_encode(up_nodes))

        global_server_picker = rr_balancer.new(up_nodes, metadata.value.nodes)
    end

    local server = global_server_picker.get(ctx)
    local host, port, err = core.utils.parse_addr(server)
    if err then
        return nil, nil, err
    end

    return host, port, nil
end


local function check_match(conf, ctx)
    if not conf.match or #conf.match == 0 then
        return true
    end

    for _, match in ipairs(conf.match) do
        local cache_key = tostring(match.vars)

        local exp, err = lrucache(cache_key, nil, function(vars)
            return expr.new(vars)
        end, match.vars)

        if not exp then
            local msg = "failed to create match expression for " ..
                        tostring(match.vars) .. ", err: " .. tostring(err)
            return false, msg
        end

        local matched = exp:eval(ctx.var)
        if matched then
            return true
        end
    end

    return false
end


local function get_conf(conf, metadata)
    local t = {
        mode = "monitor",
        real_client_ip = true,
    }

    if metadata.config then
        t.connect_timeout = metadata.config.connect_timeout
        t.send_timeout = metadata.config.send_timeout
        t.read_timeout = metadata.config.read_timeout
        t.req_body_size = metadata.config.req_body_size
        t.keepalive_size = metadata.config.keepalive_size
        t.keepalive_timeout = metadata.config.keepalive_timeout
        if metadata.config.real_client_ip ~= nil then
            t.real_client_ip = metadata.config.real_client_ip
        end
    end

    if conf.config then
        t.connect_timeout = conf.config.connect_timeout
        t.send_timeout = conf.config.send_timeout
        t.read_timeout = conf.config.read_timeout
        t.req_body_size = conf.config.req_body_size
        t.keepalive_size = conf.config.keepalive_size
        t.keepalive_timeout = conf.config.keepalive_timeout
        if conf.config.real_client_ip ~= nil then
            t.real_client_ip = conf.config.real_client_ip
        end
    end

    t.mode = conf.mode or metadata.mode or t.mode

    return t
end


local function do_access(conf, ctx)
    local extra_headers = {}

    local metadata = plugin.plugin_metadata(plugin_name)
    if not core.table.try_read_attr(metadata, "value", "nodes") then
        extra_headers[HEADER_CHAITIN_WAF] = "err"
        extra_headers[HEADER_CHAITIN_WAF_ERROR] = "missing metadata"
        return 500, nil, extra_headers
    end

    local host, port, err = get_chaitin_server(metadata, ctx)
    if err then
        extra_headers[HEADER_CHAITIN_WAF] = "unhealthy"
        extra_headers[HEADER_CHAITIN_WAF_ERROR] = tostring(err)

        return 500, nil, extra_headers
    end

    core.log.info("picked chaitin-waf server: ", host, ":", port)
    local t = get_conf(conf, metadata.value)
    t.host = host
    t.port = port

    extra_headers[HEADER_CHAITIN_WAF_SERVER] = host

    local mode = t.mode or "monitor"
    if mode == "off" then
        extra_headers[HEADER_CHAITIN_WAF] = "off"
        return nil, nil, extra_headers
    end

    local match, err = check_match(conf, ctx)
    if not match then
        if err then
            extra_headers[HEADER_CHAITIN_WAF] = "err"
            extra_headers[HEADER_CHAITIN_WAF_ERROR] = tostring(err)
            return 500, nil, extra_headers
        else
            extra_headers[HEADER_CHAITIN_WAF] = "no"
            return nil, nil, extra_headers
        end
    end

    if t.real_client_ip then
        t.client_ip = core.request.get_remote_client_ip(ctx)
    else
        t.client_ip = core.request.get_ip(ctx)
    end
    core.log.info("chaitin-waf client_ip: ", t.client_ip)

    local start_time = ngx_now() * 1000
    local ok, err, result = t1k.do_access(t, false)

    extra_headers[HEADER_CHAITIN_WAF_TIME] = ngx_now() * 1000 - start_time

    if not ok then
        extra_headers[HEADER_CHAITIN_WAF] = "waf-err"
        local err_msg = tostring(err)
        if core.string.find(err_msg, "timeout") then
            extra_headers[HEADER_CHAITIN_WAF] = "timeout"
        end
        extra_headers[HEADER_CHAITIN_WAF_ERROR] = tostring(err)

        if mode == "monitor" then
            core.log.warn("chaitin-waf monitor mode: detected waf error - ", err_msg)
            return nil, nil, extra_headers
        end

        return 500, nil, extra_headers
    else
        extra_headers[HEADER_CHAITIN_WAF] = "yes"
        extra_headers[HEADER_CHAITIN_WAF_ACTION] = "pass"
    end

    local code = 200
    extra_headers[HEADER_CHAITIN_WAF_STATUS] = code

    if result and result.status and result.status ~= 200 and result.event_id then
        extra_headers[HEADER_CHAITIN_WAF_STATUS] = result.status
        extra_headers[HEADER_CHAITIN_WAF_ACTION] = "reject"

        if mode == "monitor" then
            core.log.warn(warning_message, result.event_id)
            return nil, nil, extra_headers
        end

        core.log.error("request rejected by chaitin-waf, event_id: " .. result.event_id)

        return tonumber(result.status),
               fmt(blocked_message, result.status, result.event_id) .. "\n",
               extra_headers
    end

    return nil, nil, extra_headers
end


function _M.access(conf, ctx)
    local code, msg, extra_headers = do_access(conf, ctx)

    if not conf.append_waf_debug_header then
        extra_headers[HEADER_CHAITIN_WAF_ERROR] = nil
        extra_headers[HEADER_CHAITIN_WAF_SERVER] = nil
    end

    if conf.append_waf_resp_header then
        core.response.set_header(extra_headers)
    end

    return code, msg
end


function _M.header_filter(conf, ctx)
    t1k.do_header_filter()
end


return _M
