🍆

Misskeyを使って内輪向けのクローズドなSNSを構築してみようとしたけどうまくいかなかった話

2024/11/27に公開

クローズドなSNSというと、部活やサークルではLineのグループ機能やDiscordなどのチャットツール、会社ではMicrosoft365やGoogle Workspaceといったようなグループウェアを利用することが多いかと思います。
ただ、上記のようなツールだと雰囲気が堅苦しくなったり、逆に内輪ノリになり過ぎて発言しにくくなる人が出たりもしてしまうので、Twitterのようなタイムラインを搭載したミニブログ形式のSNSの方が場合によっては優れている場面もあるのではないかと思いました。
そこで今回はTwitterライクなSNSとして、Misskeyが利用できないか検証してみました。
要件としては以下を想定しています。

  1. スマホからも利用するためIP制限などはかけない
  2. 外部の人間からは投稿を見えないようにする
  3. リモート(他のサーバ)とActivityPubでの連携はしない
  4. なるべく工数はかけない

MisskeyはActivityPubを採用した分散型SNSのため、クローズドな運用をするのは本来の用途ではなく、意外と上記要件を満たすのが面倒でした。
結論から言うと今回は完全に非公開にすることができなかったのですが、同じようなことを考えている人がもし万が一いれば、本記事が参考になれば幸いです。

なお、Misskeyを構築する部分については本記事では割愛しますが、基本的に以下手順を参考に構築しています。
https://misskey-hub.net/ja/docs/for-admin/install/guides/ubuntu-manual/

環境は以下CloudformationでUbuntu Server 22.04 LTSを構築し、ALBをHTTPS終端にしています。
Route53にホストゾーンさえ登録されていれば利用可能です。
(昔書いたものを流用しているのでSSMではなく直接EC2にパブリックIPを付与してしまっています)

Cloudformation.yml
AWSTemplateFormatVersion: "2010-09-09"
Description: 
  Misskey CloudFormation

Metadata: 
  "AWS::CloudFormation::Interface": 
    ParameterGroups: 
      - Label: 
          default: "Project Name Prefix"
        Parameters: 
          - PJPrefix
      - Label: 
          default: "Network Configuration"
        Parameters: 
          - VPCCIDR
          - PublicSubnetACIDR
          - PublicSubnetCCIDR
      - Label:
          default: "EC2 Configration"
        Parameters:
          - EC2Name
          - AMIImage
          - InstanceType
          - MyIP
          - EC2VolumeType
          - EC2VolumeSize
      - Label:
          default: "Route53 Configration"
        Parameters:
          - HostZoneName
          - HostName
      - Label:
          default: "ALB Configration"
        Parameters:
          - InternetALBName
          - AllowWebAccessIP
      - Label:
          default: "ACM Configration"
        Parameters:
          - HostedZoneId
    ParameterLabels: 
      VPCCIDR: 
        default: "VPC CIDR"
      PublicSubnetACIDR: 
        default: "PublicSubnetA CIDR"
      PublicSubnetCCIDR: 
        default: "PublicSubnetC CIDR"
      EC2Name:
        default: "EC2 Instance Name"
      AMIImage:
        default: "Use EC2 AMI Image  Default Ubuntu Server 22.04 LTS"
      InstanceType:
        default: "InstanceType"
      MyIP:
        default: "Your IP Address with CIDR"
      EC2VolumeType:
        default: "EC2 Instance Volume Type"
      EC2VolumeSize:
        default: "EC2 Instance Volume Size GiB"
      HostZoneName:
        default: "Domain HostZone Name"
      HostName:
        default: "HostName"
      InternetALBName:
        default: "Internet ALB Name"
      AllowWebAccessIP:
        default: "Allow Web Access IP with CIDR"
      HostedZoneId:
        default: "Domain HostedZone ID"

Parameters:
  PJPrefix:
    Type: String

  VPCCIDR:
    Type: String
    Default: "192.168.0.0/24"

  PublicSubnetACIDR:
    Type: String
    Default: "192.168.0.0/27"

  PublicSubnetCCIDR:
    Type: String
    Default: "192.168.0.32/27"

  EC2Name:
    Type: String
    Default: misskey

  AMIImage:
    Type: String
    Default: ami-0ac6b9b2908f3e20d

  InstanceType:
    Type: String
    Default: t3a.medium

  MyIP:
    Type: String
    Default: 0.0.0.0/32 #自身の環境のグローバルIPに書き換えてください

  EC2VolumeType:
    Type: String
    Default: gp3

  EC2VolumeSize:
    Type: Number
    Default: 20

  HostZoneName:
    Type: String
    Default: example.com #自身の環境のホストゾーン名に書き換えてください

  HostName:
    Type: String
    Default: misskey #任意のホスト名に書き換えてください

  InternetALBName:
    Type: String
    Default: alb

  AllowWebAccessIP:
    Type: String
    Default: 0.0.0.0/0

  HostedZoneId:
    Type: String
    Default: XXXXXXXXXXXXXXXXX #自身の環境のホストゾーンIDに書き換えてください

