Skip to content

Commit

Permalink
Improve available port checker
Browse files Browse the repository at this point in the history
  • Loading branch information
j8r committed Jan 15, 2020
1 parent 9daea07 commit cc6088d
Show file tree
Hide file tree
Showing 8 changed files with 193 additions and 74 deletions.
52 changes: 52 additions & 0 deletions spec/port_checker_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
require "spec"
require "../src/port_checker"

describe PortChecker do
it "normalizes IPv6 addresses" do
pc = PortChecker.new "[::1]"
pc.ipaddress.address.should eq "::1"
end

{"127.0.0.1", "::1"}.each do |host|
{
PortChecker.new(host, tcp: true),
PortChecker.new(host, udp: true),
PortChecker.new(host, tcp: true, udp: true),
}.each do |pc|
msg = host
msg += " TCP" if pc.tcp
msg += " UDP" if pc.udp
if !pc.first_available_port
puts "#{msg} not supported"
next
end

describe msg do
describe "available port" do
it "returns true when available" do
port = pc.first_available_port.as Int32
pc.available_port?(port).should be_true
end

it "returns false when not available" do
tcp_socket = TCPSocket.new pc.ipaddress.family if pc.tcp
udp_socket = UDPSocket.new pc.ipaddress.family if pc.udp
port = pc.first_available_port.as Int32
begin
tcp_socket.try &.bind pc.ipaddress.address, port
udp_socket.try &.bind pc.ipaddress.address, port
pc.available_port?(port).should be_false
ensure
tcp_socket.try &.close
udp_socket.try &.close
end
end
end

it "returns the first available port" do
pc.first_available_port.should be_a Int32
end
end
end
end
end
45 changes: 3 additions & 42 deletions src/host.cr
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
require "socket"

lib LibC
fun getegid : GidT
fun getgid : GidT
Expand Down Expand Up @@ -33,7 +31,7 @@ class Process
end
end

struct DPPM::Host
module DPPM::Host
class_getter kernel_ver : String = File.read("/proc/version").partition(" version ")[2].partition('-')[0]

# System's kernel
Expand All @@ -46,7 +44,7 @@ struct DPPM::Host
{% elsif flag?(:darwin) %}
class_getter kernel = "darwin"
{% else %}
Logger.error "unsupported system"; exit 1
{{ raise "Unsupported system" }}
{% end %}

# Architecture
Expand All @@ -59,7 +57,7 @@ struct DPPM::Host
{% elsif flag?(:aarch64) %}
class_getter arch = "arm64"
{% else %}
Logger.error "Unsupported architecture"; exit 1
{{ raise "Unsupported architecture" }}
{% end %}

def self.service_available?
Expand All @@ -79,43 +77,6 @@ struct DPPM::Host
}
end

def self.tcp_port_available(port_num : UInt16) : UInt16?
TCPServer.new(port_num).close
port_num
end

def self.udp_port_available(port_num : UInt16) : UInt16?
udp_ipv4_port_available(port_num) || udp_ipv6_port_available(port_num)
end

def self.udp_ipv4_port_available(port_num : UInt16) : UInt16?
sock = UDPSocket.new Socket::Family::INET
sock.bind "127.0.0.1", port_num
sock.close
port_num
end

def self.udp_ipv6_port_available(port_num : UInt16) : UInt16?
sock = UDPSocket.new Socket::Family::INET6
sock.bind "::1", port_num
sock.close
port_num
end

# Returns an available port
def self.available_port(start_port : UInt16 = 0_u16) : UInt16
ports_used = Set(UInt16).new
(start_port..UInt16::MAX).each do |port|
begin
tcp_port_available port
return port
rescue ex : Errno
ports_used << port
end
end
raise "Limit of #{UInt16::MAX} for port numbers is reached, no ports available"
end

def self.exec(command : String, args : Array(String) | Tuple) : String
Exec.new command, args, output: DPPM::Logger.output, error: DPPM::Logger.error do |process|
raise "Execution returned an error: #{command} #{args.join ' '}" if !process.wait.success?
Expand Down
6 changes: 3 additions & 3 deletions src/logger.cr
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ module DPPM::Logger
Time.utc.to_s("%F %T%z ", io) if @@date
end

