##############################################
# $Id: TcpServerUtils.pm 28227 2023-11-29 12:33:32Z rudolfkoenig $

package main;
use strict;
use warnings;
use IO::Socket;
use vars qw($SSL_ERROR);

my ($joinGroup, $leaveGroup, $multiCastLoop, $addMembership, $dropMembership);

# Perl 5.16 / wheezy compatibility mode / #126290
eval "Socket::IPV6_JOIN_GROUP";
if($@) {
  $joinGroup      = 20;
  $leaveGroup     = 21;
  $multiCastLoop  = 34;
  $addMembership  = 35;
  $dropMembership = 36;

} else {
  $joinGroup      = eval "Socket::IPV6_JOIN_GROUP";
  $leaveGroup     = eval "Socket::IPV6_LEAVE_GROUP";
  $multiCastLoop  = eval "Socket::IP_MULTICAST_LOOP";
  $addMembership  = eval "Socket::IP_ADD_MEMBERSHIP";
  $dropMembership = eval "Socket::IP_DROP_MEMBERSHIP";

}

sub
TcpServer_Open($$$;$)
{
  my ($hash, $port, $global, $multicast) = @_;
  my $name = $hash->{NAME};

  return 'multicast not supported without SO_REUSEPORT'
        if($multicast && !defined(&SO_REUSEPORT));

  if($port =~ m/^IPV6:(\d+)$/i) {
    $port = $1;
    eval "require IO::Socket::INET6; use Socket6;";
    if($@) {
      Log3 $hash, 1, $@;
      Log3 $hash, 1, "$name: Can't load INET6, falling back to IPV4";
    } else {
      $hash->{IPV6} = 1;
    }
  }

  my $lh = ($global ? ($global eq "global"? undef : $global) :
                      ($hash->{IPV6} ? "::1" : "127.0.0.1"));
  my %opts = (
    Domain    => ($hash->{IPV6} ? AF_INET6() : AF_UNSPEC), # Linux bug / #126448
    LocalHost => $lh,
    LocalPort => $port,
    Listen    => 32,    # For Windows
    Blocking  => ($^O =~ /Win/ ? 1 : 0), # Needed for .WRITEBUFFER@darwin
    ReuseAddr => 1
  );
  
  if($multicast) {
    $opts{ReusePort} = 1;
    $opts{Proto} = 'udp';
    delete($opts{Listen});
    $hash->{MULTICAST} = $multicast;
  }

  readingsSingleUpdate($hash, "state", "Initialized", 0);
  $hash->{SERVERSOCKET} = $hash->{IPV6} ?
        IO::Socket::INET6->new(%opts) : 
        IO::Socket::INET->new(%opts);

  if(!$hash->{SERVERSOCKET}) {
    return "$name: Can't open server port at $port: $!";
  }

  $hash->{FD} = $hash->{SERVERSOCKET}->fileno();
  $hash->{PORT} = $hash->{SERVERSOCKET}->sockport();

  $selectlist{"$name.$port"} = $hash;
  Log3 $hash, 3, "$name: port ". $hash->{PORT} ." opened";
  return undef;
}

sub
TcpServer_SetLoopbackMode($$)
{
  my ($hash, $loopback) = @_;
  my $name = $hash->{NAME};
  my $sock = $hash->{SERVERSOCKET};

  my $old;
  if( !$hash->{IPV6} && $sock->sockdomain() == AF_INET ) {
    my $packed = getsockopt($sock, Socket::IPPROTO_IP, $multiCastLoop);
    if( !$packed ) {
      Log3 $name, 1, "$name: failed to get loopback mode: $!";
      return undef;
    }
    $old = unpack("I", $packed);

    if( !setsockopt($sock, Socket::IPPROTO_IP,
                    $multiCastLoop, pack("I", $loopback ) ) ) {
      Log3 $name, 1, "$name: could not set loopback mode: $!";
      return undef;
    }

  } elsif( !$hash->{IPV6} && $sock->sockdomain() == AF_INET6 ) {
    my $packed = getsockopt($sock, Socket::IPPROTO_IPV6,
                            Socket::IPV6_MULTICAST_LOOP);
    if( !$packed ) {
      Log3 $name, 1, "$name: failed to get loopback mode: $!";
      return undef;
    }
    $old = unpack("I", $packed);

    if( setsockopt($sock, Socket::IPPROTO_IPV6,
                   Socket::IPV6_MULTICAST_LOOP, pack("I", $loopback ) ) ) {
      Log3 $name, 1, "$name: could not set loopback mode: $!";
      return undef;
    }

  } else {
    Log3 $name, 1,
        "$name: TcpServer_SetLoopbackMode failed: unsupported socket family";
    return undef;
  }

  return $old;
}