Resources:
# VPC
  VPC: 
    Type: "AWS::EC2::VPC"
    Properties: 
      CidrBlock: !Ref VPCCIDR
      EnableDnsSupport: "true"
      EnableDnsHostnames: "true"
      InstanceTenancy: default
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-vpc"

# InternetGateway
  InternetGateway: 
    Type: "AWS::EC2::InternetGateway"
    Properties: 
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-igw"

# attach
  InternetGatewayAttachment: 
    Type: "AWS::EC2::VPCGatewayAttachment"
    Properties: 
      InternetGatewayId: !Ref InternetGateway
      VpcId: !Ref VPC 
      
# Public SubnetA
  PublicSubnetA: 
    Type: "AWS::EC2::Subnet"
    Properties: 
      AvailabilityZone: "ap-northeast-1a"
      CidrBlock: !Ref PublicSubnetACIDR
      VpcId: !Ref VPC 
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-public-subnet-a"

# Public SubnetC
  PublicSubnetC: 
    Type: "AWS::EC2::Subnet"
    Properties: 
      AvailabilityZone: "ap-northeast-1c"
      CidrBlock: !Ref PublicSubnetCCIDR
      VpcId: !Ref VPC 
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-public-subnet-c"
       
# Public RouteTableA
  PublicRouteTableA: 
    Type: "AWS::EC2::RouteTable"
    Properties: 
      VpcId: !Ref VPC 
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-public-route-a"

# Public RouteTableC
  PublicRouteTableC: 
    Type: "AWS::EC2::RouteTable"
    Properties: 
      VpcId: !Ref VPC 
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-public-route-c"

# PublicRouteA
  PublicRouteA: 
    Type: "AWS::EC2::Route"
    Properties: 
      RouteTableId: !Ref PublicRouteTableA 
      DestinationCidrBlock: "0.0.0.0/0"
      GatewayId: !Ref InternetGateway 

# PublicRouteC
  PublicRouteC: 
    Type: "AWS::EC2::Route"
    Properties: 
      RouteTableId: !Ref PublicRouteTableC 
      DestinationCidrBlock: "0.0.0.0/0"
      GatewayId: !Ref InternetGateway 

  PublicSubnetARouteTableAssociation: 
    Type: "AWS::EC2::SubnetRouteTableAssociation"
    Properties: 
      SubnetId: !Ref PublicSubnetA 
      RouteTableId: !Ref PublicRouteTableA

  PublicSubnetCRouteTableAssociation: 
    Type: "AWS::EC2::SubnetRouteTableAssociation"
    Properties: 
      SubnetId: !Ref PublicSubnetC 
      RouteTableId: !Ref PublicRouteTableC

# EC2
  EC2: 
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !Ref AMIImage
      KeyName: !Sub "${PJPrefix}-key"
      InstanceType: !Ref InstanceType
      BlockDeviceMappings:
        - DeviceName: /dev/xvda
          Ebs:
            VolumeType: !Ref EC2VolumeType
            DeleteOnTermination: true
            VolumeSize: !Ref EC2VolumeSize
      NetworkInterfaces:
        - DeleteOnTermination: True 
          DeviceIndex: "0"
          SubnetId: !Ref PublicSubnetA
          GroupSet:
            - !Ref EC2SG
      Tags:
          - Key: Name
            Value: !Sub "${PJPrefix}-${EC2Name}"

# Keypair
  Keypair:
    Type: AWS::EC2::KeyPair
    Properties: 
      KeyFormat: pem
      KeyName: !Sub "${PJPrefix}-key"
      KeyType: ed25519