def info(title : String, message : String)
def info(title : String, message) : Nil
print_date @@output
if @@colorize
@@output << "INFO".colorize.blue.mode(:bold) << ' ' << title.colorize.white << ": " << message << '\n'
Expand All @@ -21,7 +21,7 @@ module DPPM::Logger
@@output.flush
end

def warn(title : String, message : String)
def warn(title : String, message) : Nil
print_date @@error
if @@colorize
@@error << "WARN".colorize.yellow.mode(:bold) << ' ' << title.colorize.white.mode(:bold) << ": " << message << '\n'
Expand All @@ -31,7 +31,7 @@ module DPPM::Logger
@@error.flush
end

def error(message : String)
def error(message)
print_error message
end

Expand Down
51 changes: 51 additions & 0 deletions src/port_checker.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
require "socket"

# Creates local Sockets to know local available ports.
struct PortChecker
property tcp : Bool
property udp : Bool
getter ipaddress : Socket::IPAddress

def address=(host : String)
@ipaddress = address_normalizer host
end

def initialize(host : String, @tcp : Bool = false, @udp : Bool = false)
@ipaddress = address_normalizer host
end

private def address_normalizer(host : String) : Socket::IPAddress
Socket::IPAddress.new host.lchop('[').rchop(']'), 1
end

# Returns an available port.
def available_port?(port : Int32) : Bool
if @tcp
socket = TCPSocket.new @ipaddress.family
return false if !internal_available_port? socket, port
end
if @udp
socket = UDPSocket.new @ipaddress.family
return false if !internal_available_port? socket, port
end

true
end

private def internal_available_port?(socket : IPSocket, port : Int32) : Bool
socket.bind @ipaddress.address, port
available = true
rescue ex : Errno
available = false
ensure
socket.close
available
end

# Returns the first available port.
def first_available_port(start_port : Int32 = @ipaddress.port) : Int32?
(start_port..UInt16::MAX).each do |port|
return port if available_port? port
end
end
end
1 change: 1 addition & 0 deletions src/prefix.cr
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ require "./logger"
require "./config"
require "./database"
require "./host"
require "./port_checker"
require "./service"
require "./web_site"
require "./http_helper"
Expand Down
88 changes: 64 additions & 24 deletions src/prefix/app.cr
Original file line number Diff line number Diff line change
Expand Up @@ -462,17 +462,6 @@ struct DPPM::Prefix::App
# Default variables
unset_vars = Set(String).new

if !socket && (port = vars["port"]?)
Logger.info "checking port availability", port
if pkg_file.type.udp? || pkg_file.type.tcp_udp?
Host.udp_port_available port.to_u16
end

if !pkg_file.type.udp?
Host.tcp_port_available port.to_u16
end
end

source_package = pkg.exists? || pkg.src
if web_server
pkg_file.type.webapp!
Expand All @@ -491,6 +480,8 @@ struct DPPM::Prefix::App
set_url = false
has_socket = false
database_password = nil
default_host = false
default_port = false
source_package.each_config_key do |var|
# Skip if the var is set, or port if a socket is used
if var == "socket"
Expand All @@ -508,29 +499,67 @@ struct DPPM::Prefix::App
if key.empty?
unset_vars << var
else
if var == "port"
vars["port"] = Host.available_port(key.to_u16).to_s
else
vars[var] = key
case var
when "port" then default_port = true
when "host" then default_host = true
end
Logger.info "default value set '#{var}'", key
vars[var] = key
Logger.info "Default value set '#{var}'", key
end
end
end
end
raise "Socket not supported by #{pkg_file.name}" if socket && !has_socket

# Determine a port (and host)
if (host = vars["host"]?) && (port = vars["port"]?.try &.to_i)
local_port_checker = port_checker host

available_port = port_checker local_port_checker, port, default_port