sub
TcpServer_MCastAdd($$)
{
  my ($hash, $addr) = @_;
  my $name = $hash->{NAME};
  my $sock = $hash->{SERVERSOCKET};

  $hash->{ADDR} = $addr;

  # disable loopback
  TcpServer_SetLoopbackMode($hash, 0);

  # add multicast address
  if(!$hash->{IPV6} && $sock->sockdomain() == AF_INET) {
    my $ip_mreq = Socket::pack_ip_mreq( inet_aton( $addr ), INADDR_ANY );
    setsockopt($sock, Socket::IPPROTO_IP, $addMembership, $ip_mreq )
      or return "$name: could not set IP_ADD_MEMBERSHIP socket option: $!";

  } elsif($hash->{IPV6} && $sock->sockdomain() == AF_INET6) {
    my $ipv6_mreq = Socket::pack_ipv6_mreq( inet_pton( AF_INET6, $addr ), 0 );
    setsockopt($sock, Socket::IPPROTO_IPV6, $joinGroup, $ipv6_mreq )
      or return "$name: could not set IPV6_JOIN_GROUP socket option: $!";

  } else {
    return("$name: TcpServer_MCastAdd failed: unsupported socket family" );

  }

  readingsSingleUpdate($hash, "state", "Multicast listen", 0);

  return undef;
}

sub
TcpServer_MCastRecv($$$;$)
{
  my ($hash, undef, $length, $flags) = @_;
  my $name = $hash->{NAME};
  my $sock = $hash->{SERVERSOCKET};

  my $sockaddr = $sock->recv($_[1], $length, $flags);
  if(!$hash->{IPV6} && $sock->sockdomain() == AF_INET) {
    my ($peer_port, $addr) = Socket::unpack_sockaddr_in($sockaddr);
    my $peer_host = inet_ntoa($addr);
    return $peer_host, $peer_port;

  } elsif($hash->{IPV6} && $sock->sockdomain() == AF_INET6) {
    my ($peer_port, $addr) = Socket::unpack_sockaddr_in6($sockaddr);
    my $peer_host = inet_ntop(AF_INET6(),$addr);
    return $peer_host, $peer_port;

  } else {
    Log3 $name, 1,"$name: TcpServer_MCastRecv failed: unsupported socket family";
    return undef;
  }
}

sub
TcpServer_MCastSend($$;$$)
{
  my ($hash, $data, $addr, $port) = @_;
  my $name = $hash->{NAME};
  my $sock = $hash->{SERVERSOCKET};

  $addr = $hash->{ADDR} if( !$addr );
  $port = $hash->{PORT} if( !$port );

  if( !$addr ) {
    Log3 $name, 1, "$name: TcpServer_MCastSend failed: address unknown";
    return undef;
  }
  if( !$port ) {
    Log3 $name, 1, "$name: TcpServer_MCastSend failed: port unknown";
    return undef;
  }

  if(!$hash->{IPV6} && $sock->sockdomain() == AF_INET) {
    my $sockaddr = Socket::pack_sockaddr_in($port, inet_aton($addr));
    return $sock->send($data,0,$sockaddr);

  } elsif($hash->{IPV6} && $sock->sockdomain() == AF_INET6) {
    my $sockaddr = Socket::pack_sockaddr_in6($port, inet_pton($addr));
    return $sock->send($data,0,$sockaddr);

  } else {
    Log3 $name, 1,"$name: TcpServer_MCastSend failed: unsupported socket family";
    return undef;
  }

}

sub
TcpServer_MCastRemove($$)
{
  my ($hash, $addr) = @_;
  my $name = $hash->{NAME};
  my $sock = $hash->{SERVERSOCKET};

  delete $hash->{ADDR};

  if(!$hash->{IPV6} && $sock->sockdomain() == AF_INET) {
    my $ip_mreq = Socket::pack_ip_mreq( inet_aton( $addr ), INADDR_ANY );
    setsockopt($sock, Socket::IPPROTO_IP, $dropMembership, $ip_mreq )
      or return "$name: could not set IP_DROP_MEMBERSHIP socket option: $!";

  } elsif($hash->{IPV6} && $sock->sockdomain() == AF_INET6) {
    my $ipv6_mreq = Socket::pack_ipv6_mreq( inet_pton( AF_INET6, $addr ), 0 );
    setsockopt($sock, Socket::IPPROTO_IPV6, $leaveGroup, $ipv6_mreq) 
      or return "$name: could not set IPV6_LEAVE_GROUP socket option: $!";

  } else {
    return("$name: TcpServer_MCastRemove failed: unsupported socket family" );

  }

  readingsSingleUpdate($hash, "state", "Multicast listen stopped", 0);
  return undef;
}