# ElasticIP
  ElasticIP:
    Type: "AWS::EC2::EIP"
    Properties:
      Domain: vpc
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-eip"

  ElasticIPAssociate:
    Type: AWS::EC2::EIPAssociation
    Properties: 
      AllocationId: !GetAtt ElasticIP.AllocationId
      InstanceId: !Ref EC2
        
  #SG
  EC2SG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName:  !Sub "${PJPrefix}-EC2-SG"
      GroupDescription: Allow SSH access
      VpcId: !Ref VPC
      SecurityGroupIngress:
        # ssh
        - IpProtocol: tcp
          FromPort: 22
          ToPort: 22
          CidrIp: !Ref MyIP
        #ALB
  EC2SGIngress: 
    Type: "AWS::EC2::SecurityGroupIngress"
    Properties: 
      IpProtocol: tcp
      FromPort: 80
      ToPort: 80
      SourceSecurityGroupId: !GetAtt [ ALBSG, GroupId ] 
      GroupId: !GetAtt [ EC2SG, GroupId ]

  ALBSG:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      VpcId: !Ref VPC
      GroupName: !Sub "${PJPrefix}-alb-sg"
      GroupDescription: "-"
      Tags:
        - Key: "Name"
          Value: !Sub "${PJPrefix}-alb-sg"
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: !Ref AllowWebAccessIP

# A Record Alias
  DNSRecordSet:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneName: !Sub "${HostZoneName}."
      Name: !Sub "${HostName}.${HostZoneName}."
      Type: A
      AliasTarget:
        DNSName: !GetAtt InternetALB.DNSName
        HostedZoneId: !GetAtt InternetALB.CanonicalHostedZoneID

#  Target Group
  TargetGroup: 
    Type: "AWS::ElasticLoadBalancingV2::TargetGroup"
    Properties: 
      VpcId: !Ref VPC 
      Name: !Sub "${PJPrefix}-${InternetALBName}-tg"
      Protocol: HTTP
      Port: 80
      HealthCheckProtocol: HTTP
      HealthCheckPath: "/"
      HealthCheckPort: "traffic-port"
      HealthyThresholdCount: 5
      UnhealthyThresholdCount: 2
      HealthCheckTimeoutSeconds: 5
      HealthCheckIntervalSeconds: 30
      Matcher: 
        HttpCode: 200
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-${InternetALBName}-tg"
      TargetGroupAttributes: 
        - Key: "deregistration_delay.timeout_seconds"
          Value: 300
        - Key: "stickiness.enabled"
          Value: false
        - Key: "stickiness.type"
          Value: lb_cookie
        - Key: "stickiness.lb_cookie.duration_seconds"
          Value: 86400
      Targets: 
        - Id: !Ref EC2
          Port: 80

#  Internet ALB
  InternetALB: 
    Type: "AWS::ElasticLoadBalancingV2::LoadBalancer"
    Properties: 
      Name: !Sub "${PJPrefix}-${InternetALBName}-alb"
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-${InternetALBName}-alb"
      Scheme: "internet-facing"
      LoadBalancerAttributes: 
        - Key: "deletion_protection.enabled"
          Value: false
        - Key: "idle_timeout.timeout_seconds"
          Value: 300
      SecurityGroups:
        - !Ref ALBSG
      Subnets: 
        - !Sub "${PublicSubnetA}"
        - !Sub "${PublicSubnetC}"

  ALBListener: 
    Type: "AWS::ElasticLoadBalancingV2::Listener"
    Properties: 
      DefaultActions: 
        - TargetGroupArn: !Ref TargetGroup
          Type: forward
      LoadBalancerArn: !Ref InternetALB
      Port: 443
      Protocol: HTTPS
      Certificates:
        - CertificateArn: !Ref ACM

#  ACM
  ACM:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName: !Sub "${HostName}.${HostZoneName}"
      DomainValidationOptions:
        - DomainName: !Sub "${HostName}.${HostZoneName}"
          HostedZoneId: !Ref HostedZoneId
      ValidationMethod: DNS

クローズドにするにあたっての問題点

Misskeyをクローズドに運用するにあたって、以下の問題に直面しました。

外部サーバから情報がみえてしまう

普通にMisskeyのサーバを構築した状態だと、外部のサーバから投稿を見たりフォローしたりができてしまいます。
以下は自身のMisskey.ioのアカウントから、今回構築した検証サーバのユーザを検索した結果です。
分かりにくいと思いますが、ActivityPubを利用して検証サーバのユーザ情報を正常に取得できています。
ただし、今回は取得されてしまうと困るので、接続を拒否する必要があります。

また、もし外部のサーバとうっかり連合してしまうと、サーバ内の情報が連合先のサーバにpostされてしまうらしいので、連合しないような設定も行う必要があります。

アカウントを持っていない人でもホーム画面からサーバ内情報を確認できる

画像の通り、ログインをしなくてもトップ画面からタイムラインが筒抜けです。