# If the default host is :1 and no available port is found, it may be blocked - try 127.0.0.1
if !available_port
Logger.warn "Limit of #{UInt16::MAX} for port numbers is reached, no ports available for the address", host
if default_host && local_port_checker.ipaddress.address == Socket::IPAddress::LOOPBACK6
local_port_checker.address = Socket::IPAddress::LOOPBACK
vars["host"] = Socket::IPAddress::LOOPBACK
available_port = port_checker local_port_checker, port, default_port
end
end

# Perhaps UDP is blocked/not available
if !available_port && local_port_checker.udp
local_port_checker.udp = false
available_port = port_checker local_port_checker, port, default_port
end

if available_port
vars["port"] = available_port.to_s
else
raise "No available port for host #{host}"
end
end

# Set url
if url
vars["url"] = url
vars["domain"] = URI.parse(url).hostname.to_s
# A web server needs an url
elsif set_url || web_server
uri = URI.new
if !(domain = vars["domain"]?)
domain = vars["host"]?
end
domain ||= "[::1]"
# Add the application name as a path by default if behind a webserver
vars["url"] = "http://" + domain + (web_server ? '/' + @name : '/')
uri.host = domain
uri.scheme = "http"
if port = vars["port"]?
uri.port = port.to_i
else
uri.path = web_server ? '/' + @name : "/"
end
# Add the application name as a path by default if behind a web server
vars["url"] = uri.to_s
vars["domain"] = domain
end

Expand All @@ -541,7 +570,7 @@ struct DPPM::Prefix::App
raise "Database name required: " + database_type if !vars.has_key?("database_name")
raise "Database user required: " + database_type if !vars.has_key?("database_user")
if !vars.has_key?("database_address") || !(vars.has_key?("database_host") && vars.has_key?("database_port"))
raise "Database address or host and port required:" + database_type
raise "Database address, or host and port required:" + database_type
end
end
end
Expand Down Expand Up @@ -736,6 +765,17 @@ struct DPPM::Prefix::App
end
end

private def port_checker(local_port_checker : PortChecker, port : Int32, find_port : Bool) : Int32?
if local_port_checker.available_port? port
return port
else
Logger.warn "Port not available on host '#{local_port_checker.ipaddress.address}'", port
end
if find_port
return local_port_checker.first_available_port port + 1
end
end

private def copy_dir(src : String, dest : String)
if !File.exists? dest
if File.exists? src
Expand All @@ -753,11 +793,11 @@ struct DPPM::Prefix::App
end

if shared
Logger.info "creating symlinks from #{pkg.path}", @path.to_s
Logger.info "creating symlinks from #{pkg.path}", @path
File.symlink pkg.app_path.to_s, app_path.to_s
File.symlink pkg.pkg_file.path.to_s, pkg_file.path.to_s
else
Logger.info "copying from #{pkg.path}", @path.to_s
Logger.info "copying from #{pkg.path}", @path
FileUtils.cp_r pkg.app_path.to_s, app_path.to_s
FileUtils.cp_r pkg.pkg_file.path.to_s, pkg_file.path.to_s
end
Expand Down Expand Up @@ -815,7 +855,7 @@ struct DPPM::Prefix::App
begin
if webserver = webserver?
website = webserver.parse_site @name
Logger.info "deleting web site", website.file.to_s
Logger.info "deleting web site", website.file
File.delete webserver_sites_path.to_s
File.delete website.file.to_s
if output_file = website.log_file_output.to_s
Expand Down Expand Up @@ -844,15 +884,15 @@ struct DPPM::Prefix::App
libcrown.write
end
rescue ex
Logger.warn "error when deleting system user/group", ex.to_s
Logger.warn "error when deleting system user/group", ex
end

if !preserve_database && (app_database = database?)
Logger.info "deleting database", app_database.user
app_database.delete
end

Logger.info "delete completed", @path.to_s
Logger.info "delete completed", @path
self
ensure
FileUtils.rm_rf @path.to_s
Expand Down
Loading

0 comments on commit cc6088d

Please sign in to comment.