sub
TcpServer_Accept($$)
{
  my ($hash, $type) = @_;
  my $name = $hash->{NAME};

  if($hash->{MULTICAST}) {
    Log3 $name, 1, "$name: can't accept on a mutlicast socket";
    return undef;
  }

  my @clientinfo = $hash->{SERVERSOCKET}->accept();
  if(!@clientinfo) {
    Log3 $name, 1, "Accept failed ($name: $!)" if($! != EAGAIN);
    return undef;
  }
  $hash->{CONNECTS}++;

  my ($port, $iaddr) = $hash->{IPV6} ? 
      sockaddr_in6($clientinfo[1]) :
      sockaddr_in($clientinfo[1]);
  my $caddr = $hash->{IPV6} ?
                inet_ntop(AF_INET6(), $iaddr) :
                inet_ntoa($iaddr);

  my $af = $attr{$name}{allowfrom};
  if(!$af) {
    my $re ="^(::ffff:)?(127|192.168|172.(1[6-9]|2[0-9]|3[01])|10|169.254)\\.|".
            "^(f[cde]|::1)";
    if($caddr !~ m/$re/) {
      my %empty;
      $hash->{SNAME} = $hash->{NAME};
      my $auth = Authenticate($hash, \%empty);
      delete $hash->{SNAME};
      if($auth == 0) {
        Log3 $name, 1,
             "Connection refused from the non-local address $caddr:$port, ".
             "as there is no working allowed instance defined for it";
        close($clientinfo[0]);
        return undef;
      }
    }
  }

  if($af) {
    if($caddr !~ m/$af/) {
      my $hostname = gethostbyaddr($iaddr, AF_INET);
      if(!$hostname || $hostname !~ m/$af/) {
        Log3 $name, 1, "Connection refused from $caddr:$port";
        close($clientinfo[0]);
        return undef;
      }
    }
  }

  #$clientinfo[0]->blocking(0);  # Forum #24799

  if($hash->{SSL}) {
    # Forum #27565: SSLv23:!SSLv3:!SSLv2', #35004: TLSv12:!SSLv3
    my $sslVersion = AttrVal($hash->{NAME}, "sslVersion", 
                     AttrVal("global", "sslVersion", undef));

    # Certs directory must be in the modpath, i.e. at the same level as the
    # FHEM directory
    my $mp = AttrVal("global", "modpath", ".");
    my $certPrefix = AttrVal($name, "sslCertPrefix", "certs/server-");
    my $ret;
    eval {
      $ret = IO::Socket::SSL->start_SSL($clientinfo[0], {
        SSL_server    => 1, 
        SSL_key_file  => "$mp/${certPrefix}key.pem",
        SSL_cert_file => "$mp/${certPrefix}cert.pem",
        SSL_version => $sslVersion,
        SSL_cipher_list => 'HIGH:!RC4:!eNULL:!aNULL',
        Timeout       => 4,
        });
      $! = EINVAL if(!$clientinfo[0]->blocking() && $!==EWOULDBLOCK);
    };
    my $err = $!;
    if( !$ret
      && $err != EWOULDBLOCK
      && $err ne "Socket is not connected") {
      $err = "" if(!$err);
      $err .= " ".($SSL_ERROR ? $SSL_ERROR : IO::Socket::SSL::errstr());
      my $errLevel = ($err =~ m/error:14094416:SSL/ || # 61511
                      $err =~ m/ssl accept attempt failed/i ? 5 : 1); #134820
      Log3 $name, $errLevel, "$type SSL/HTTPS error: $err (peer: $caddr)"
        if($err !~ m/error:00000000:lib.0.:func.0.:reason.0./); #56364
      close($clientinfo[0]);
      return undef;
    }
  }

  my $cname = "${name}_${caddr}_${port}";
  my %nhash;
  $nhash{NR}    = $devcountTemp++;
  $nhash{NAME}  = $cname;
  $nhash{PEER}  = $caddr;
  $nhash{PORT}  = $port;
  $nhash{FD}    = $clientinfo[0]->fileno();
  $nhash{CD}    = $clientinfo[0];     # sysread / close won't work on fileno
  $nhash{TYPE}  = $type;
  $nhash{SSL}   = $hash->{SSL};
  readingsSingleUpdate(\%nhash, "state", "Connected", 0);
  $nhash{SNAME} = $name;
  $nhash{TEMPORARY} = 1;              # Don't want to save it
  $nhash{BUF}   = "";
  $attr{$cname}{room} = "hidden";
  $defs{$cname} = \%nhash;
  $selectlist{$nhash{NAME}} = \%nhash;

  my $ret = $clientinfo[0]->setsockopt(SOL_SOCKET, SO_KEEPALIVE, 1);

  Log3 $name, 4, "Connection accepted from $nhash{NAME}";
  return \%nhash;
}