上記についてはベースロールからLTL、GTLを無効化することで簡単に表示を消せるのですが、同じ画面から「サーバー情報」をクリックすると、ユーザーやチャンネルなどは結局見えてしまいます。

アカウントを持っていない人でも「サーバ名/timeline」や「サーバ名/search」で検索をかけるとサーバ内情報を確認できる

先ほどと同じような問題なのですが、例えば以下のようにhttps://サーバ名/searchで検索をかけると、検索画面に入れてしまいますし、そのままノートやユーザーの検索などもできてしまいます。

APIを使うとサーバ内の情報を引っ張ってこれてしまう

APIを利用することで、一部情報を引っ張ってくることが可能です。
以下のリファレンスにも記載の通り、APIの認証キーが無くても持ってこれる情報があります。
https://misskey.dev/api-doc
例えばノートの情報は「Credential required」が No となっているため、アカウントを持っていなくても以下のAPIを叩けば誰でも最新の投稿を取得できます。

$ curl -s https://サーバ名/api/notes -H 'content-type: application/json' --data-raw "{\"limit\":10}" | jq -r .[0].user.name,.[0].text
pessi
検証環境Misskeyへのテスト投稿!!!

試してみた対応

サーバの連合を無効化する

  1. 管理者アカウントでコントロールパネル - 設定 - 全般 - 連合を「なし」に設定
  2. Misskeyの設定ファイルから、proxyに存在しないプロキシを設定する
    # Proxy for HTTP/HTTPS
    - #proxy: http://127.0.0.1:3128
    + proxy: http://127.0.0.1:3128
    

上記にて、リモートサーバとの連合はできなくなっているはずです。

外部から情報を見えないようにする

NginxでBasic認証有効化

ログイン画面からでも内部の情報を取得できてしまうため、不格好ですがBasic認証を有効化してアカウントを知っている人以外はログイン画面を開けないようにします。
(認証目的ではなく画面隠し目的でBasic認証を利用)

sudo apt install apache2-utils
sudo htpasswd -c /etc/nginx/.htpasswd 【ユーザ名】

nginx設定ファイルの修正(/etc/nginx/conf.d/misskey.conf)

~~~(略)~~~
location / {
+    auth_basic      "Auth";
+    auth_basic_user_file    "/etc/nginx/.htpasswd";
~~~(略)~~~

この状態でアクセスを行うと、Basic認証画面を表示させることに成功しました。

しかし、Basic認証後のページ読み込みでエラーが表示されます。
どうやらAPIがBasic認証に失敗して必要な情報を持ってこれずエラーとなるようです。

APIでBasic認証を回避する

location / の設定をコピーしてlocation /api を作成し、こちらにはベーシック認証の設定を記載しないようにします。
ついでにエラーがでていたので、manifest.jsonも追加しておきます。

~~~(略)~~~
location / {
+    auth_basic      "Auth";
+    auth_basic_user_file    "/etc/nginx/.htpasswd";
~~~(略)~~~
}
+ location /api {
-    auth_basic      "Auth";
-    auth_basic_user_file    "/etc/nginx/.htpasswd";
~~~(以下 "location /" と同様の設定)~~~
+ location /manifest.json {
-    auth_basic      "Auth";
-    auth_basic_user_file    "/etc/nginx/.htpasswd";
~~~(以下 "location /" と同様の設定)~~~

こうすることで、ログイン画面はBasic認証で隠しつつ、一度Basic認証にさえ通れば通常の画面が見えるようになります。
しかし、これではAPIがBasic認証を回避してしまうので、先ほど説明した通り、アカウントを持っていなくてもAPI経由で情報を引っ張れてしまいます。
$ curl -s https://サーバ名/api/notes -H 'content-type: application/json' --data-raw "{\"limit\":10}" | jq -r .[0].user.name,.[0].text

Misskeyと特定されにくいドメイン名にしておけばAPIを叩かれる可能性は低くなると思いますが、とはいえやはり不安が残ります。

結論

Basic認証を利用してGUIのWeb画面上では外部非公開のMisskeyサーバを構築できましたが、APIから情報が取れてしまう問題については簡単に解消することは難しそうでした。
問題もなにも、そもそも外部に情報がオープンになっている状態が本来は正常なので、ソースを書き換えない限り対応は難しいかもしれません。

プライベートなネットワークに構築してVPNで接続に行ければいいのですが、そこまで手間をかけて構築したいものでもなかったため、今回は構築を見送ることにしました。
もしうまい回避方法があれば教えていただけますと幸いです。

Discussion