sub
TcpServer_SetSSL($)
{
  my ($hash) = @_; 
  eval "require IO::Socket::SSL";
  if($@) {
    Log3 $hash, 1, $@;
    Log3 $hash, 1, "Can't load IO::Socket::SSL, falling back to HTTP";
    return;
  }

  my $name = $hash->{NAME};
  my $cp = AttrVal("global", "modpath", ".")."/".
           AttrVal($name, "sslCertPrefix", "certs/server-");
  if(! -r "${cp}key.pem") {

    Log 1, "$name: Server certificate missing, trying to create one";
    if($cp =~ m,^(.*)/(.*?), && ! -d $1 && !mkdir($1)) {
      Log 1, "$name: failed to create $1: $!, falling back to HTTP";
      return;
    }

    if(!open(FH,">certreq.txt")) {
      Log 1, "$name: failed to create certreq.txt: $!, falling back to HTTP";
      return;
    }
    my $hostname = `hostname`;
    chomp($hostname);
    print FH << "EOF";
[ req ]
prompt = no
distinguished_name = dn
x509_extensions = ext

[ dn ]
CN = $hostname
O = FHEM
OU = localhost

[ ext ]
basicConstraints=CA:TRUE
extendedKeyUsage = serverAuth
subjectAltName=\@san

[san]
DNS.1=localhost
DNS.2=$hostname
IP.1=127.0.0.1
IP.2=::1
EOF

    close(FH);

    my $cmd = "openssl req -new -x509 -days 3650 -nodes -newkey rsa:2048 ".
                "-config certreq.txt -out ${cp}cert.pem -keyout ${cp}key.pem";
    Log 1, "Executing $cmd";
    `$cmd`;
    unlink("certreq.txt");
  }
  $hash->{SSL} = 1;
}


sub
TcpServer_Close($@)
{
  my ($hash, $dodel, $ignoreNtfy) = @_;
  my $name = $hash->{NAME};

  if(defined($hash->{CD})) { # Clients
    close($hash->{CD});
    delete($hash->{CD}); 
    delete($selectlist{$name});
    delete($hash->{FD});  # Avoid Read->Close->Write
    removeFromNtfyHash($name) if(!$ignoreNtfy); # can be expensive
  }

  if(defined($hash->{SERVERSOCKET})) {          # Server
    close($hash->{SERVERSOCKET});
    $name = $name . "." . $hash->{PORT};
    delete($selectlist{$name});
    delete($hash->{FD});  # Avoid Read->Close->Write
  }

  if($dodel) {
    delete $attr{$name};
    delete $defs{$name};
  } else {
    $hash->{stacktrace} = stacktraceAsString(1);
  }
  return undef;
}

# close a (SSL-)Socket in local process
# avoids interfering with other processes using it
# this is critical for SSL and helps with other issues, too 
sub
TcpServer_Disown($)
{
  my ($hash) = @_;
  my $name = $hash->{NAME};

  if( defined($hash->{CD}) ){
    if( $hash->{SSL} ){
      $hash->{CD}->close( SSL_no_shutdown => 1);
    } else {
      close( $hash->{CD} );
    }
    delete($hash->{CD});
    delete($selectlist{$name});
    delete($hash->{FD});  # Avoid Read->Close->Write
    $hash->{stacktrace} = stacktraceAsString(1);
  }

  return;
}

# wait for a socket to become ready
# takes IO::Socket::SSL + non-blocking into account
sub
TcpServer_Wait($$)
{
  my( $hash, $direction ) = @_;

  my $read = '';
  my $write ='';

  if( $direction eq 'read' || $hash->{wantRead} ){
    vec( $read, $hash->{FD}, 1) = 1;
  } elsif( $direction eq 'write' || $hash->{wantWrite} ){
    vec( $write, $hash->{FD}, 1) = 1;
  } else {
    return undef;
  }

  my $ret = select( $read, $write, undef, undef );
  return if $ret == -1;

  if( vec( $read, $hash->{FD}, 1) ){
    delete $hash->{wantRead};
  }
  if( vec( $write, $hash->{FD}, 1) ){
    delete $hash->{wantWrite};
  }

  # return true on success
  return 1;
}

# WantRead/Write: keep ssl constants local
sub
TcpServer_WantRead($)
{
  my( $hash ) = @_;

  return $hash->{SSL}
	&& $hash->{CD}
	&& $hash->{CD}->errstr == &IO::Socket::SSL::SSL_WANT_READ;
}

sub
TcpServer_WantWrite($)
{
  my( $hash ) = @_;

  return $hash->{SSL}
	&& $hash->{CD}
	&& $hash->{CD}->errstr == &IO::Socket::SSL::SSL_WANT_WRITE;
}

# write until all data is done.
# hanldes both, blocking and non-blocking sockets
# ... with or without SSL
sub
TcpServer_WriteBlocking($$)
{
  my( $hash, $txt ) = @_;

  if($hash->{WriteFn}) { # FWTP needs it
    no strict "refs";
    return &{$hash->{WriteFn}}($hash, \$txt);
    use strict "refs";
  }

  my $sock = $hash->{CD};
  return undef if(!$sock);
  my $off = 0;
  my $len = length($txt);

  while($off < $len) {
    if(!TcpServer_Wait($hash, 'write')) {
      TcpServer_Close($hash);
      return undef;
    }

    my $ret;
    eval { $ret = syswrite($sock, $txt, $len-$off, $off); }; # Wide character
    if($@) {
      Log 1, $@;
      Log 1, "txt:".join(":",unpack("C*",$txt)).",len:$len,off:$off";
      stacktrace();
    }

    if( defined $ret ){
      $off += $ret;
      my $sh = $defs{$hash->{SNAME}};
      $sh->{BYTES_WRITTEN} += $ret if(defined($sh->{BYTES_WRITTEN}));

    } elsif( $! == EWOULDBLOCK ){
      $hash->{wantRead} = 1
        if TcpServer_WantRead($hash);

    } else {
      TcpServer_Close($hash);
      return undef; # error
    }
  }

  return 1; # success
}

=pod

Multicast:
(https://forum.fhem.de/index.php/topic,126290.msg1209591.html#msg1209591)
verwendet wird es so:
- das socket mit gesetztem optionalen vierten paramter von TcpServer_Open
  erzeugen: d.h. im define oder sonst wo mit my $ret = TcpServer_Open($hash,
  '5353', '0.0.0.0', 1); initialisieren. statt der 0.0.0.0 kann man auch die
  multicast adresse angeben, ich bin mir nicht sicher was richtiger ist und ob
  es einen unterschied macht. funktionieren tut bei mir beides.
- TcpServer_Open schaltet automatisch den loopback mode aus.  d.h. man empfängt
  seine eigenen daten nicht. ich vermute das ist der normalfall.  mit
  TcpServer_SetLoopbackMode($hash,[0|1]); kann man das ändern wenn man es
  braucht.
- mit TcpServer_MCastAdd die multicast adresse zum socket explizit hinzufügen:
  z.b.: TcpServer_MCastAdd($hash,'224.0.0.251'); erst ab jetzt bekommt man auch
  tatsächlich daten.
- im (device) hash wird für jedes udp packet die ReadFn aufgerufen.  dort kann
  man mit TcpServer_MCastRecv dann die empfangenen daten abholen:
  my($peer_host, $peer_port) = TcpServer_MCastRecv($hash,$data,$length);

  alternativ kann man auch die low leven routinen direkt aufrufen:
  - my $sockaddr = $hash->{SERVERSOCKET}->recv($data, 4096);
    der empfang geht über SERVERSOCKET, nicht wie bei tcp über CD, es wird auch
    kein accept verwendet
  - aus $sockaddr kann man sich die gegenstelle zum udp packet holen falls man
    die braucht:
    my ($peer_port, $addr) = Socket::sockaddr_in($sockaddr);
    my $peer_host = inet_ntoa($addr);
- eigene daten sendet man mit TcpServer_MCastSend:
  TcpServer_MCastSend($hash,$data[,$host[,$port]]); hier bei wird der port aus
  dem TcpServer_Open und die adresse aus TcpServer_MCastAdd verwendet.
  alternativ kann man beides auch beim aufruf mitgeben.
- mit TcpServer_MCastRemove kann man die multicast adresse auch wieder vom
  socket entfernen:
  z.b.: TcpServer_MCastRemove($hash,'224.0.0.251');
  das socket empfängt dann keine daten für diese adresse mehr.
- mit wechselweisem TcpServer_MCastAdd und TcpServer_MCastRemove kann man auch
  zeitweise zwischen empfangen und ignorieren hin und her wechseln. z.b. als
  reaktion auf disable.
- mit TcpServer_Close($hash); das ganze am ende z.b. in der UndefFn wieder zu
  machen.

=cut

1;
